@exodus/ethereum-api 8.38.1 → 8.40.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/CHANGELOG.md CHANGED
@@ -3,6 +3,26 @@
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.40.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.39.0...@exodus/ethereum-api@8.40.0) (2025-06-26)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: enable ws reconnection and timeout fallback to rest on clarity (#5900)
13
+
14
+
15
+
16
+ ## [8.39.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.38.1...@exodus/ethereum-api@8.39.0) (2025-06-20)
17
+
18
+
19
+ ### Features
20
+
21
+
22
+ * feat: add `isPrivate` flag to `txSend` and export `transactionPrivacy` feature (#5906)
23
+
24
+
25
+
6
26
  ## [8.38.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.38.0...@exodus/ethereum-api@8.38.1) (2025-06-19)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.38.1",
3
+ "version": "8.40.0",
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",
@@ -53,6 +53,7 @@
53
53
  "@exodus/bsc-meta": "^2.1.2",
54
54
  "@exodus/ethereumarbone-meta": "^2.0.3",
55
55
  "@exodus/fantommainnet-meta": "^2.0.2",
56
+ "@exodus/matic-meta": "^2.2.6",
56
57
  "@exodus/rootstock-meta": "^2.0.3"
57
58
  },
58
59
  "bugs": {
@@ -62,5 +63,5 @@
62
63
  "type": "git",
63
64
  "url": "git+https://github.com/ExodusMovement/assets.git"
64
65
  },
65
- "gitHead": "29460910f5183b5f2c5298f806e837050df0ecdf"
66
+ "gitHead": "8c81c64076e7092e8a1a0d7c9a51f410a94aeec9"
66
67
  }
@@ -1,6 +1,11 @@
1
1
  import assert from 'minimalistic-assert'
2
+ import ms from 'ms'
2
3
 
3
4
  import { createEvmServer, ValidMonitorTypes } from './exodus-eth-server/index.js'
5
+ import { createEthereumHooks } from './hooks/index.js'
6
+ import { ClarityMonitor } from './tx-log/clarity-monitor.js'
7
+ import { EthereumMonitor } from './tx-log/ethereum-monitor.js'
8
+ import { EthereumNoHistoryMonitor } from './tx-log/ethereum-no-history-monitor.js'
4
9
 
5
10
  // Determines the appropriate `monitorType`, `serverUrl` and `monitorInterval`
6
11
  // to use for a given config.
@@ -74,3 +79,63 @@ export const createBroadcastTxFactory = ({ assetName, server, privacyRpcUrl }) =
74
79
  broadcastPrivateTx: (...args) => privacyServer.sendRawTransaction(...args),
75
80
  }
76
81
  }
82
+
83
+ export const createHistoryMonitorFactory = ({
84
+ assetName,
85
+ assetClientInterface,
86
+ monitorInterval,
87
+ monitorType,
88
+ server,
89
+ stakingAssetNames,
90
+ }) => {
91
+ assert(assetName, 'expected assetName')
92
+ assert(assetClientInterface, 'expected assetClientInterface')
93
+ assert(monitorType, 'expected monitorType')
94
+ assert(server, 'expected server')
95
+ assert(Array.isArray(stakingAssetNames), 'expected array stakingAssetNames')
96
+
97
+ return (args) => {
98
+ let monitor
99
+ switch (monitorType) {
100
+ case 'clarity':
101
+ case 'clarity-v2':
102
+ monitor = new ClarityMonitor({
103
+ assetClientInterface,
104
+ interval: ms(monitorInterval || '5m'),
105
+ server,
106
+ ...args,
107
+ })
108
+ break
109
+ case 'no-history':
110
+ monitor = new EthereumNoHistoryMonitor({
111
+ assetClientInterface,
112
+ interval: ms(monitorInterval || '15s'),
113
+ server,
114
+ ...args,
115
+ })
116
+ break
117
+ case 'magnifier':
118
+ monitor = new EthereumMonitor({
119
+ assetClientInterface,
120
+ interval: ms(monitorInterval || '15s'),
121
+ server,
122
+ ...args,
123
+ })
124
+ break
125
+ default:
126
+ throw new Error(`Monitor type ${monitorType} of evm asset ${assetName} is unknown`)
127
+ }
128
+
129
+ if (stakingAssetNames.length > 0) {
130
+ const afterTickHook = createEthereumHooks({
131
+ assetClientInterface,
132
+ assetName,
133
+ stakingAssetNames,
134
+ server,
135
+ })['after-tick']
136
+ monitor.addHook('after-tick', afterTickHook)
137
+ }
138
+
139
+ return monitor
140
+ }
141
+ }
@@ -19,10 +19,13 @@ import {
19
19
  } from '@exodus/ethereum-lib'
20
20
  import lodash from 'lodash'
21
21
  import assert from 'minimalistic-assert'
22
- import ms from 'ms'
23
22
 
24
23
  import { addressHasHistoryFactory } from './address-has-history.js'
25
- import { createBroadcastTxFactory, resolveMonitorSettings } from './create-asset-utils.js'
24
+ import {
25
+ createBroadcastTxFactory,
26
+ createHistoryMonitorFactory,
27
+ resolveMonitorSettings,
28
+ } from './create-asset-utils.js'
26
29
  import { createTokenFactory } from './create-token-factory.js'
27
30
  import { createCustomFeesApi } from './custom-fees.js'
28
31
  import { createEvmServer } from './exodus-eth-server/index.js'
@@ -31,13 +34,9 @@ import { createGetBalanceForAddress } from './get-balance-for-address.js'
31
34
  import { getBalancesFactory } from './get-balances.js'
32
35
  import { getFeeFactory } from './get-fee.js'
33
36
  import getFeeAsyncFactory from './get-fee-async.js'
34
- import { createEthereumHooks } from './hooks/index.js'
35
37
  import { estimateL1DataFeeFactory, getL1GetFeeFactory } from './optimism-gas/index.js'
36
38
  import { serverBasedFeeMonitorFactoryFactory } from './server-based-fee-monitor.js'
37
39
  import { createStakingApi } from './staking-api.js'
38
- import { ClarityMonitor } from './tx-log/clarity-monitor.js'
39
- import { EthereumMonitor } from './tx-log/ethereum-monitor.js'
40
- import { EthereumNoHistoryMonitor } from './tx-log/ethereum-no-history-monitor.js'
41
40
  import { txSendFactory } from './tx-send/index.js'
42
41
  import { createWeb3API } from './web3/index.js'
43
42
 
@@ -171,6 +170,12 @@ export const createAssetFactory = ({
171
170
  server,
172
171
  })
173
172
 
173
+ const { broadcastTx, broadcastPrivateTx } = createBroadcastTxFactory({
174
+ assetName: asset.name,
175
+ server,
176
+ privacyRpcUrl,
177
+ })
178
+
174
179
  const features = {
175
180
  accountState: true,
176
181
  customTokens,
@@ -180,6 +185,7 @@ export const createAssetFactory = ({
180
185
  isTestnet,
181
186
  nfts,
182
187
  noHistory: monitorType === 'no-history',
188
+ transactionPrivacy: typeof broadcastPrivateTx === 'function',
183
189
  signWithSigner: true,
184
190
  signMessageWithSigner: true,
185
191
  supportsCustomFees,
@@ -201,50 +207,14 @@ export const createAssetFactory = ({
201
207
  const accountStateClass =
202
208
  CustomAccountState || createEthereumLikeAccountState({ asset: base, assets, extraData })
203
209
 
204
- const createHistoryMonitor = (args) => {
205
- let monitor
206
- switch (monitorType) {
207
- case 'clarity':
208
- case 'clarity-v2':
209
- monitor = new ClarityMonitor({
210
- assetClientInterface,
211
- interval: ms(monitorInterval || '5m'),
212
- server,
213
- ...args,
214
- })
215
- break
216
- case 'no-history':
217
- monitor = new EthereumNoHistoryMonitor({
218
- assetClientInterface,
219
- interval: ms(monitorInterval || '15s'),
220
- server,
221
- ...args,
222
- })
223
- break
224
- case 'magnifier':
225
- monitor = new EthereumMonitor({
226
- assetClientInterface,
227
- interval: ms(monitorInterval || '15s'),
228
- server,
229
- ...args,
230
- })
231
- break
232
- default:
233
- throw new Error(`Monitor type ${monitorType} of evm asset ${asset.name} is unknown`)
234
- }
235
-
236
- if (stakingAssetNames.length > 0) {
237
- const afterTickHook = createEthereumHooks({
238
- assetClientInterface,
239
- assetName: asset.name,
240
- stakingAssetNames,
241
- server,
242
- })['after-tick']
243
- monitor.addHook('after-tick', afterTickHook)
244
- }
245
-
246
- return monitor
247
- }
210
+ const createHistoryMonitor = createHistoryMonitorFactory({
211
+ assetName: asset.name,
212
+ assetClientInterface,
213
+ monitorInterval,
214
+ monitorType,
215
+ server,
216
+ stakingAssetNames,
217
+ })
248
218
 
249
219
  const defaultAddressPath = 'm/0/0'
250
220
 
@@ -266,12 +236,6 @@ export const createAssetFactory = ({
266
236
  ? getL1GetFeeFactory({ asset, originalGetFee })
267
237
  : originalGetFee
268
238
 
269
- const { broadcastTx, broadcastPrivateTx } = createBroadcastTxFactory({
270
- assetName: asset.name,
271
- server,
272
- privacyRpcUrl,
273
- })
274
-
275
239
  const api = {
276
240
  addressHasHistory,
277
241
  broadcastTx,
@@ -1,6 +1,7 @@
1
1
  import { retry } from '@exodus/simple-retry'
2
+ import assert from 'minimalistic-assert'
2
3
 
3
- import ClarityServer from './clarity.js'
4
+ import ClarityServer, { RPC_REQUEST_TIMEOUT } from './clarity.js'
4
5
 
5
6
  export const encodeCursor = (blockNumberBigInt, isLegacy = false) => {
6
7
  if (typeof blockNumberBigInt !== 'bigint') throw new Error('expected bigint')
@@ -50,6 +51,22 @@ const fetchJson = async (url, fetchOptions) => {
50
51
  return response.json()
51
52
  }
52
53
 
54
+ const fetchHttpRequest = ({ baseApiPath, path, method, body }) => {
55
+ assert(typeof baseApiPath === 'string', 'expected string baseApiPath')
56
+
57
+ const url = new URL(`${baseApiPath}${path}`)
58
+ const fetchOptions = {
59
+ method,
60
+ headers: { 'Content-Type': 'application/json' },
61
+ }
62
+
63
+ if (body) fetchOptions.body = JSON.stringify(body)
64
+ return fetchJson(url, fetchOptions)
65
+ }
66
+
67
+ const fetchRpcHttpRequest = ({ baseApiPath, body }) =>
68
+ fetchHttpRequest({ baseApiPath, path: '/rpc', method: 'POST', body })
69
+
53
70
  async function fetchJsonRetry(url, fetchOptions) {
54
71
  const waitTimes = ['3s']
55
72
  const fetchWithRetry = retry(fetchJson, { delayTimesMs: waitTimes })
@@ -78,6 +95,48 @@ export default class ClarityServerV2 extends ClarityServer {
78
95
  return fetchJsonRetry(url)
79
96
  }
80
97
 
98
+ // Clarity exposes a new way to determine fee data for EIP-1559
99
+ // networks. In addition to the `nextBlockBaseFeePerGas`, we also
100
+ // receive a `rewardPercentiles` mapping, which defines the prevailing
101
+ // 25th, 50th and 75th percentiles of `maxPriorityFeePerGas` for
102
+ // an EVM-based network.
103
+ async getGasPriceEstimation() {
104
+ return fetchJson(`${this.baseApiPath}/gas-price-estimation`)
105
+ }
106
+
107
+ async getFee() {
108
+ // HACK: When we call `getFee`, we should still
109
+ // subscribe for period fee updates.
110
+ await this.connectFee()
111
+
112
+ try {
113
+ // NOTE: Depending on whether the network is `eip1559Enabled`
114
+ // **on the backend**, this can omit certain
115
+ // properties.
116
+ //
117
+ // By default, we should receive:
118
+ // + blockNumber
119
+ // + gasPrice
120
+ // + baseGasPrice (deprecated)
121
+ //
122
+ // In addition, when `eip1559Enabled` on Clarity:
123
+ // + baseFeePerGas
124
+ // + nextBaseFeePerGas
125
+ // + rewardPercentiles
126
+ //
127
+ // See: https://github.com/ExodusMovement/clarity/blob/d3c2a7f501a4391da630592bca3bf57c3ddd5e89/src/modules/ethereum-like/gas-price/index.js#L192C5-L219C6
128
+ return await this.getGasPriceEstimation()
129
+ } catch {
130
+ console.log(
131
+ `failed to query ${this.baseAssetName} gas-price-estimation endpoint, falling back to websocket`
132
+ )
133
+ // HACK: The `getGasPriceEstimation` endpoint is not guaranteed
134
+ // to exist for all assets. In this case, we'll fallback
135
+ // to legacy behaviour, which is to query via the WebSocket.
136
+ return this.getFeeFromWebSocket()
137
+ }
138
+ }
139
+
81
140
  async getAllTransactions({ address, cursor }) {
82
141
  let { blockNumber } = decodeCursor(cursor)
83
142
  blockNumber = blockNumber.toString()
@@ -125,40 +184,29 @@ export default class ClarityServerV2 extends ClarityServer {
125
184
  }
126
185
  }
127
186
 
128
- async sendHttpRequest({ path, method, body }) {
129
- const url = new URL(`${this.baseApiPath}${path}`)
130
- const fetchOptions = {
131
- method,
132
- headers: {
133
- 'Content-Type': 'application/json',
134
- },
135
- }
187
+ async sendRpcRequest(rpcRequest) {
188
+ try {
189
+ return await super.sendRpcRequest(rpcRequest)
190
+ } catch (err) {
191
+ // If we encounter an error which isn't associated
192
+ // with transport timeouts, then bubble up.
193
+ if (err.message !== RPC_REQUEST_TIMEOUT) throw err
136
194
 
137
- if (body) {
138
- fetchOptions.body = JSON.stringify(body)
195
+ const { baseApiPath } = this
196
+ return fetchRpcHttpRequest({ baseApiPath, body: rpcRequest })
139
197
  }
140
-
141
- const response = await fetchJson(url, fetchOptions)
142
-
143
- return this.handleJsonRPCResponse(response)
144
198
  }
145
199
 
146
200
  async sendRawTransaction(...params) {
201
+ const { baseApiPath } = this
147
202
  const request = this.sendRawTransactionRequest(...params)
148
- return this.sendHttpRequest({
149
- path: '/rpc',
150
- method: 'POST',
151
- body: request,
152
- })
203
+ return this.handleJsonRPCResponse(await fetchRpcHttpRequest({ baseApiPath, body: request }))
153
204
  }
154
205
 
155
206
  async getTransactionCount(...params) {
156
207
  // nonce is called during tx send, use it in rest api
208
+ const { baseApiPath } = this
157
209
  const request = this.getTransactionCountRequest(...params)
158
- return this.sendHttpRequest({
159
- path: '/rpc',
160
- method: 'POST',
161
- body: request,
162
- })
210
+ return this.handleJsonRPCResponse(await fetchRpcHttpRequest({ baseApiPath, body: request }))
163
211
  }
164
212
  }
@@ -5,6 +5,8 @@ import io from 'socket.io-client'
5
5
 
6
6
  import { fromHexToString } from '../number-utils.js'
7
7
 
8
+ export const RPC_REQUEST_TIMEOUT = 'RPC_REQUEST_TIMEOUT'
9
+
8
10
  export default class ClarityServer extends EventEmitter {
9
11
  constructor({ baseAssetName, uri }) {
10
12
  super()
@@ -56,6 +58,7 @@ export default class ClarityServer extends EventEmitter {
56
58
  return io(`${this.uri}${namespace}`, {
57
59
  transports: ['websocket', 'polling'],
58
60
  extraHeaders: { 'User-Agent': 'exodus' },
61
+ reconnection: true,
59
62
  })
60
63
  }
61
64
 
@@ -137,10 +140,10 @@ export default class ClarityServer extends EventEmitter {
137
140
  .finally(() => socket.off('transactionsChunk', listener))
138
141
  }
139
142
 
140
- async getFee() {
143
+ getFeeFromWebSocket() {
141
144
  const socket = this.connectFee()
142
145
  return new Promise((resolve, reject) => {
143
- const timeout = setTimeout(() => reject(new Error('Fee Timeout')), 30_000)
146
+ const timeout = setTimeout(() => reject(new Error('Fee Timeout')), 3000)
144
147
  socket.emit('getFee', (fee) => {
145
148
  clearTimeout(timeout)
146
149
  if (!fee) {
@@ -153,6 +156,10 @@ export default class ClarityServer extends EventEmitter {
153
156
  })
154
157
  }
155
158
 
159
+ async getFee() {
160
+ return this.getFeeFromWebSocket()
161
+ }
162
+
156
163
  // for fee monitors
157
164
  async getGasPrice() {
158
165
  const fee = await this.getFee()
@@ -162,7 +169,7 @@ export default class ClarityServer extends EventEmitter {
162
169
  async sendRpcRequest(rpcRequest) {
163
170
  const rpcSocket = this.connectRpc()
164
171
  return new Promise((resolve, reject) => {
165
- const timeout = setTimeout(() => reject(new Error('Rpc Timeout')), 30_000)
172
+ const timeout = setTimeout(() => reject(new Error(RPC_REQUEST_TIMEOUT)), 3000)
166
173
  rpcSocket.emit('request', rpcRequest, (response) => {
167
174
  clearTimeout(timeout)
168
175
  resolve(response)
package/src/fee-utils.js CHANGED
@@ -1,10 +1,20 @@
1
1
  import assert from 'minimalistic-assert'
2
2
 
3
- const applyMultiplierToPriceInWei = ({ feeAsset, feeData, priceInWei }) => {
4
- assert(typeof priceInWei === 'string', 'gasPriceInWei should be a string')
3
+ export const shouldFetchEthLikeFallbackGasPrices = async ({ eip1559Enabled, server }) => {
4
+ const [gasPrice, baseFeePerGas] = await Promise.all([
5
+ server.getGasPrice(),
6
+ eip1559Enabled ? server.getBaseFeePerGas() : undefined,
7
+ ])
5
8
 
6
- const gasPriceMultiplier = feeData.gasPriceMultiplier || 1
7
- return feeAsset.currency.parse(priceInWei).mul(gasPriceMultiplier).toBaseString({ unit: true })
9
+ return { gasPrice, baseFeePerGas }
10
+ }
11
+
12
+ export const applyMultiplierToPrice = ({ feeAsset, gasPriceMultiplier, price }) => {
13
+ assert(typeof price === 'string', 'price should be a string')
14
+ return feeAsset.currency
15
+ .parse(price)
16
+ .mul(gasPriceMultiplier || 1)
17
+ .toBaseString({ unit: true })
8
18
  }
9
19
 
10
20
  /**
@@ -15,18 +25,18 @@ const applyMultiplierToPriceInWei = ({ feeAsset, feeData, priceInWei }) => {
15
25
  * TODO: We shouldn't actually do this, lol. The closer we are to
16
26
  * the node, the better!
17
27
  */
18
- export const rewriteFeeConfig = ({ feeAsset, feeConfig, feeData }) => {
28
+ export const rewriteFeeConfig = ({ feeAsset, feeConfig, gasPriceMultiplier }) => {
19
29
  try {
20
30
  const { gasPrice, baseFeePerGas, ...extras } = feeConfig
21
31
  return {
22
32
  ...extras,
23
- gasPrice: applyMultiplierToPriceInWei({
33
+ gasPrice: applyMultiplierToPrice({
24
34
  feeAsset,
25
- feeData,
26
- priceInWei: gasPrice,
35
+ gasPriceMultiplier,
36
+ price: gasPrice,
27
37
  }) /* required */,
28
38
  baseFeePerGas: baseFeePerGas
29
- ? applyMultiplierToPriceInWei({ feeAsset, feeData, priceInWei: baseFeePerGas })
39
+ ? applyMultiplierToPrice({ feeAsset, gasPriceMultiplier, price: baseFeePerGas })
30
40
  : undefined,
31
41
  }
32
42
  } catch (e) {
@@ -38,6 +48,154 @@ export const rewriteFeeConfig = ({ feeAsset, feeConfig, feeData }) => {
38
48
  }
39
49
  }
40
50
 
51
+ const canNormalizeToBigInt = (val) =>
52
+ typeof val === 'string' || typeof val === 'number' || typeof val === 'bigint'
53
+
54
+ // Returns a `bigint`ified version of the supplied value.
55
+ export const ensureBigInt = (val) => {
56
+ assert(canNormalizeToBigInt(val), `unable to normalize ${String(val)}`)
57
+ return BigInt(val)
58
+ }
59
+
60
+ export const getOptionalBigInt = (fetchedGasPrices, prop) => {
61
+ const val = fetchedGasPrices?.[prop]
62
+ return canNormalizeToBigInt(val) ? BigInt(val) : null
63
+ }
64
+
65
+ const calculateEthLikeFeeMonitorUpdateNonEip1559 = async ({ gasPrice }) => ({
66
+ gasPrice: `${gasPrice.toString()} wei`,
67
+ })
68
+
69
+ export const getBaseFeePerGasFromServerOrNull = async ({ feeAsset }) => {
70
+ try {
71
+ const { server } = feeAsset
72
+ return ensureBigInt(await server.getBaseFeePerGas())
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+
78
+ // HACK: If the `baseFeePerGas` has not been defined for a network,
79
+ // or due to issues with the `baseFeePerGas` being returned
80
+ // as `0` from WebSockets, we'll make a fresh attempt to query
81
+ // . the RPC here. If that still doesn't work we'll return `null`.
82
+ const getBaseFeePerGasOrNull = async ({ feeAsset, fetchedGasPrices }) => {
83
+ const baseFeePerGas = getOptionalBigInt(fetchedGasPrices, 'baseFeePerGas')
84
+
85
+ if (typeof baseFeePerGas === 'bigint' && baseFeePerGas > BigInt('0')) return baseFeePerGas
86
+
87
+ return getBaseFeePerGasFromServerOrNull({ feeAsset })
88
+ }
89
+
90
+ export const calculateEthLikeFeeMonitorUpdateEip1559 = async ({
91
+ feeAsset,
92
+ fetchedGasPrices,
93
+ gasPrice: defaultGasPrice,
94
+ }) => {
95
+ // Determine the `baseFeePerGas`, if possible.
96
+ let baseFeePerGas = await getBaseFeePerGasOrNull({ feeAsset, fetchedGasPrices })
97
+
98
+ // Some backends will return a `nextBaseFeePerGas`, which
99
+ // provides us more information about pricing in an upcoming
100
+ // block.
101
+ //
102
+ // As a precaution, rather than choose this value unconditionally,
103
+ // we'll aim to use the larger between the two (where defined).
104
+ const nextBaseFeePerGas = getOptionalBigInt(fetchedGasPrices, 'nextBaseFeePerGas')
105
+ if (typeof nextBaseFeePerGas === 'bigint' && nextBaseFeePerGas > BigInt(baseFeePerGas || 0)) {
106
+ baseFeePerGas = nextBaseFeePerGas
107
+ }
108
+
109
+ // Knowing the `baseFeePerGas` is required for EIP-1559 networks,
110
+ // so we if we haven't found one, we cannot continue.
111
+ if (typeof baseFeePerGas !== 'bigint') throw new Error('expected baseFeePerGas')
112
+
113
+ // HACK: Enforce our expectations that the `gasPrice` must be
114
+ // at least the `baseFeePerGas`.
115
+ const gasPrice = baseFeePerGas > defaultGasPrice ? baseFeePerGas : defaultGasPrice
116
+
117
+ const defaultFeeConfig = {
118
+ gasPrice: `${gasPrice.toString()} wei`,
119
+ baseFeePerGas: `${baseFeePerGas.toString()} wei`,
120
+ }
121
+
122
+ // Determine if there are any `rewardPercentiles` specified in the
123
+ // `fetchedGasPrices`. If there's nothing, we can continue as normal.
124
+ if (!fetchedGasPrices.hasOwnProperty('rewardPercentiles')) return defaultFeeConfig
125
+
126
+ // If `rewardPercentiles` have been defined, we aim to consume the
127
+ // `50`th reward percentile, and use this as the `maxPriorityFeePerGas`.
128
+ const maxPriorityFeePerGas50Percentile = getOptionalBigInt(
129
+ fetchedGasPrices.rewardPercentiles,
130
+ '50'
131
+ )
132
+
133
+ // NOTE: We do not use a `maxPriorityFeePerGas50Percentile === 0n` as
134
+ // a condition for early termination, since this is a valid
135
+ // (although unlikely) condition.
136
+ if (typeof maxPriorityFeePerGas50Percentile !== 'bigint') return defaultFeeConfig
137
+
138
+ return {
139
+ ...defaultFeeConfig,
140
+ tipGasPrice: `${maxPriorityFeePerGas50Percentile.toString()} wei`,
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The core logic for normalizing and interpreting cross-server
146
+ * fee information.
147
+ */
148
+ export const calculateEthLikeFeeMonitorUpdate = async ({
149
+ assetClientInterface,
150
+ feeAsset,
151
+ fetchedGasPrices,
152
+ }) => {
153
+ assert(assetClientInterface, 'expected assetClientInterface')
154
+ assert(feeAsset, 'expected feeAsset')
155
+ assert(fetchedGasPrices, 'expected fetchedGasPrices')
156
+
157
+ // Depending on whether `eip1559Enabled` on the client, we
158
+ // can choose to return different properties.
159
+ const { eip1559Enabled, gasPriceMultiplier } = await assetClientInterface.getFeeConfig({
160
+ assetName: feeAsset.name,
161
+ })
162
+
163
+ // NOTE: The `gasPrice` is a required value for all EVM networks.
164
+ const gasPrice = ensureBigInt(fetchedGasPrices.gasPrice)
165
+
166
+ const feeConfig = await (eip1559Enabled
167
+ ? calculateEthLikeFeeMonitorUpdateEip1559({ gasPrice, feeAsset, fetchedGasPrices })
168
+ : calculateEthLikeFeeMonitorUpdateNonEip1559({ gasPrice }))
169
+
170
+ // HACK: We use `rewriteFeeConfig` to apply some additional padding
171
+ // to our `gasPrice` and `baseFeePerGas` values. Once we're
172
+ // feeling confident, we should be safe to skip doing this for
173
+ // `eip1559Enabled` chains, since we should be able to guarantee
174
+ // next block inclusion with accuracy.
175
+ return rewriteFeeConfig({ feeAsset, feeConfig, gasPriceMultiplier })
176
+ }
177
+
178
+ /**
179
+ * A common handler for normalizing the handling of `FeeMonitor` updates.
180
+ */
181
+ export const executeEthLikeFeeMonitorUpdate = async ({
182
+ assetClientInterface,
183
+ feeAsset,
184
+ fetchedGasPrices,
185
+ }) => {
186
+ const nextFeeConfig = await calculateEthLikeFeeMonitorUpdate({
187
+ assetClientInterface,
188
+ feeAsset,
189
+ fetchedGasPrices,
190
+ })
191
+
192
+ // Update the `assetClientInterface` so our updated fee
193
+ // settings propagate through the client.
194
+ await assetClientInterface.updateFeeConfig({ assetName: feeAsset.name, feeConfig: nextFeeConfig })
195
+
196
+ return nextFeeConfig
197
+ }
198
+
41
199
  export const resolveGasPrice = ({ feeData }) => {
42
200
  assert(feeData, 'feeData is required')
43
201
 
@@ -1,8 +1,10 @@
1
1
  import { FeeMonitor } from '@exodus/asset-lib'
2
2
  import assert from 'minimalistic-assert'
3
3
 
4
- import { rewriteFeeConfig } from './fee-utils.js'
5
- import { fromHexToString } from './number-utils.js'
4
+ import {
5
+ calculateEthLikeFeeMonitorUpdate,
6
+ shouldFetchEthLikeFallbackGasPrices,
7
+ } from './fee-utils.js'
6
8
 
7
9
  /**
8
10
  * Generic eth server based fee monitor.
@@ -23,6 +25,11 @@ export const serverBasedFeeMonitorFactoryFactory = ({ asset, interval, server, a
23
25
  assert(server, 'server is required')
24
26
  assert(aci, 'aci is required')
25
27
 
28
+ const shouldFetchGasPrices = async () => {
29
+ const { eip1559Enabled } = await aci.getFeeConfig({ assetName: asset.name })
30
+ return shouldFetchEthLikeFallbackGasPrices({ eip1559Enabled, server })
31
+ }
32
+
26
33
  const FeeMonitorClass = class ServerBaseEthereumFeeMonitor extends FeeMonitor {
27
34
  constructor({ updateFee }) {
28
35
  assert(updateFee, 'updateFee is required')
@@ -34,15 +41,11 @@ export const serverBasedFeeMonitorFactoryFactory = ({ asset, interval, server, a
34
41
  }
35
42
 
36
43
  async fetchFee() {
37
- const feeData = await aci.getFeeConfig({ assetName: asset.name })
38
- const eip1559Enabled = feeData.eip1559Enabled
39
- const [gasPrice, baseFeePerGas] = await Promise.all([
40
- server.getGasPrice().then((wei) => `${fromHexToString(wei)} wei`),
41
- eip1559Enabled ? server.getBaseFeePerGas().then((wei) => `${wei} wei`) : undefined,
42
- ])
43
-
44
- const feeConfig = { gasPrice, baseFeePerGas }
45
- return rewriteFeeConfig({ feeAsset: asset, feeConfig, feeData })
44
+ return calculateEthLikeFeeMonitorUpdate({
45
+ assetClientInterface: aci,
46
+ feeAsset: asset,
47
+ fetchedGasPrices: await shouldFetchGasPrices(),
48
+ })
46
49
  }
47
50
  }
48
51
  return (...args) => new FeeMonitorClass(...args)
@@ -2,7 +2,7 @@ import { BaseMonitor } from '@exodus/asset-lib'
2
2
  import { getAssetAddresses, isRpcBalanceAsset } from '@exodus/ethereum-lib'
3
3
  import lodash from 'lodash'
4
4
 
5
- import { rewriteFeeConfig } from '../fee-utils.js'
5
+ import { executeEthLikeFeeMonitorUpdate } from '../fee-utils.js'
6
6
  import { fromHexToString } from '../number-utils.js'
7
7
  import { filterEffects, getLogItemsFromServerTx } from './clarity-utils/index.js'
8
8
  import {
@@ -286,17 +286,13 @@ export class ClarityMonitor extends BaseMonitor {
286
286
  return [...set]
287
287
  }
288
288
 
289
- async updateGasPrice({ gasPrice, baseGasPrice: baseFeePerGas }) {
289
+ // NOTE: Here, fetchedGasPrices is the result of a call to `ClarityMonitor.getFee()`.
290
+ async updateGasPrice(fetchedGasPrices) {
290
291
  try {
291
- const feeData = await this.aci.getFeeConfig({ assetName: this.asset.name })
292
-
293
- const feeConfig = {
294
- gasPrice: `${fromHexToString(gasPrice)} wei`,
295
- baseFeePerGas: baseFeePerGas ? `${fromHexToString(baseFeePerGas)} wei` : undefined,
296
- }
297
- await this.aci.updateFeeConfig({
298
- assetName: this.asset.name,
299
- feeConfig: rewriteFeeConfig({ feeAsset: this.asset, feeData, feeConfig }),
292
+ await executeEthLikeFeeMonitorUpdate({
293
+ assetClientInterface: this.aci,
294
+ feeAsset: this.asset,
295
+ fetchedGasPrices,
300
296
  })
301
297
  } catch (e) {
302
298
  this.logger.warn('error updating gasPrice', e)
@@ -2,7 +2,7 @@ import { BaseMonitor } from '@exodus/asset-lib'
2
2
  import { getAssetAddresses, isRpcBalanceAsset } from '@exodus/ethereum-lib'
3
3
  import lodash from 'lodash'
4
4
 
5
- import { rewriteFeeConfig } from '../fee-utils.js'
5
+ import { executeEthLikeFeeMonitorUpdate } from '../fee-utils.js'
6
6
  import { fromHexToString } from '../number-utils.js'
7
7
  import {
8
8
  checkPendingTransactions,
@@ -199,30 +199,12 @@ export class EthereumMonitor extends BaseMonitor {
199
199
  }
200
200
  }
201
201
 
202
- async updateGasPrice({ gasPrice, baseFeePerGas }) {
202
+ async updateGasPrice(fetchedGasPrices) {
203
203
  try {
204
- const feeData = await this.aci.getFeeConfig({ assetName: this.asset.name })
205
-
206
- const resolveBaseFeePerGas = () => {
207
- if (!feeData.eip1559Enabled) {
208
- return
209
- }
210
-
211
- if (baseFeePerGas === 0) {
212
- // In some cases, web socker sends 0 baseFeePerGas!!
213
- return this.server.getBaseFeePerGas().then((wei) => `${wei} wei`)
214
- }
215
-
216
- return `${fromHexToString(baseFeePerGas)} wei`
217
- }
218
-
219
- const feeConfig = {
220
- gasPrice: `${fromHexToString(gasPrice)} wei`,
221
- baseFeePerGas: await resolveBaseFeePerGas(),
222
- }
223
- await this.aci.updateFeeConfig({
224
- assetName: this.asset.name,
225
- feeConfig: rewriteFeeConfig({ feeAsset: this.asset, feeConfig, feeData }),
204
+ await executeEthLikeFeeMonitorUpdate({
205
+ assetClientInterface: this.aci,
206
+ feeAsset: this.asset,
207
+ fetchedGasPrices,
226
208
  })
227
209
  } catch (e) {
228
210
  this.logger.warn('error updating gasPrice', e)
@@ -79,6 +79,7 @@ const txSendFactory = ({ assetClientInterface, createUnsignedTx, useAbsoluteBala
79
79
  keepTxInput,
80
80
  isSendAll,
81
81
  isHardware,
82
+ isPrivate,
82
83
  } = options
83
84
  let { txInput, feeAmount: providedFeeAmount } = options // avoid let!
84
85
 
@@ -230,8 +231,15 @@ const txSendFactory = ({ assetClientInterface, createUnsignedTx, useAbsoluteBala
230
231
 
231
232
  let { txId, rawTx, nonce, gasLimit, tipGasPrice, feeAmount } = await createTx(createTxParams)
232
233
 
234
+ if (isPrivate && !baseAsset.api.features.transactionPrivacy)
235
+ throw new Error(
236
+ `unable to send private transaction - transactionPrivacy is not enabled for ${baseAsset.name}`
237
+ )
238
+
239
+ const broadcastTx = isPrivate ? baseAsset.broadcastPrivateTx : baseAsset.api.broadcastTx
240
+
233
241
  try {
234
- await baseAsset.api.broadcastTx(rawTx.toString('hex'))
242
+ await broadcastTx(rawTx.toString('hex'))
235
243
  } catch (err) {
236
244
  const nonceTooLowErr = err.message.match(/nonce (is |)too low/i)
237
245
  const insufficientFundsErr = err.message.match(/insufficient funds/i)