@exodus/ethereum-api 8.78.1 → 8.79.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 +65 -0
- package/package.json +3 -3
- package/src/allowance/index.js +1 -3
- package/src/create-asset-utils.js +31 -2
- package/src/ens/index.js +0 -2
- package/src/exodus-eth-server/api-coin-nodes.js +7 -2
- package/src/exodus-eth-server/api.js +3 -3
- package/src/exodus-eth-server/clarity.js +3 -3
- package/src/exodus-eth-server/eth-like-server.js +17 -9
- package/src/exodus-eth-server/index.js +0 -6
- package/src/exodus-eth-server/ws-gateway.js +2 -2
- package/src/fee-data/EthLikeFeeData.js +0 -2
- package/src/fee-utils.js +9 -0
- package/src/index.js +10 -9
- package/src/simulate-tx/simulate-eth-tx.js +19 -6
- package/src/simulation/simulate-message-api.js +1 -1
- package/src/simulation/simulate-transactions-api.js +1 -1
- package/src/staking/ethereum/service.js +3 -3
- package/src/staking/utils/index.js +0 -2
- package/src/tx-log/clarity-monitor.js +62 -24
- package/src/tx-log/clarity-truncated-history-monitor.js +3 -1
- package/src/tx-log/ethereum-monitor.js +54 -23
- package/src/tx-log/get-optimistic-txlog-effects.js +1 -1
- package/src/tx-log/monitor-utils/check-pending-transactions.js +12 -3
- package/src/tx-log/monitor-utils/get-derive-transactions-to-check.js +7 -3
- package/src/tx-log/monitor-utils/index.js +1 -0
- package/src/tx-log/monitor-utils/is-unverifiable-pending-tx-expired.js +19 -0
- package/src/tx-log/monitor-utils/verify-pending-tx-status-rpc.js +34 -11
- package/src/tx-log-staking-processor/utils.js +1 -1
- package/src/tx-send/broadcast-error-handler.js +2 -2
- package/src/tx-send/tx-send.js +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,71 @@
|
|
|
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.79.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.77.1...@exodus/ethereum-api@8.79.0) (2026-08-18)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
* feat: source eip7702 delegation whitelist from remote config (#8356)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
* fix: disable @exodus/hydra/no-param-reassign-prop-only warnings (#8531)
|
|
19
|
+
|
|
20
|
+
* fix(ethereum-api): detect legacy delegate transactions (#8357)
|
|
21
|
+
|
|
22
|
+
* fix(ethereum-api): preserve live pending txs across refresh to prevent nonce reuse (#8430)
|
|
23
|
+
|
|
24
|
+
* fix(ethereum-api): preserve precision for wei values in tx simulation (#7920)
|
|
25
|
+
|
|
26
|
+
* fix(ethereum-api): reference defined ethereum asset in simulate-tx tests (#8525)
|
|
27
|
+
|
|
28
|
+
* fix(ethereum): prevent silently dropped zero-tip private bundles (#8493)
|
|
29
|
+
|
|
30
|
+
* fix(lint): add explicit process imports and enforce no-implicit-process (#8530)
|
|
31
|
+
|
|
32
|
+
* fix(lint): convert console.log/info to console.debug and enforce no-console (#8529)
|
|
33
|
+
|
|
34
|
+
* fix(lint): fix import/named warnings and upgrade to error (#8496)
|
|
35
|
+
|
|
36
|
+
* fix(lint): fix JSDoc warnings across multiple packages (#8519)
|
|
37
|
+
|
|
38
|
+
* fix(lint): fix no-throw-literal warnings and upgrade to error (#8498)
|
|
39
|
+
|
|
40
|
+
* fix(lint): fix oxlint warnings and convert rules to error (#8489)
|
|
41
|
+
|
|
42
|
+
* fix(simulation): route recipient changes to dedicated field, guard asset-less entries (#8370)
|
|
43
|
+
|
|
44
|
+
* fix: upgrade low-complexity lint rules to error (#8510)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
## [8.78.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.77.1...@exodus/ethereum-api@8.78.2) (2026-08-14)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
### Features
|
|
52
|
+
|
|
53
|
+
* source eip7702 delegation whitelist from remote config ([#8356](https://github.com/ExodusMovement/assets/issues/8356)) ([1a0966b](https://github.com/ExodusMovement/assets/commit/1a0966b08befb6e9310993f323c21d003dea2754))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
### Bug Fixes
|
|
57
|
+
|
|
58
|
+
* **ethereum-api:** detect legacy delegate transactions ([#8357](https://github.com/ExodusMovement/assets/issues/8357)) ([070e529](https://github.com/ExodusMovement/assets/commit/070e5295f21f12695e97e721ff8c0601c553e447))
|
|
59
|
+
* **ethereum-api:** preserve precision for wei values in tx simulation ([#7920](https://github.com/ExodusMovement/assets/issues/7920)) ([5c99258](https://github.com/ExodusMovement/assets/commit/5c9925871d450167fcf23e33ba62e100a2d71a9b))
|
|
60
|
+
* **ethereum-api:** reference defined ethereum asset in simulate-tx tests ([#8525](https://github.com/ExodusMovement/assets/issues/8525)) ([27a8918](https://github.com/ExodusMovement/assets/commit/27a8918f5ba3112f1830ac0111a43d30921aa3b3))
|
|
61
|
+
* **ethereum:** prevent silently dropped zero-tip private bundles ([#8493](https://github.com/ExodusMovement/assets/issues/8493)) ([67dd1ff](https://github.com/ExodusMovement/assets/commit/67dd1ffd7e1c22e7119b1781968c4be321ab1822))
|
|
62
|
+
* **lint:** fix import/named warnings and upgrade to error ([#8496](https://github.com/ExodusMovement/assets/issues/8496)) ([8d290c9](https://github.com/ExodusMovement/assets/commit/8d290c948a0d3c2c1897287cb133f9d26cad73b0))
|
|
63
|
+
* **lint:** fix JSDoc warnings across multiple packages ([#8519](https://github.com/ExodusMovement/assets/issues/8519)) ([5fefcd5](https://github.com/ExodusMovement/assets/commit/5fefcd5af996efbe9587cecba540efc7f469ceb2))
|
|
64
|
+
* **lint:** fix no-throw-literal warnings and upgrade to error ([#8498](https://github.com/ExodusMovement/assets/issues/8498)) ([8fc68eb](https://github.com/ExodusMovement/assets/commit/8fc68eb65682e0b5a7da9415410ee1dedf309696))
|
|
65
|
+
* **lint:** fix oxlint warnings and convert rules to error ([#8489](https://github.com/ExodusMovement/assets/issues/8489)) ([19d35be](https://github.com/ExodusMovement/assets/commit/19d35be718391303d331978926884f2f376b356f))
|
|
66
|
+
* **simulation:** route recipient changes to dedicated field, guard asset-less entries ([#8370](https://github.com/ExodusMovement/assets/issues/8370)) ([c0a082b](https://github.com/ExodusMovement/assets/commit/c0a082b6c80710408337ff9285a19fb657c656be))
|
|
67
|
+
* upgrade low-complexity lint rules to error ([#8510](https://github.com/ExodusMovement/assets/issues/8510)) ([25016bd](https://github.com/ExodusMovement/assets/commit/25016bdf43526c6bc9efe2771f92f1ae1ebf7cd7))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
6
71
|
## [8.78.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.77.1...@exodus/ethereum-api@8.78.1) (2026-08-13)
|
|
7
72
|
|
|
8
73
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/ethereum-api",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.79.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",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"provenance": false
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"test": "
|
|
20
|
+
"test": "exodus-test --jest"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@exodus/asset": "^2.0.4",
|
|
@@ -67,5 +67,5 @@
|
|
|
67
67
|
"type": "git",
|
|
68
68
|
"url": "git+https://github.com/ExodusMovement/assets.git"
|
|
69
69
|
},
|
|
70
|
-
"gitHead": "
|
|
70
|
+
"gitHead": "85b01e122492dcdd4205ed06df8f4c0af545d25b"
|
|
71
71
|
}
|
package/src/allowance/index.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
2
1
|
import { isNumberUnit } from '@exodus/currency'
|
|
3
2
|
import { buffer2currency, createUnsignedTxFactory } from '@exodus/ethereum-lib'
|
|
4
3
|
import { bufferToInt } from '@exodus/ethereumjs/util'
|
|
@@ -46,7 +45,6 @@ export const buildApproveTx = async ({
|
|
|
46
45
|
}) => {
|
|
47
46
|
assert(asset, 'expected asset')
|
|
48
47
|
assert(feeData, 'expected feeData')
|
|
49
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
50
48
|
assert(isNumberUnit(approveAmount), 'expected approveAmount')
|
|
51
49
|
assert(typeof spenderAddress === 'string', 'expected spenderAddress')
|
|
52
50
|
assert(walletAccount, 'expected walletAccount')
|
|
@@ -231,7 +229,7 @@ export const createApproveSpendingTokens =
|
|
|
231
229
|
|
|
232
230
|
txIds.push(revokeTokenApprovalTxId)
|
|
233
231
|
// Hardcoded min gas limit. Otherwise `fetchGasLimit` will fail since `approve(_spender, 0)` is not yet confirmed
|
|
234
|
-
// eslint-disable-next-line @exodus/
|
|
232
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
235
233
|
data.gasLimit = Math.max(data.gasLimit || 0, APPROVAL_GAS_LIMIT)
|
|
236
234
|
}
|
|
237
235
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BlacklistCheckTypes } from '@exodus/asset-lib'
|
|
2
|
+
import { parseUnsignedTx } from '@exodus/ethereum-lib'
|
|
2
3
|
import { defaultAbiCoder } from '@exodus/ethereumjs/ethers5-abi'
|
|
3
4
|
import { safeString } from '@exodus/safe-string'
|
|
4
5
|
import assert from 'minimalistic-assert'
|
|
@@ -61,7 +62,7 @@ export const resolveMonitorSettings = (
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
const fallbackWithWarning = (msg) => {
|
|
64
|
-
console.
|
|
65
|
+
console.debug([msg, `Falling back to ${defaultMonitorType}<${defaultServerUrl}>.`].join(' '))
|
|
65
66
|
return defaultResolution
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -112,6 +113,29 @@ const assertSendPrivateTxProps = ({ asset, unsignedTx, walletAccount }) => {
|
|
|
112
113
|
assert(walletAccount, 'expected walletAccount')
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
// Block builders drop private transactions that carry no bid — accepted by
|
|
117
|
+
// the relay but never included on chain (ExodusMovement/assets#8084). The
|
|
118
|
+
// effective bid is min(tipGasPrice, gasPrice - baseFeePerGas): `gasPrice` is
|
|
119
|
+
// the maxFeePerGas ceiling for EIP-1559 transactions and the all-in price
|
|
120
|
+
// for legacy ones, so either term at zero kills the bid.
|
|
121
|
+
const assertNonZeroTipGasPrice = async ({ asset, assetClientInterface, unsignedTx }) => {
|
|
122
|
+
const { eip1559Enabled, gasPrice, tipGasPrice } = parseUnsignedTx({ asset, unsignedTx })
|
|
123
|
+
|
|
124
|
+
if (eip1559Enabled && (!tipGasPrice || tipGasPrice.isZero)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
'ERR_PRIVATE_TX_ZERO_TIP: transaction pays no tipGasPrice; block builders drop zero-bid private transactions'
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const feeData = await assetClientInterface.getFeeConfig({ assetName: asset.baseAsset.name })
|
|
131
|
+
const baseFeePerGas = feeData?.baseFeePerGas
|
|
132
|
+
if (!baseFeePerGas || gasPrice.gt(baseFeePerGas)) return
|
|
133
|
+
|
|
134
|
+
throw new Error(
|
|
135
|
+
`ERR_PRIVATE_TX_ZERO_TIP: transaction gasPrice (${gasPrice.toBaseString()}) does not exceed baseFeePerGas (${baseFeePerGas.toBaseString()}); block builders drop zero-bid private transactions`
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
115
139
|
export const createTransactionPrivacyResult = ({
|
|
116
140
|
assetClientInterface,
|
|
117
141
|
broadcastPrivateBundle,
|
|
@@ -123,6 +147,7 @@ export const createTransactionPrivacyResult = ({
|
|
|
123
147
|
|
|
124
148
|
const sendPrivateTx = async ({ asset, unsignedTx, walletAccount }) => {
|
|
125
149
|
assertSendPrivateTxProps({ asset, unsignedTx, walletAccount })
|
|
150
|
+
await assertNonZeroTipGasPrice({ asset, assetClientInterface, unsignedTx })
|
|
126
151
|
|
|
127
152
|
const { rawTx, txId } = await signTx({
|
|
128
153
|
asset,
|
|
@@ -160,6 +185,10 @@ export const createTransactionPrivacyResult = ({
|
|
|
160
185
|
|
|
161
186
|
unsignedTxBundle.forEach(assertSendPrivateTxProps)
|
|
162
187
|
|
|
188
|
+
for (const { asset, unsignedTx } of unsignedTxBundle) {
|
|
189
|
+
await assertNonZeroTipGasPrice({ asset, assetClientInterface, unsignedTx })
|
|
190
|
+
}
|
|
191
|
+
|
|
163
192
|
const signTxResults = await Promise.all(
|
|
164
193
|
unsignedTxBundle.map(({ asset, unsignedTx, walletAccount }) =>
|
|
165
194
|
signTx({ assetClientInterface, asset, unsignedTx, walletAccount })
|
|
@@ -288,7 +317,7 @@ export const createHistoryMonitorFactory = ({
|
|
|
288
317
|
let monitor
|
|
289
318
|
switch (monitorType) {
|
|
290
319
|
case 'clarity-v3':
|
|
291
|
-
console.
|
|
320
|
+
console.debug('clarity-v3 is no longer supported, falling back to clarity-v2')
|
|
292
321
|
// eslint-disable-next-line no-fallthrough
|
|
293
322
|
case 'clarity':
|
|
294
323
|
case 'clarity-v2':
|
package/src/ens/index.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
2
1
|
import { hashSync } from '@exodus/crypto/hash'
|
|
3
2
|
import { ABI } from '@exodus/ethereum-lib'
|
|
4
3
|
import SolidityContract from '@exodus/solidity-contract'
|
|
@@ -25,7 +24,6 @@ function namehash(inputName) {
|
|
|
25
24
|
const name = normalize(inputName)
|
|
26
25
|
if (name) {
|
|
27
26
|
for (const label of name.split('.').reverse()) {
|
|
28
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
29
27
|
node = hashSync('keccak256', [node, hashSync('keccak256', label, 'uint8')], 'buffer')
|
|
30
28
|
}
|
|
31
29
|
}
|
|
@@ -29,10 +29,15 @@ export default class ApiCoinNodesServer extends EthLikeServer {
|
|
|
29
29
|
return fetchJsonRetry(this.uri, options)
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
async sendBatchRequest(batch) {
|
|
32
|
+
async sendBatchRequest(batch, { tolerant } = {}) {
|
|
33
33
|
if (isEmpty(batch)) return batch
|
|
34
34
|
const responses = await this.request(batch)
|
|
35
|
-
return this.parseBatchResponse({
|
|
35
|
+
return this.parseBatchResponse({
|
|
36
|
+
batch,
|
|
37
|
+
responses,
|
|
38
|
+
errorMessage: 'Bad rpc batch response',
|
|
39
|
+
tolerant,
|
|
40
|
+
})
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
async sendRequest(request) {
|
|
@@ -263,11 +263,11 @@ export function create(defaultURL, ensAssetName) {
|
|
|
263
263
|
},
|
|
264
264
|
|
|
265
265
|
async proxyToCoinNode(requestData) {
|
|
266
|
-
// eslint-disable-next-line @exodus/
|
|
266
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
267
267
|
if (!requestData.jsonrpc) requestData.jsonrpc = '2.0'
|
|
268
|
-
// eslint-disable-next-line @exodus/
|
|
268
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
269
269
|
if (!requestData.id) requestData.id = randomUUID()
|
|
270
|
-
// eslint-disable-next-line @exodus/
|
|
270
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
271
271
|
if (!requestData.params) requestData.params = []
|
|
272
272
|
|
|
273
273
|
return request('proxy', requestData, { version: 'v2', method: 'post' })
|
|
@@ -198,7 +198,7 @@ export default class ClarityServer extends EthLikeServer {
|
|
|
198
198
|
clearTimeout(timeout)
|
|
199
199
|
const rawTraceparent = response?.traceparent || response?.[0]?.traceparent
|
|
200
200
|
if (rawTraceparent) {
|
|
201
|
-
// eslint-disable-next-line @exodus/
|
|
201
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
202
202
|
response.traceparent = TraceId.fromRawTraceparent(rawTraceparent)
|
|
203
203
|
}
|
|
204
204
|
|
|
@@ -208,9 +208,9 @@ export default class ClarityServer extends EthLikeServer {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
// Transport: WS only in ClarityServer, WS first → HTTP fallback in ClarityServerV2
|
|
211
|
-
async sendBatchRequest(batch) {
|
|
211
|
+
async sendBatchRequest(batch, { tolerant } = {}) {
|
|
212
212
|
const responses = await this.sendRpcRequest(batch)
|
|
213
|
-
return this.parseBatchResponse({ batch, responses, errorMessage: 'Bad Response' })
|
|
213
|
+
return this.parseBatchResponse({ batch, responses, errorMessage: 'Bad Response', tolerant })
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
// Transport: Uses sendRpcRequest - WS only in ClarityServer, WS first → HTTP fallback in ClarityServerV2
|
|
@@ -165,10 +165,11 @@ export class EthLikeServer extends EthLikeServerBase {
|
|
|
165
165
|
* @param {Array<{ id: number|string }>} args.batch - Requests sent, in the order results are expected.
|
|
166
166
|
* @param {unknown} args.responses - Raw transport payload; expected `[{ id, result }]` but treated as untrusted (gateways may return `{ message }` on error).
|
|
167
167
|
* @param {string} args.errorMessage - Thrown-error prefix per transport (e.g. `'Bad rpc batch response'`, `'Bad Response'`).
|
|
168
|
-
* @
|
|
169
|
-
* @
|
|
168
|
+
* @param {boolean} [args.tolerant] - When true, a request without a valid entry (JSON-RPC error entry, non-numeric `id`, duplicated or out-of-batch `id`, missing from a short response) maps to `undefined` instead of failing the whole batch.
|
|
169
|
+
* @returns {unknown[]} Each request's `result`, ordered to match `batch`. Entries with a duplicated or out-of-batch `id` are discarded in both modes, so an affected request maps to `undefined` rather than another request's result.
|
|
170
|
+
* @throws {Error} When `responses` isn't an array; in strict mode also when length mismatches `batch` or any entry lacks a numeric `id` / defined `result`.
|
|
170
171
|
*/
|
|
171
|
-
parseBatchResponse({ batch, responses, errorMessage }) {
|
|
172
|
+
parseBatchResponse({ batch, responses, errorMessage, tolerant = false }) {
|
|
172
173
|
// Gateways can respond with a JSON object (e.g. `{ message }`) instead of a batch array.
|
|
173
174
|
if (!Array.isArray(responses)) {
|
|
174
175
|
// Untrusted server text: keep only a short string preview, developer-facing (no `hint`).
|
|
@@ -179,16 +180,23 @@ export class EthLikeServer extends EthLikeServerBase {
|
|
|
179
180
|
throw new Error(`${errorMessage}${preview}`)
|
|
180
181
|
}
|
|
181
182
|
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
)
|
|
185
|
-
if (responses.length !== batch.length || !isValid) {
|
|
183
|
+
const isValidEntry = (response) => !isNaN(response?.id) && response?.result !== undefined
|
|
184
|
+
|
|
185
|
+
if (!tolerant && (responses.length !== batch.length || !responses.every(isValidEntry))) {
|
|
186
186
|
throw new Error(errorMessage)
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
// Safe against prototype keys:
|
|
189
|
+
// Safe against prototype keys: only entries with a validated numeric `id` are kept.
|
|
190
190
|
const keyed = Object.fromEntries(
|
|
191
|
-
responses
|
|
191
|
+
responses
|
|
192
|
+
.filter(isValidEntry)
|
|
193
|
+
.filter(({ id }, _i, all) => {
|
|
194
|
+
const key = `${id}`
|
|
195
|
+
const inBatch = batch.some((request) => `${request.id}` === key)
|
|
196
|
+
const unique = all.filter(({ id: other }) => `${other}` === key).length === 1
|
|
197
|
+
return inBatch && unique
|
|
198
|
+
})
|
|
199
|
+
.map((response) => [`${response.id}`, response.result])
|
|
192
200
|
)
|
|
193
201
|
return batch.map((request) => keyed[`${request.id}`])
|
|
194
202
|
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
3
2
|
DEFAULT_SERVER_URLS,
|
|
4
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
5
3
|
ETHEREUM_LIKE_ASSETS,
|
|
6
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
7
4
|
ETHEREUM_LIKE_MONITOR_TYPES,
|
|
8
5
|
} from '@exodus/ethereum-lib'
|
|
9
6
|
import assert from 'minimalistic-assert'
|
|
@@ -80,11 +77,8 @@ export const createWsGateway = ({ uri } = Object.create(null)) => {
|
|
|
80
77
|
|
|
81
78
|
// @Deprecated
|
|
82
79
|
const serverMap = Object.fromEntries(
|
|
83
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
84
80
|
ETHEREUM_LIKE_ASSETS.map((assetName) => {
|
|
85
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
86
81
|
const monitorType = ETHEREUM_LIKE_MONITOR_TYPES[assetName]
|
|
87
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
88
82
|
const serverUrl = DEFAULT_SERVER_URLS[assetName]
|
|
89
83
|
return [assetName, createEvmServer({ assetName, serverUrl, monitorType })]
|
|
90
84
|
})
|
|
@@ -21,8 +21,8 @@ const parseSubscriptionKey = (subscriptionKey) => {
|
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* @typedef subscribeWalletAddresses
|
|
24
|
-
* @property {string} network
|
|
25
|
-
* @property {string} address
|
|
24
|
+
* @property {string} network - The network identifier
|
|
25
|
+
* @property {string} address - The wallet address to subscribe
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
export default class WsGateway extends EventEmitter {
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
mapCurrency as defaultMapCurrency,
|
|
4
4
|
modelToJSON,
|
|
5
5
|
} from '@exodus/asset-lib/src/utils/index.js'
|
|
6
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
7
6
|
import { isNumberUnit } from '@exodus/currency'
|
|
8
7
|
import lodash from 'lodash'
|
|
9
8
|
|
|
@@ -13,7 +12,6 @@ const PRE_EIP1559_UNIT_KEYS_GUARD = ['gasPrice']
|
|
|
13
12
|
const EIP1559_UNIT_KEYS_GUARD = ['gasPrice', 'baseFeePerGas']
|
|
14
13
|
|
|
15
14
|
export const bound = ({ val, multiplier, max, min }) => {
|
|
16
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
17
15
|
if (!isNumberUnit(val)) return val
|
|
18
16
|
|
|
19
17
|
if (multiplier) {
|
package/src/fee-utils.js
CHANGED
|
@@ -111,9 +111,18 @@ export const calculateEthLikeFeeMonitorUpdateEip1559 = async ({
|
|
|
111
111
|
// at least the `baseFeePerGas`.
|
|
112
112
|
const gasPrice = baseFeePerGas > defaultGasPrice ? baseFeePerGas : defaultGasPrice
|
|
113
113
|
|
|
114
|
+
// `eth_gasPrice` embeds the node's prevailing tip on top of the
|
|
115
|
+
// `baseFeePerGas`. Without an explicit `tipGasPrice`, `refineFeeConfig`
|
|
116
|
+
// collapses the effective gas price to the bare base fee, producing
|
|
117
|
+
// zero-tip transactions that private bundle relays drop silently.
|
|
118
|
+
const inferredTipGasPrice = gasPrice - baseFeePerGas
|
|
119
|
+
|
|
114
120
|
const defaultFeeConfig = {
|
|
115
121
|
gasPrice: `${gasPrice.toString()} wei`,
|
|
116
122
|
baseFeePerGas: `${baseFeePerGas.toString()} wei`,
|
|
123
|
+
...(inferredTipGasPrice > BigInt(0) && {
|
|
124
|
+
tipGasPrice: `${inferredTipGasPrice.toString()} wei`,
|
|
125
|
+
}),
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
// Determine if there are any `rewardPercentiles` specified in the
|
package/src/index.js
CHANGED
|
@@ -41,28 +41,29 @@ export { getStakingHistoryBalance, getBalancesFactory } from './get-balances.js'
|
|
|
41
41
|
export { getHistoricalBalanceFactory } from './get-historical-balance.js'
|
|
42
42
|
export { createEncodeMultisigContract } from './multisig-address.js'
|
|
43
43
|
|
|
44
|
+
export { FantomStaking, stakingProviderClientFactory } from './staking/index.js'
|
|
45
|
+
export { ethereumStakingDeps } from './staking/ethereum/deps.js'
|
|
46
|
+
export { getEthereumStakingInfo, createEthereumStakingService } from './staking/ethereum/service.js'
|
|
47
|
+
export { EthereumStaking } from './staking/ethereum/api.js'
|
|
44
48
|
export {
|
|
45
|
-
FantomStaking,
|
|
46
|
-
stakingProviderClientFactory,
|
|
47
|
-
ethereumStakingDeps,
|
|
48
|
-
getEthereumStakingInfo,
|
|
49
|
-
createEthereumStakingService,
|
|
50
|
-
EthereumStaking,
|
|
51
49
|
isEthereumStakingTx,
|
|
52
50
|
isEthereumDelegate,
|
|
53
51
|
isEthereumUndelegatePending,
|
|
54
52
|
isEthereumUndelegate,
|
|
55
53
|
isEthereumClaimUndelegate,
|
|
56
|
-
|
|
54
|
+
} from './staking/ethereum/staking-utils.js'
|
|
55
|
+
export { MaticStakingApi } from './staking/matic/index.js'
|
|
56
|
+
export {
|
|
57
57
|
isPolygonTx,
|
|
58
58
|
isPolygonDelegate,
|
|
59
59
|
isPolygonUndelegate,
|
|
60
60
|
isPolygonReward,
|
|
61
61
|
isPolygonClaimUndelegate,
|
|
62
62
|
isPendingPolygonUndelegateTxInEthereumTxLog,
|
|
63
|
-
} from './staking/
|
|
63
|
+
} from './staking/matic/matic-staking-utils.js'
|
|
64
64
|
|
|
65
|
-
export { fetchTxPreview
|
|
65
|
+
export { fetchTxPreview } from './simulate-tx/fetch-tx-preview.js'
|
|
66
|
+
export { maybeRemoveDuplicates, retrieveSideEffects } from './simulate-tx/simulate-eth-tx.js'
|
|
66
67
|
export { simulateTransactions } from './simulation/simulate-transactions.js'
|
|
67
68
|
|
|
68
69
|
export {
|
|
@@ -8,11 +8,22 @@ import SolidityContract from '@exodus/solidity-contract'
|
|
|
8
8
|
import assert from 'minimalistic-assert'
|
|
9
9
|
|
|
10
10
|
import { getERC20Params } from '../eth-like-util.js'
|
|
11
|
+
import { fromHexToBigInt } from '../number-utils.js'
|
|
11
12
|
import { fetchTxPreview } from './fetch-tx-preview.js'
|
|
12
13
|
|
|
13
14
|
const ethDecimals = ethereum.units.ETH
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
// Convert a hex transaction field (e.g. "0x10a741a462780000" for 1.2 ETH in wei)
|
|
17
|
+
// to a precision-preserving decimal string. `parseInt` would silently truncate
|
|
18
|
+
// values above Number.MAX_SAFE_INTEGER (~9e15), and Ethereum wei amounts routinely
|
|
19
|
+
// exceed that (1 ETH = 1e18 wei).
|
|
20
|
+
const ethHexToDecimalString = (hexValue) => {
|
|
21
|
+
if (hexValue === undefined || hexValue === null || hexValue === '' || hexValue === '0x') {
|
|
22
|
+
return '0'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return fromHexToBigInt(hexValue).toString()
|
|
26
|
+
}
|
|
16
27
|
|
|
17
28
|
async function getAssetSymbolFromContract(contractAddress, asset) {
|
|
18
29
|
const { symbol: assetSymbol } = await getERC20Params({
|
|
@@ -169,14 +180,16 @@ export async function retrieveSideEffects({ transaction, asset, shouldSimulate =
|
|
|
169
180
|
|
|
170
181
|
if (!transaction.to) throw new Error(`'to' field is missing in the TX object`)
|
|
171
182
|
|
|
183
|
+
const txValueDecimal = ethHexToDecimalString(transaction.value)
|
|
184
|
+
|
|
172
185
|
const blocknativeTxObject = {
|
|
173
186
|
to: transaction.to,
|
|
174
187
|
input: transaction.data,
|
|
175
188
|
from: transaction.from,
|
|
176
|
-
value:
|
|
177
|
-
gas:
|
|
178
|
-
maxFeePerGas:
|
|
179
|
-
maxPriorityFeePerGas:
|
|
189
|
+
value: txValueDecimal,
|
|
190
|
+
gas: ethHexToDecimalString(transaction.gas),
|
|
191
|
+
maxFeePerGas: ethHexToDecimalString(transaction.maxFeePerGas),
|
|
192
|
+
maxPriorityFeePerGas: ethHexToDecimalString(transaction.maxPriorityFeePerGas),
|
|
180
193
|
}
|
|
181
194
|
|
|
182
195
|
try {
|
|
@@ -224,7 +237,7 @@ export async function retrieveSideEffects({ transaction, asset, shouldSimulate =
|
|
|
224
237
|
if (willSend.length === 0) {
|
|
225
238
|
willSend.push({
|
|
226
239
|
symbol: 'ETH',
|
|
227
|
-
balance: `-${
|
|
240
|
+
balance: `-${txValueDecimal}`,
|
|
228
241
|
assetType: 'ether',
|
|
229
242
|
decimal: ethDecimals,
|
|
230
243
|
})
|
|
@@ -30,7 +30,7 @@ const convertToAPIPayload = ({ address, message, url }) => {
|
|
|
30
30
|
|
|
31
31
|
export const handleActionResponse = ({ simulationResult, response }) => {
|
|
32
32
|
if (['BLOCK', 'WARN'].includes(response.action)) {
|
|
33
|
-
// eslint-disable-next-line @exodus/
|
|
33
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
34
34
|
simulationResult.action = response.action
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -194,7 +194,7 @@ export const simulateTransactionsApi = async ({
|
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
// eslint-disable-next-line @exodus/
|
|
197
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
198
198
|
simulationResult.metadata.simulatedLocally = false
|
|
199
199
|
|
|
200
200
|
const { expectedStateChanges = {}, error } = response.simulationResults.aggregated
|
|
@@ -178,7 +178,7 @@ export function createEthereumStakingService({
|
|
|
178
178
|
stakingProvider = stakingProviderClientFactory(),
|
|
179
179
|
}) {
|
|
180
180
|
if (deprectedArg && !assetName) {
|
|
181
|
-
console.
|
|
181
|
+
console.debug('using deprecated ARG `asset` instead of `assetName`')
|
|
182
182
|
assetName = deprectedArg.name
|
|
183
183
|
}
|
|
184
184
|
|
|
@@ -212,7 +212,7 @@ export function createEthereumStakingService({
|
|
|
212
212
|
|
|
213
213
|
const { to, data } = await staking.stake({ amount })
|
|
214
214
|
|
|
215
|
-
console.
|
|
215
|
+
console.debug(
|
|
216
216
|
`delegator address ${delegatorAddress} staking ${amount.toDefaultString({
|
|
217
217
|
unit: true,
|
|
218
218
|
})}`
|
|
@@ -376,7 +376,7 @@ export function createEthereumStakingService({
|
|
|
376
376
|
},
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
console.
|
|
379
|
+
console.debug(
|
|
380
380
|
`delegator address ${delegatorAddress} unstaking ${requestedAmount.toDefaultString({
|
|
381
381
|
unit: true,
|
|
382
382
|
})} - pending amount: ${pendingAmount.toDefaultString({ unit: true })}`
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
2
1
|
import { isNumberUnit } from '@exodus/currency'
|
|
3
2
|
|
|
4
3
|
export const DISABLE_BALANCE_CHECKS = '0x0'
|
|
5
4
|
|
|
6
5
|
export function amountToCurrency({ asset, amount }) {
|
|
7
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
8
6
|
return isNumberUnit(amount) ? amount : asset.currency.parse(amount)
|
|
9
7
|
}
|
|
10
8
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getCurrentEIP7702Delegation,
|
|
20
20
|
getDeriveDataNeededForTick,
|
|
21
21
|
getDeriveTransactionsToCheck,
|
|
22
|
+
isUnverifiablePendingTxExpired,
|
|
22
23
|
verifyRpcPendingTxStatusBatch,
|
|
23
24
|
} from './monitor-utils/index.js'
|
|
24
25
|
|
|
@@ -90,19 +91,23 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
90
91
|
|
|
91
92
|
// eslint-disable-next-line no-undef
|
|
92
93
|
async checkPendingTransactions(params) {
|
|
93
|
-
const {
|
|
94
|
-
|
|
94
|
+
const {
|
|
95
|
+
pendingTransactionsToCheck,
|
|
96
|
+
pendingTransactionsGroupedByAddressAndNonce,
|
|
97
|
+
recentPendingTransactions,
|
|
98
|
+
} = checkPendingTransactions(params)
|
|
95
99
|
const txsToRemove = []
|
|
96
100
|
const { walletAccount } = params
|
|
97
101
|
|
|
98
|
-
const updateTx = (tx, asset, { error, remove }) => {
|
|
102
|
+
const updateTx = (tx, asset, { error, remove, preserve, isTxConfirmed }) => {
|
|
99
103
|
if (remove) {
|
|
100
104
|
txsToRemove.push({ tx, assetSource: { asset, walletAccount } })
|
|
105
|
+
} else if (preserve) {
|
|
106
|
+
params.logItemsByAsset[asset].push(tx)
|
|
101
107
|
} else {
|
|
102
108
|
params.logItemsByAsset[asset].push({
|
|
103
109
|
...tx,
|
|
104
|
-
dropped: true,
|
|
105
|
-
error,
|
|
110
|
+
...(isTxConfirmed ? { confirmations: 1 } : { dropped: true, error }),
|
|
106
111
|
})
|
|
107
112
|
}
|
|
108
113
|
|
|
@@ -115,11 +120,15 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
115
120
|
assetSource: { asset: assetName, walletAccount },
|
|
116
121
|
})
|
|
117
122
|
} else if (tokenTxSet && tokenTxSet.has(tx.txId)) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
// Optional: the stored tx may name a token since removed from the registry
|
|
124
|
+
params.logItemsByAsset[assetName]?.push(
|
|
125
|
+
preserve
|
|
126
|
+
? tokenTxSet.get(tx.txId)
|
|
127
|
+
: {
|
|
128
|
+
...tokenTxSet.get(tx.txId),
|
|
129
|
+
...(isTxConfirmed ? { confirmations: 1 } : { dropped: true, error }),
|
|
130
|
+
}
|
|
131
|
+
)
|
|
123
132
|
}
|
|
124
133
|
})
|
|
125
134
|
return Promise.all(promises)
|
|
@@ -131,28 +140,55 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
131
140
|
if (replaced) {
|
|
132
141
|
await updateTx(tx, assetName, { remove: true })
|
|
133
142
|
delete pendingTransactionsToCheck[tx.txId]
|
|
143
|
+
delete recentPendingTransactions[tx.txId]
|
|
134
144
|
}
|
|
135
145
|
}
|
|
136
146
|
|
|
137
|
-
// Batch verify all pending txs with a single RPC call
|
|
147
|
+
// Batch verify all pending txs with a single RPC call
|
|
138
148
|
const txIds = Object.keys(pendingTransactionsToCheck)
|
|
139
|
-
const statuses =
|
|
140
|
-
|
|
141
|
-
:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
txIds,
|
|
145
|
-
})
|
|
149
|
+
const statuses = await verifyRpcPendingTxStatusBatch({
|
|
150
|
+
server: this.server,
|
|
151
|
+
logger: this.logger,
|
|
152
|
+
txIds,
|
|
153
|
+
})
|
|
146
154
|
|
|
147
155
|
for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
|
|
156
|
+
const txStatus = statuses[tx.txId]
|
|
148
157
|
if (params.refresh) {
|
|
149
|
-
await updateTx(tx, assetName, { remove: true })
|
|
150
|
-
} else {
|
|
151
|
-
const txStatus = statuses[tx.txId]
|
|
152
158
|
if (txStatus?.status === 'dropped') {
|
|
153
|
-
await updateTx(tx, assetName, {
|
|
159
|
+
await updateTx(tx, assetName, { remove: true })
|
|
160
|
+
} else if (txStatus?.status === 'confirmed') {
|
|
161
|
+
// Confirmed on-chain but missing from the indexer response: mark it
|
|
162
|
+
// confirmed so it stops being a pending entry that would otherwise be
|
|
163
|
+
// preserved (exempt from the age ceiling) on every refresh forever.
|
|
164
|
+
await updateTx(tx, assetName, { isTxConfirmed: true })
|
|
165
|
+
} else if (
|
|
166
|
+
// No txStatus at all means the whole verification batch failed - evidence
|
|
167
|
+
// about nothing, so preserve unconditionally; the age ceiling only applies
|
|
168
|
+
// to a per-entry 'unknown' verdict (evidence about that one hash).
|
|
169
|
+
!txStatus ||
|
|
170
|
+
txStatus.status === 'pending' ||
|
|
171
|
+
!isUnverifiablePendingTxExpired(tx)
|
|
172
|
+
) {
|
|
173
|
+
// Still live on-chain (or not verifiable yet) but missing from the indexer
|
|
174
|
+
// response: re-add it so the refresh overwrite cannot wipe it and break
|
|
175
|
+
// tx-log-based nonce resolution.
|
|
176
|
+
await updateTx(tx, assetName, { preserve: true })
|
|
177
|
+
} else {
|
|
178
|
+
// Unverifiable past the age ceiling: purge so a hash the node keeps
|
|
179
|
+
// erroring on cannot pin nonce derivation forever.
|
|
180
|
+
await updateTx(tx, assetName, { remove: true })
|
|
154
181
|
}
|
|
155
|
-
|
|
182
|
+
} else if (txStatus?.status === 'dropped') {
|
|
183
|
+
await updateTx(tx, assetName, { error: 'Dropped' })
|
|
184
|
+
}
|
|
185
|
+
// non-refresh: 'confirmed', 'pending' or unknown - tx is fine, wait for Clarity to confirm
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (params.refresh) {
|
|
189
|
+
// Too recent for a reliable on-chain verdict - always survive the refresh overwrite
|
|
190
|
+
for (const { tx, assetName } of Object.values(recentPendingTransactions)) {
|
|
191
|
+
await updateTx(tx, assetName, { preserve: true })
|
|
156
192
|
}
|
|
157
193
|
}
|
|
158
194
|
|
|
@@ -284,7 +320,9 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
284
320
|
}
|
|
285
321
|
|
|
286
322
|
if (historyError) {
|
|
287
|
-
throw historyError
|
|
323
|
+
throw historyError instanceof Error
|
|
324
|
+
? historyError
|
|
325
|
+
: new Error(String(historyError), { cause: historyError })
|
|
288
326
|
}
|
|
289
327
|
|
|
290
328
|
if (refresh || hasNewTxs) {
|
|
@@ -229,7 +229,9 @@ export class ClarityTruncatedHistoryMonitor extends ClarityMonitor {
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
if (historyError) {
|
|
232
|
-
throw historyError
|
|
232
|
+
throw historyError instanceof Error
|
|
233
|
+
? historyError
|
|
234
|
+
: new Error(String(historyError), { cause: historyError })
|
|
233
235
|
}
|
|
234
236
|
|
|
235
237
|
if (refresh || hasNewTxs) {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getDeriveTransactionsToCheck,
|
|
13
13
|
getHistoryFromServer,
|
|
14
14
|
getLogItemsFromServerTx,
|
|
15
|
+
isUnverifiablePendingTxExpired,
|
|
15
16
|
verifyRpcPendingTxStatusBatch,
|
|
16
17
|
} from './monitor-utils/index.js'
|
|
17
18
|
import {
|
|
@@ -74,16 +75,21 @@ export class EthereumMonitor extends BaseMonitor {
|
|
|
74
75
|
|
|
75
76
|
// eslint-disable-next-line no-undef
|
|
76
77
|
async checkPendingTransactions(params) {
|
|
77
|
-
const {
|
|
78
|
-
|
|
78
|
+
const {
|
|
79
|
+
pendingTransactionsToCheck,
|
|
80
|
+
pendingTransactionsGroupedByAddressAndNonce,
|
|
81
|
+
recentPendingTransactions,
|
|
82
|
+
} = checkPendingTransactions(params)
|
|
79
83
|
const txsToRemove = []
|
|
80
84
|
const {
|
|
81
85
|
assetSource: { walletAccount },
|
|
82
86
|
} = params
|
|
83
87
|
|
|
84
|
-
const updateTx = (tx, asset, { isTxConfirmed, remove }) => {
|
|
88
|
+
const updateTx = (tx, asset, { isTxConfirmed, remove, preserve }) => {
|
|
85
89
|
if (remove) {
|
|
86
90
|
txsToRemove.push({ tx, assetSource: { asset, walletAccount } })
|
|
91
|
+
} else if (preserve) {
|
|
92
|
+
params.logItemsByAsset[asset].push(tx)
|
|
87
93
|
} else {
|
|
88
94
|
params.logItemsByAsset[asset].push({
|
|
89
95
|
...tx,
|
|
@@ -100,10 +106,15 @@ export class EthereumMonitor extends BaseMonitor {
|
|
|
100
106
|
assetSource: { asset: assetName, walletAccount },
|
|
101
107
|
})
|
|
102
108
|
} else if (tokenTxSet && tokenTxSet.has(tx.txId)) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
// Optional: the stored tx may name a token since removed from the registry
|
|
110
|
+
params.logItemsByAsset[assetName]?.push(
|
|
111
|
+
preserve
|
|
112
|
+
? tokenTxSet.get(tx.txId)
|
|
113
|
+
: {
|
|
114
|
+
...tokenTxSet.get(tx.txId),
|
|
115
|
+
...(isTxConfirmed ? { confirmations: 1 } : { dropped: true, error: 'Dropped' }),
|
|
116
|
+
}
|
|
117
|
+
)
|
|
107
118
|
}
|
|
108
119
|
})
|
|
109
120
|
|
|
@@ -116,30 +127,50 @@ export class EthereumMonitor extends BaseMonitor {
|
|
|
116
127
|
if (replaced) {
|
|
117
128
|
await updateTx(tx, assetName, { remove: true })
|
|
118
129
|
delete pendingTransactionsToCheck[tx.txId]
|
|
130
|
+
delete recentPendingTransactions[tx.txId]
|
|
119
131
|
}
|
|
120
132
|
}
|
|
121
133
|
|
|
122
|
-
// Batch verify all pending txs with a single RPC call
|
|
134
|
+
// Batch verify all pending txs with a single RPC call
|
|
123
135
|
const txIds = Object.keys(pendingTransactionsToCheck)
|
|
124
|
-
const statuses =
|
|
125
|
-
|
|
126
|
-
:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
txIds,
|
|
130
|
-
})
|
|
136
|
+
const statuses = await verifyRpcPendingTxStatusBatch({
|
|
137
|
+
server: this.server,
|
|
138
|
+
logger: this.logger,
|
|
139
|
+
txIds,
|
|
140
|
+
})
|
|
131
141
|
|
|
132
142
|
for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
await updateTx(tx, assetName, {
|
|
139
|
-
} else
|
|
143
|
+
const txStatus = statuses[tx.txId]
|
|
144
|
+
if (txStatus?.status === 'confirmed') {
|
|
145
|
+
await updateTx(tx, assetName, { isTxConfirmed: true })
|
|
146
|
+
} else if (txStatus?.status === 'dropped') {
|
|
147
|
+
if (params.refresh) {
|
|
148
|
+
await updateTx(tx, assetName, { remove: true })
|
|
149
|
+
} else {
|
|
140
150
|
await updateTx(tx, assetName, { isTxConfirmed: false })
|
|
141
151
|
}
|
|
142
|
-
|
|
152
|
+
} else if (params.refresh) {
|
|
153
|
+
// No txStatus at all means the whole verification batch failed - evidence
|
|
154
|
+
// about nothing, so preserve unconditionally; the age ceiling only applies
|
|
155
|
+
// to a per-entry 'unknown' verdict (evidence about that one hash).
|
|
156
|
+
if (!txStatus || txStatus.status === 'pending' || !isUnverifiablePendingTxExpired(tx)) {
|
|
157
|
+
// Still in the mempool (or not verifiable yet) but missing from the history
|
|
158
|
+
// server response: re-add it so the refresh overwrite cannot wipe it and
|
|
159
|
+
// break tx-log-based nonce resolution.
|
|
160
|
+
await updateTx(tx, assetName, { preserve: true })
|
|
161
|
+
} else {
|
|
162
|
+
// Unverifiable past the age ceiling: purge so a hash the node keeps
|
|
163
|
+
// erroring on cannot pin nonce derivation forever.
|
|
164
|
+
await updateTx(tx, assetName, { remove: true })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// non-refresh: 'pending' or unknown - tx still in mempool, do nothing
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (params.refresh) {
|
|
171
|
+
// Too recent for a reliable on-chain verdict - always survive the refresh overwrite
|
|
172
|
+
for (const { tx, assetName } of Object.values(recentPendingTransactions)) {
|
|
173
|
+
await updateTx(tx, assetName, { preserve: true })
|
|
143
174
|
}
|
|
144
175
|
}
|
|
145
176
|
|
|
@@ -82,7 +82,7 @@ export const getOptimisticTxLogEffects = async ({
|
|
|
82
82
|
maybeTxToReplace && maybeTxToReplace.feeAmount.lt(feeAmount) ? maybeTxToReplace.txId : undefined
|
|
83
83
|
|
|
84
84
|
if (!replacedTxId && maybeTxToReplace) {
|
|
85
|
-
console.
|
|
85
|
+
console.debug('Attempting to replace a transaction using an insufficient fee!')
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
// Contains effects for smart-contract initiated token movements.
|
|
@@ -4,13 +4,18 @@ import getSenderNonceKey from './get-sender-nonce-key.js'
|
|
|
4
4
|
export default function checkPendingTransactions({
|
|
5
5
|
pendingTransactionsGroupedByAddressAndNonce = {},
|
|
6
6
|
pendingTransactionsToCheck = {},
|
|
7
|
+
recentPendingTransactions = {},
|
|
7
8
|
txlist,
|
|
8
9
|
ourWalletAddress,
|
|
9
10
|
asset,
|
|
10
11
|
}) {
|
|
11
12
|
for (const tx of txlist) {
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
const txId = tx.txId || tx.hash
|
|
14
|
+
// Found this transaction on the server, do not mark it as dropped or re-add it
|
|
15
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
16
|
+
delete pendingTransactionsToCheck[txId]
|
|
17
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
18
|
+
delete recentPendingTransactions[txId]
|
|
14
19
|
|
|
15
20
|
// check if this TX replaces another pending TX
|
|
16
21
|
const replacedTx =
|
|
@@ -29,5 +34,9 @@ export default function checkPendingTransactions({
|
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
pendingTransactionsGroupedByAddressAndNonce,
|
|
39
|
+
pendingTransactionsToCheck,
|
|
40
|
+
recentPendingTransactions,
|
|
41
|
+
}
|
|
33
42
|
}
|
|
@@ -10,6 +10,7 @@ const mapToObject = (map) => Object.fromEntries(map.entries()) // only for strin
|
|
|
10
10
|
export default function getDeriveTransactionsToCheck({ getTxLog }) {
|
|
11
11
|
return async ({ assetName: _assetName, walletAccount, tokens, ourWalletAddress }) => {
|
|
12
12
|
const pendingTransactionsToCheck = new Map()
|
|
13
|
+
const recentPendingTransactions = new Map()
|
|
13
14
|
const pendingTransactionsGroupedByAddressAndNonce = new Map()
|
|
14
15
|
const simulatedTransactions = new Map()
|
|
15
16
|
const now = SynchronizedTime.now()
|
|
@@ -19,11 +20,13 @@ export default function getDeriveTransactionsToCheck({ getTxLog }) {
|
|
|
19
20
|
for (const tx of txSet) {
|
|
20
21
|
// ERC20 sends have an entry in ETH and one in the ERC20 log so we just want the ETH one
|
|
21
22
|
if (
|
|
22
|
-
!pendingTransactionsToCheck.has(tx.txId) &&
|
|
23
23
|
tx.pending &&
|
|
24
|
-
|
|
24
|
+
!pendingTransactionsToCheck.has(tx.txId) &&
|
|
25
|
+
!recentPendingTransactions.has(tx.txId)
|
|
25
26
|
) {
|
|
26
|
-
|
|
27
|
+
const isRecent = now - tx.date.getTime() <= UNCONFIRMED_TX_LIMIT
|
|
28
|
+
const map = isRecent ? recentPendingTransactions : pendingTransactionsToCheck
|
|
29
|
+
map.set(tx.txId, { tx, assetName })
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
if (tx.meta.simulated) simulatedTransactions.set(tx.txId, tx)
|
|
@@ -45,6 +48,7 @@ export default function getDeriveTransactionsToCheck({ getTxLog }) {
|
|
|
45
48
|
|
|
46
49
|
return {
|
|
47
50
|
pendingTransactionsToCheck: mapToObject(pendingTransactionsToCheck),
|
|
51
|
+
recentPendingTransactions: mapToObject(recentPendingTransactions),
|
|
48
52
|
pendingTransactionsGroupedByAddressAndNonce: mapToObject(
|
|
49
53
|
pendingTransactionsGroupedByAddressAndNonce
|
|
50
54
|
),
|
|
@@ -8,5 +8,6 @@ export { default as getDeriveTransactionsToCheck } from './get-derive-transactio
|
|
|
8
8
|
export { default as getCurrentEIP7702Delegation } from './get-current-eip7702-delegation.js'
|
|
9
9
|
export { default as getCurrentBlackListStatus } from './get-current-blacklist-status.js'
|
|
10
10
|
export { default as verifyRpcPendingTxStatusBatch } from './verify-pending-tx-status-rpc.js'
|
|
11
|
+
export { default as isUnverifiablePendingTxExpired } from './is-unverifiable-pending-tx-expired.js'
|
|
11
12
|
export { extractBalanceFromClarityAccountInfo } from './extract-balance-from-clarity-account-info.js'
|
|
12
13
|
export * from './exclude-unchanged-token-balances.js'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { SynchronizedTime } from '@exodus/basic-utils'
|
|
2
|
+
import ms from 'ms'
|
|
3
|
+
|
|
4
|
+
// Upper bound on how long a pending tx with a per-entry 'unknown' verdict
|
|
5
|
+
// (a hash the node persistently errors on) keeps being preserved across
|
|
6
|
+
// refreshes. Without it, "cannot verify this hash" would mean "keep forever"
|
|
7
|
+
// and a phantom tx would pin nonce derivation indefinitely.
|
|
8
|
+
// Positively verified txs ('pending'/'confirmed') are exempt: their lifetime
|
|
9
|
+
// is bounded by the node itself (mempool eviction yields a 'dropped' verdict).
|
|
10
|
+
// A full batch failure is also exempt: it is evidence about nothing, so the
|
|
11
|
+
// monitors preserve unconditionally until the batch path recovers.
|
|
12
|
+
export const UNVERIFIABLE_PENDING_TX_MAX_AGE = ms('1h')
|
|
13
|
+
|
|
14
|
+
export default function isUnverifiablePendingTxExpired(tx) {
|
|
15
|
+
const txTime = tx.date?.getTime()
|
|
16
|
+
// Fail closed: a tx with a missing or invalid date cannot prove it is young
|
|
17
|
+
if (!Number.isFinite(txTime)) return true
|
|
18
|
+
return SynchronizedTime.now() - txTime > UNVERIFIABLE_PENDING_TX_MAX_AGE
|
|
19
|
+
}
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Determines the status of an RPC transaction response.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* `undefined` means the node returned a JSON-RPC error entry for this hash
|
|
5
|
+
* (tolerant batch parsing) - no verdict, as opposed to `null` which is a
|
|
6
|
+
* definitive "not found".
|
|
7
|
+
*
|
|
8
|
+
* @param {Object|null|undefined} rpcTx - The RPC transaction response
|
|
9
|
+
* @returns {{status: 'confirmed' | 'pending' | 'dropped' | 'unknown', blockNumber?: number}}
|
|
6
10
|
*/
|
|
7
11
|
function getTxStatus(rpcTx) {
|
|
12
|
+
if (rpcTx === undefined) {
|
|
13
|
+
return { status: 'unknown' }
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
if (rpcTx?.blockNumber) {
|
|
9
17
|
return { status: 'confirmed', blockNumber: parseInt(rpcTx.blockNumber, 16) }
|
|
10
18
|
}
|
|
@@ -29,7 +37,7 @@ function getTxStatus(rpcTx) {
|
|
|
29
37
|
* @param {Object} params.server - RPC server used to verify pending transactions
|
|
30
38
|
* @param {Object} params.logger - Logger instance for info/debug output
|
|
31
39
|
* @param {string[]} params.txIds - Array of transaction IDs/hashes to verify
|
|
32
|
-
* @returns {Promise<Object<string, {status: 'confirmed' | 'pending' | 'dropped', blockNumber?: number}>>}
|
|
40
|
+
* @returns {Promise<Object<string, {status: 'confirmed' | 'pending' | 'dropped' | 'unknown', blockNumber?: number}>>}
|
|
33
41
|
*/
|
|
34
42
|
export default async function verifyRpcPendingTxStatusBatch({ server, logger, txIds }) {
|
|
35
43
|
if (txIds.length === 0) return {}
|
|
@@ -39,27 +47,42 @@ export default async function verifyRpcPendingTxStatusBatch({ server, logger, tx
|
|
|
39
47
|
|
|
40
48
|
let responses
|
|
41
49
|
try {
|
|
42
|
-
|
|
50
|
+
// Tolerant parsing: one bad hash must not void the verdicts for every other tx
|
|
51
|
+
responses = await server.sendBatchRequest(requests, { tolerant: true })
|
|
43
52
|
} catch (error) {
|
|
44
53
|
// If batch request fails, skip all drop checks (safe default)
|
|
45
54
|
logger.warn('Batch RPC request failed, skipping drop checks', error)
|
|
46
55
|
return {}
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
if (!Array.isArray(responses) || responses.length !== txIds.length) {
|
|
59
|
+
// A malformed batch response must not produce 'dropped' verdicts (safe default)
|
|
60
|
+
logger.warn('Batch RPC response shape mismatch, skipping drop checks')
|
|
61
|
+
return {}
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
// Map responses to statuses
|
|
50
|
-
const results =
|
|
65
|
+
const results = Object.create(null)
|
|
51
66
|
txIds.forEach((txId, idx) => {
|
|
52
67
|
const rpcTx = responses[idx]
|
|
53
68
|
const txStatus = getTxStatus(rpcTx)
|
|
54
69
|
results[txId] = txStatus
|
|
55
70
|
|
|
56
71
|
// Log each tx status
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
72
|
+
switch (txStatus.status) {
|
|
73
|
+
case 'confirmed':
|
|
74
|
+
logger.info(
|
|
75
|
+
`tx ${txId} is confirmed on-chain (block ${txStatus.blockNumber}), skipping drop`
|
|
76
|
+
)
|
|
77
|
+
break
|
|
78
|
+
case 'pending':
|
|
79
|
+
logger.info(`tx ${txId} is still in mempool, skipping drop`)
|
|
80
|
+
break
|
|
81
|
+
case 'unknown':
|
|
82
|
+
logger.warn(`tx ${txId} could not be verified (RPC error entry), skipping drop`)
|
|
83
|
+
break
|
|
84
|
+
default:
|
|
85
|
+
logger.info(`tx ${txId} not found on-chain, marking as dropped`)
|
|
63
86
|
}
|
|
64
87
|
})
|
|
65
88
|
|
|
@@ -47,7 +47,7 @@ export const calculateRewardsFromStakeTx = ({ tx, currency }) => {
|
|
|
47
47
|
|
|
48
48
|
if (stakeTxContainsReward) {
|
|
49
49
|
const txAmount = stakedAmount.sub(tx.coinAmount.abs()).abs()
|
|
50
|
-
// eslint-disable-next-line @exodus/
|
|
50
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only -- TODO: Fix this the next time the file is edited.
|
|
51
51
|
return txAmount.toDefaultString()
|
|
52
52
|
}
|
|
53
53
|
}
|
|
@@ -29,7 +29,7 @@ export const handleBroadcastError = async (
|
|
|
29
29
|
isNonceTooLow || errorInfo.reason === EVM_ERROR_REASONS.transactionUnderpriced.reason
|
|
30
30
|
|
|
31
31
|
if (errorInfo.reason === EVM_ERROR_REASONS.alreadyKnown.reason) {
|
|
32
|
-
console.
|
|
32
|
+
console.debug('tx already broadcast')
|
|
33
33
|
return { shouldRetry: false }
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -44,7 +44,7 @@ export const handleBroadcastError = async (
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
if (txAlreadyExists) {
|
|
47
|
-
console.
|
|
47
|
+
console.debug('tx already broadcast')
|
|
48
48
|
return { shouldRetry: false }
|
|
49
49
|
}
|
|
50
50
|
|
package/src/tx-send/tx-send.js
CHANGED
|
@@ -50,7 +50,7 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
|
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
if (shouldRetry) {
|
|
53
|
-
console.
|
|
53
|
+
console.debug('trying to send again...') // inject logger factory from platform
|
|
54
54
|
|
|
55
55
|
// let's try to fix the nonce issue
|
|
56
56
|
let newNonce = await baseAsset.getNonce({
|
|
@@ -73,14 +73,14 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
|
|
|
73
73
|
//
|
|
74
74
|
// TODO: should return an unsignedTx -> i.e. completely replicate the transaction
|
|
75
75
|
if (unsignedTx.txData.transactionBuffer) {
|
|
76
|
-
// eslint-disable-next-line @exodus/
|
|
76
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
77
77
|
unsignedTx.txData.transactionBuffer = updateNonce({
|
|
78
78
|
chainId: baseAsset.chainId,
|
|
79
79
|
newNonce,
|
|
80
80
|
unsignedTx,
|
|
81
81
|
})
|
|
82
82
|
} else {
|
|
83
|
-
// eslint-disable-next-line @exodus/
|
|
83
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
84
84
|
unsignedTx.txData.nonce = newNonce
|
|
85
85
|
}
|
|
86
86
|
|