@exodus/ethereum-api 10.2.3 → 10.3.1
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 +18 -0
- package/package.json +2 -2
- package/src/exodus-eth-server/clarity-v2.js +28 -2
- package/src/hooks/monitor.js +2 -1
- package/src/transaction-authentication.js +93 -0
- package/src/tx-create.js +80 -92
- package/src/tx-log/authenticated-monitor.js +85 -0
- package/src/tx-log/clarity-monitor.js +65 -24
- package/src/tx-log/clarity-truncated-history-monitor.js +3 -3
- package/src/tx-log/clarity-truncated-history-monitor.md +2 -0
- package/src/tx-log/clarity-utils/get-log-items-from-server-tx.js +2 -3
- package/src/tx-log/ethereum-no-history-monitor.js +2 -2
- package/src/tx-log-staking-processor/index.js +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,24 @@
|
|
|
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
|
+
## [10.3.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@10.3.0...@exodus/ethereum-api@10.3.1) (2026-11-03)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* authenticate EVM replacements and remaining outgoing history ([#9094](https://github.com/ExodusMovement/assets/issues/9094)) ([3cca521](https://github.com/ExodusMovement/assets/commit/3cca521f813fdb5e03c9d0dfaa4030b5c3b17bca))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## [10.3.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@10.2.3...@exodus/ethereum-api@10.3.0) (2026-10-30)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **ethereum-api:** self-heal tx log with clarity tip mode ([#9123](https://github.com/ExodusMovement/assets/issues/9123)) ([67cbc64](https://github.com/ExodusMovement/assets/commit/67cbc641606e18f1c4cdf66ee0d76b3e7fdd70fd))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
6
24
|
## [10.2.3](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@10.2.2...@exodus/ethereum-api@10.2.3) (2026-10-23)
|
|
7
25
|
|
|
8
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/ethereum-api",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.3.1",
|
|
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",
|
|
@@ -65,5 +65,5 @@
|
|
|
65
65
|
"type": "git",
|
|
66
66
|
"url": "git+https://github.com/ExodusMovement/assets.git"
|
|
67
67
|
},
|
|
68
|
-
"gitHead": "
|
|
68
|
+
"gitHead": "cc266df454f96bb8efd03909701476c54985e3d7"
|
|
69
69
|
}
|
|
@@ -63,10 +63,14 @@ export default class ClarityServerV2 extends ClarityServer {
|
|
|
63
63
|
this.updateBaseApiPath(uri) // pass in the uri from remote config to override assets-gateway
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
getTransactionsFromBlockNumber = async ({ address, blockNumber, withInput, truncated }) => {
|
|
66
|
+
getTransactionsFromBlockNumber = async ({ address, blockNumber, withInput, truncated, tip }) => {
|
|
67
67
|
const url = new URL(`${this.baseApiPath}/addresses/${encodeURIComponent(address)}/transactions`)
|
|
68
68
|
url.searchParams.set('cursor', blockNumber || '0')
|
|
69
69
|
|
|
70
|
+
if (tip) {
|
|
71
|
+
url.searchParams.set('mode', 'tip')
|
|
72
|
+
}
|
|
73
|
+
|
|
70
74
|
if (withInput) {
|
|
71
75
|
url.searchParams.set('withInput', 'true')
|
|
72
76
|
}
|
|
@@ -122,8 +126,12 @@ export default class ClarityServerV2 extends ClarityServer {
|
|
|
122
126
|
* into the backend's truncation mode + `accountInfo` pre-cursor snapshots (used by
|
|
123
127
|
* `ClarityTruncatedHistoryMonitor`). For `clarity`/`clarity-v2`/`clarity-v3` flows,
|
|
124
128
|
* leave the default so Clarity returns full history.
|
|
129
|
+
*
|
|
130
|
+
* Pass `tip: true` for incremental syncs: Clarity pages like the default mode until the
|
|
131
|
+
* chain tip, then returns the newest txs with a cursor anchored at the oldest of them and
|
|
132
|
+
* `hasMore: false`, so each call re-reads that window.
|
|
125
133
|
*/
|
|
126
|
-
async getAllTransactions({ address, cursor, withInput = true, truncated = false }) {
|
|
134
|
+
async getAllTransactions({ address, cursor, withInput = true, truncated = false, tip = false }) {
|
|
127
135
|
let { blockNumber } = decodeCursor(cursor)
|
|
128
136
|
|
|
129
137
|
let transactions = []
|
|
@@ -139,6 +147,7 @@ export default class ClarityServerV2 extends ClarityServer {
|
|
|
139
147
|
blockNumber: blockNumber.toString(),
|
|
140
148
|
withInput,
|
|
141
149
|
truncated,
|
|
150
|
+
tip,
|
|
142
151
|
})
|
|
143
152
|
|
|
144
153
|
const txs = result.transactions || []
|
|
@@ -149,6 +158,23 @@ export default class ClarityServerV2 extends ClarityServer {
|
|
|
149
158
|
|
|
150
159
|
const nextBlockNumber = BigInt(result.cursor)
|
|
151
160
|
|
|
161
|
+
if (tip) {
|
|
162
|
+
transactions.push(...txs)
|
|
163
|
+
|
|
164
|
+
if (!result.hasMore) {
|
|
165
|
+
blockNumber = nextBlockNumber
|
|
166
|
+
break
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (nextBlockNumber <= blockNumber) {
|
|
170
|
+
console.warn(`Tip mode cursor did not advance past ${blockNumber} with hasMore, stopping`)
|
|
171
|
+
break
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
blockNumber = nextBlockNumber
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
152
178
|
if (txs.length === 0) {
|
|
153
179
|
// fetch until no more new transactions
|
|
154
180
|
blockNumber = nextBlockNumber
|
package/src/hooks/monitor.js
CHANGED
|
@@ -54,7 +54,7 @@ export const createEthereumHooks = ({
|
|
|
54
54
|
const addresses = new Map()
|
|
55
55
|
const generations = new Map()
|
|
56
56
|
const commits = new Map()
|
|
57
|
-
const afterTickHook = async ({ walletAccount, refresh = false }) => {
|
|
57
|
+
const afterTickHook = async ({ walletAccount, refresh = false, monitor }) => {
|
|
58
58
|
// Order requests at entry, not after address discovery: discovery itself can
|
|
59
59
|
// be delayed while a newer explicit refresh is already running.
|
|
60
60
|
const accountKey = String(walletAccount)
|
|
@@ -126,6 +126,7 @@ export const createEthereumHooks = ({
|
|
|
126
126
|
processTxLog({
|
|
127
127
|
asset,
|
|
128
128
|
assetClientInterface,
|
|
129
|
+
monitor,
|
|
129
130
|
walletAccount,
|
|
130
131
|
batch,
|
|
131
132
|
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { verifyTransactionSignature } from '@exodus/ethereum-lib'
|
|
2
|
+
import assert from 'minimalistic-assert'
|
|
3
|
+
|
|
4
|
+
export async function fetchAuthenticatedTransaction({
|
|
5
|
+
baseAsset,
|
|
6
|
+
txId,
|
|
7
|
+
address,
|
|
8
|
+
server = baseAsset.server,
|
|
9
|
+
missingTransactionMessage = 'Signed transaction is required',
|
|
10
|
+
}) {
|
|
11
|
+
assert(typeof address === 'string', 'Wallet address is required for authentication')
|
|
12
|
+
const transaction = await server.getTransactionByHash(txId)
|
|
13
|
+
assert(transaction, missingTransactionMessage)
|
|
14
|
+
assert(
|
|
15
|
+
verifyTransactionSignature({
|
|
16
|
+
transaction,
|
|
17
|
+
txId,
|
|
18
|
+
address,
|
|
19
|
+
chainId: baseAsset.chainId,
|
|
20
|
+
}),
|
|
21
|
+
`Cannot authenticate transaction ${txId}`
|
|
22
|
+
)
|
|
23
|
+
return transaction
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function rpcInteger(value) {
|
|
27
|
+
const integer = BigInt(value)
|
|
28
|
+
const number = Number(integer)
|
|
29
|
+
assert(Number.isSafeInteger(number) && number >= 0, 'Transaction integer exceeds safe range')
|
|
30
|
+
return number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A signed envelope authenticates these fields. Receipts, token events, internal
|
|
34
|
+
// transfers and inclusion status are separate execution claims, not signed data.
|
|
35
|
+
export async function authenticateHistoryTransactions({
|
|
36
|
+
transactions,
|
|
37
|
+
baseAsset,
|
|
38
|
+
address,
|
|
39
|
+
server = baseAsset.server,
|
|
40
|
+
}) {
|
|
41
|
+
const authenticated = []
|
|
42
|
+
const cache = new Map()
|
|
43
|
+
for (const item of transactions) {
|
|
44
|
+
if (item.from?.toLowerCase() !== address.toLowerCase()) {
|
|
45
|
+
authenticated.push(item)
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
let signed = cache.get(item.hash)
|
|
49
|
+
if (!signed) {
|
|
50
|
+
signed = await fetchAuthenticatedTransaction({ baseAsset, txId: item.hash, address, server })
|
|
51
|
+
cache.set(item.hash, signed)
|
|
52
|
+
}
|
|
53
|
+
authenticated.push({
|
|
54
|
+
...item,
|
|
55
|
+
hash: signed.hash,
|
|
56
|
+
from: signed.from.toLowerCase(),
|
|
57
|
+
to: signed.to?.toLowerCase() ?? null,
|
|
58
|
+
value: BigInt(signed.value).toString(),
|
|
59
|
+
nonce: rpcInteger(signed.nonce),
|
|
60
|
+
gas: rpcInteger(signed.gas),
|
|
61
|
+
input: signed.input,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
return authenticated
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function authenticateReplacement({ baseAsset, tx, address }) {
|
|
68
|
+
const signed = await fetchAuthenticatedTransaction({
|
|
69
|
+
baseAsset,
|
|
70
|
+
txId: tx.txId,
|
|
71
|
+
address,
|
|
72
|
+
missingTransactionMessage: `ERR_BUMP_TX_DROPPED: transaction ${tx.txId} is no longer available`,
|
|
73
|
+
})
|
|
74
|
+
const type = rpcInteger(signed.type ?? '0x0')
|
|
75
|
+
// The current replacement serializer supports legacy and fee-market envelopes.
|
|
76
|
+
// Never silently discard access lists, blob commitments or 7702 authorizations.
|
|
77
|
+
assert([0, 2].includes(type), 'Unsupported replacement transaction envelope')
|
|
78
|
+
assert(!signed.accessList?.length, 'Cannot replace a transaction with an access list')
|
|
79
|
+
const nonce = rpcInteger(signed.nonce)
|
|
80
|
+
const gasLimit = rpcInteger(signed.gas)
|
|
81
|
+
const gasPrice = BigInt(type === 2 ? signed.maxFeePerGas : signed.gasPrice)
|
|
82
|
+
const pricingTx = tx.update({
|
|
83
|
+
feeAmount: baseAsset.currency.baseUnit(gasPrice * BigInt(gasLimit)),
|
|
84
|
+
data: {
|
|
85
|
+
...tx.data,
|
|
86
|
+
nonce,
|
|
87
|
+
gasLimit,
|
|
88
|
+
gasPrice: gasPrice.toString(),
|
|
89
|
+
tipGasPrice: type === 2 ? BigInt(signed.maxPriorityFeePerGas).toString() : undefined,
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
return { signed, nonce, gasLimit, pricingTx }
|
|
93
|
+
}
|
package/src/tx-create.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
assertValidTxValue,
|
|
3
2
|
currency2buffer,
|
|
4
3
|
getHighestIncentiveTxByNonce,
|
|
5
4
|
isEthereumLikeToken,
|
|
@@ -7,6 +6,7 @@ import {
|
|
|
7
6
|
import createEthereumJsTx from '@exodus/ethereum-lib/src/unsigned-tx/create-ethereumjs-tx.js'
|
|
8
7
|
import assert from 'minimalistic-assert'
|
|
9
8
|
|
|
9
|
+
import { authenticateReplacement } from './transaction-authentication.js'
|
|
10
10
|
import { ensureSaneEip1559GasPriceForTipGasPrice } from './fee-utils.js'
|
|
11
11
|
import { fetchGasLimit } from './gas-estimation.js'
|
|
12
12
|
import { getAggregateTransactionPricing, getExtraFeeData } from './get-fee.js'
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
resolveTxAttributesByTxType,
|
|
18
18
|
resolveTxFromAddress,
|
|
19
19
|
resolveTxNonce,
|
|
20
|
+
TX_TYPE_CREATE_CONTRACT,
|
|
20
21
|
TX_TYPE_TRANSFER,
|
|
21
22
|
} from './tx-type/index.js'
|
|
22
23
|
|
|
@@ -327,86 +328,45 @@ const createBumpUnsignedTx = async ({
|
|
|
327
328
|
|
|
328
329
|
const baseAsset = asset.baseAsset
|
|
329
330
|
const replacedTx = baseAssetTxLog.get(bumpTxId)
|
|
330
|
-
const assets = await assetClientInterface.getAssetsForNetwork({ baseAssetName: baseAsset.name })
|
|
331
331
|
if (!replacedTx || !replacedTx.pending) {
|
|
332
332
|
throw new Error(`Cannot bump transaction ${bumpTxId}: not found or confirmed`)
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const tokenTxSet = await assetClientInterface.getTxLog({
|
|
339
|
-
assetName: tokenAssetName,
|
|
340
|
-
walletAccount,
|
|
341
|
-
})
|
|
342
|
-
replacedTokenTx = tokenTxSet.get(bumpTxId)
|
|
343
|
-
|
|
344
|
-
if (replacedTokenTx) {
|
|
345
|
-
// Attempt to overwrite the asset to reflect the fact that
|
|
346
|
-
// we're performing a token transaction.
|
|
347
|
-
asset = assets[tokenAssetName]
|
|
348
|
-
if (!asset) {
|
|
349
|
-
throw new Error(
|
|
350
|
-
`unable to find ${tokenAssetName} during token bump transaction: asset was not available in assetsForNetwork`
|
|
351
|
-
)
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const sourceTx = replacedTokenTx || replacedTx
|
|
357
|
-
const toAddress = sourceTx.to
|
|
358
|
-
|
|
359
|
-
// For self-sends `coinAmount` is `0` (correct for balance accounting, the
|
|
360
|
-
// wallet's net delta really is zero), so we can't reconstruct the on-chain
|
|
361
|
-
// value from it. Recover from `data.rawCoinAmount`, which the tx-log
|
|
362
|
-
// writers persist alongside `coinAmount` for exactly this purpose.
|
|
363
|
-
if (sourceTx.selfSend && !sourceTx.data?.rawCoinAmount) {
|
|
364
|
-
throw new Error(
|
|
365
|
-
`Cannot bump self-send transaction ${bumpTxId}: original transfer amount is unrecoverable from the tx log (data.rawCoinAmount missing). Re-broadcast the send with a higher fee instead.`
|
|
366
|
-
)
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const amount = sourceTx.data?.rawCoinAmount
|
|
370
|
-
? asset.currency.baseUnit(sourceTx.data.rawCoinAmount)
|
|
371
|
-
: sourceTx.coinAmount.negate()
|
|
372
|
-
|
|
373
|
-
const txValue = assertValidTxValue({
|
|
374
|
-
asset,
|
|
375
|
-
amount,
|
|
376
|
-
txValue: replacedTx.data?.rawCoinAmount
|
|
377
|
-
? baseAsset.currency.baseUnit(replacedTx.data.rawCoinAmount)
|
|
378
|
-
: replacedTx.coinAmount.negate(),
|
|
335
|
+
const walletAddress = await assetClientInterface.getReceiveAddress({
|
|
336
|
+
assetName: baseAsset.name,
|
|
337
|
+
walletAccount,
|
|
379
338
|
})
|
|
380
|
-
|
|
381
|
-
const isDuplex = Boolean(replacedTokenTx && txValue.gt(baseAsset.currency.ZERO))
|
|
382
|
-
|
|
383
|
-
const txInput = replacedTx.data.data || '0x'
|
|
384
|
-
|
|
385
|
-
const isTokenTx =
|
|
386
|
-
!isDuplex /* nonpayable */ &&
|
|
387
|
-
replacedTokenTx &&
|
|
388
|
-
Object.keys(asset.contract.methodIds).includes(txInput.slice(0, 10))
|
|
389
|
-
|
|
390
|
-
const txToAddress = isTokenTx ? asset.contract.address : undefined
|
|
391
|
-
|
|
392
|
-
const replacedTxNonce = replacedTx.data.nonce
|
|
393
|
-
|
|
394
339
|
assert(
|
|
395
|
-
|
|
396
|
-
|
|
340
|
+
fromAddress.toLowerCase() === walletAddress.toLowerCase(),
|
|
341
|
+
'Replacement sender must be the wallet address'
|
|
397
342
|
)
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
343
|
+
const { signed, nonce, gasLimit, pricingTx } = await authenticateReplacement({
|
|
344
|
+
baseAsset,
|
|
345
|
+
tx: replacedTx,
|
|
346
|
+
address: fromAddress,
|
|
347
|
+
})
|
|
348
|
+
if (maybeProvidedNonce !== undefined && maybeProvidedNonce !== nonce) {
|
|
403
349
|
throw new Error('incorrect nonce for replacement transaction')
|
|
404
350
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
351
|
+
// Reuse the exact signed call. In particular, never reconstruct token calldata
|
|
352
|
+
// from indexer effects or use a tx-log recipient as the outer destination.
|
|
353
|
+
const txInput = signed.input
|
|
354
|
+
const txToAddress = signed.to ?? null
|
|
355
|
+
const txValue = baseAsset.currency.baseUnit(BigInt(signed.value))
|
|
356
|
+
let toAddress = txToAddress
|
|
357
|
+
let amount = asset === baseAsset ? txValue : asset.currency.ZERO
|
|
358
|
+
if (
|
|
359
|
+
asset !== baseAsset &&
|
|
360
|
+
asset.contract?.address.toLowerCase() === txToAddress?.toLowerCase() &&
|
|
361
|
+
Object.hasOwn(asset.contract.methodIds, txInput.slice(0, 10))
|
|
362
|
+
) {
|
|
363
|
+
const { method, values } = asset.contract.decodeInput(Buffer.from(txInput.slice(2), 'hex'))
|
|
364
|
+
if (method === 'transfer' || method === 'approve') {
|
|
365
|
+
toAddress = values[0]
|
|
366
|
+
amount = asset.currency.baseUnit(values[1])
|
|
367
|
+
}
|
|
368
|
+
}
|
|
408
369
|
await verifyBumpTxCanReplace({ baseAsset, bumpTxId, fromAddress, nonce })
|
|
409
|
-
|
|
410
370
|
const resolvedTxAttributes = await resolveTxAttributesByTxType({
|
|
411
371
|
asset,
|
|
412
372
|
assetClientInterface,
|
|
@@ -416,36 +376,46 @@ const createBumpUnsignedTx = async ({
|
|
|
416
376
|
txInput,
|
|
417
377
|
toAddress,
|
|
418
378
|
txToAddress,
|
|
419
|
-
txType,
|
|
379
|
+
txType: txToAddress === null ? TX_TYPE_CREATE_CONTRACT : TX_TYPE_TRANSFER,
|
|
420
380
|
txValue,
|
|
421
381
|
walletAccount,
|
|
422
382
|
})
|
|
423
383
|
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
const maybeTxToReplace = await getHighestIncentiveTxByNonce({
|
|
384
|
+
const prices = await resolveTxFactoryGasPrices({
|
|
427
385
|
assetClientInterface,
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
386
|
+
baseAsset,
|
|
387
|
+
feeData,
|
|
388
|
+
nonce,
|
|
389
|
+
txToReplace: pricingTx,
|
|
431
390
|
})
|
|
391
|
+
const { eip1559Enabled } = prices
|
|
392
|
+
let { resolvedGasPrice: gasPrice, resolvedTipGasPrice: tipGasPrice } = prices
|
|
432
393
|
|
|
433
|
-
|
|
434
|
-
maybeTxToReplace,
|
|
435
|
-
`unable to resolve pending transaction for nonce ${resolvedTxAttributes.nonce}`
|
|
436
|
-
)
|
|
437
|
-
|
|
438
|
-
const {
|
|
439
|
-
eip1559Enabled,
|
|
440
|
-
resolvedGasPrice: gasPrice,
|
|
441
|
-
resolvedTipGasPrice: tipGasPrice,
|
|
442
|
-
} = await resolveTxFactoryGasPrices({
|
|
394
|
+
const competingTx = await getHighestIncentiveTxByNonce({
|
|
443
395
|
assetClientInterface,
|
|
444
|
-
baseAsset,
|
|
445
|
-
feeData,
|
|
396
|
+
asset: baseAsset,
|
|
446
397
|
nonce,
|
|
447
|
-
|
|
398
|
+
walletAccount,
|
|
448
399
|
})
|
|
400
|
+
if (competingTx && competingTx.txId !== bumpTxId) {
|
|
401
|
+
const competing = await authenticateReplacement({
|
|
402
|
+
baseAsset,
|
|
403
|
+
tx: competingTx,
|
|
404
|
+
address: fromAddress,
|
|
405
|
+
})
|
|
406
|
+
assert(competing.nonce === nonce, 'Replacement nonce does not match signed transaction')
|
|
407
|
+
const competingPrices = await resolveTxFactoryGasPrices({
|
|
408
|
+
assetClientInterface,
|
|
409
|
+
baseAsset,
|
|
410
|
+
feeData,
|
|
411
|
+
nonce,
|
|
412
|
+
txToReplace: competing.pricingTx,
|
|
413
|
+
})
|
|
414
|
+
if (competingPrices.resolvedGasPrice.gt(gasPrice)) gasPrice = competingPrices.resolvedGasPrice
|
|
415
|
+
if (eip1559Enabled && competingPrices.resolvedTipGasPrice.gt(tipGasPrice)) {
|
|
416
|
+
tipGasPrice = competingPrices.resolvedTipGasPrice
|
|
417
|
+
}
|
|
418
|
+
}
|
|
449
419
|
|
|
450
420
|
return createUnsignedTxWithFees({
|
|
451
421
|
...resolvedTxAttributes,
|
|
@@ -540,6 +510,24 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
|
|
|
540
510
|
walletAccount,
|
|
541
511
|
})
|
|
542
512
|
|
|
513
|
+
if (maybeTxToReplace) {
|
|
514
|
+
const walletAddress = await assetClientInterface.getReceiveAddress({
|
|
515
|
+
assetName: baseAsset.name,
|
|
516
|
+
walletAccount,
|
|
517
|
+
})
|
|
518
|
+
assert(
|
|
519
|
+
fromAddress.toLowerCase() === walletAddress.toLowerCase(),
|
|
520
|
+
'Replacement sender must be the wallet address'
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
const pricingTxToReplace = maybeTxToReplace
|
|
524
|
+
? await authenticateReplacement({ baseAsset, tx: maybeTxToReplace, address: fromAddress })
|
|
525
|
+
: undefined
|
|
526
|
+
assert(
|
|
527
|
+
!pricingTxToReplace || pricingTxToReplace.nonce === nonce,
|
|
528
|
+
'Replacement nonce does not match signed transaction'
|
|
529
|
+
)
|
|
530
|
+
|
|
543
531
|
const {
|
|
544
532
|
eip1559Enabled,
|
|
545
533
|
resolvedGasPrice: gasPrice,
|
|
@@ -552,7 +540,7 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
|
|
|
552
540
|
gasPrice: providedGasPrice,
|
|
553
541
|
nonce,
|
|
554
542
|
tipGasPrice: providedTipGasPrice,
|
|
555
|
-
txToReplace:
|
|
543
|
+
txToReplace: pricingTxToReplace?.pricingTx,
|
|
556
544
|
})
|
|
557
545
|
|
|
558
546
|
if (nft) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { BaseMonitor } from '@exodus/asset-lib'
|
|
2
|
+
import { Tx } from '@exodus/models'
|
|
3
|
+
import assert from 'minimalistic-assert'
|
|
4
|
+
|
|
5
|
+
import { fetchAuthenticatedTransaction, rpcInteger } from '../transaction-authentication.js'
|
|
6
|
+
|
|
7
|
+
export class AuthenticatedEthereumMonitor extends BaseMonitor {
|
|
8
|
+
async verifyNewSentTransactions({ assetName = this.asset.name, logItems, walletAccount }) {
|
|
9
|
+
if (logItems.length === 0) return
|
|
10
|
+
let address
|
|
11
|
+
let txLog
|
|
12
|
+
for (const item of logItems) {
|
|
13
|
+
const tx = Tx.fromJSON(item)
|
|
14
|
+
if (tx.dropped) {
|
|
15
|
+
txLog ??= await this.aci.getTxLog({ assetName, walletAccount })
|
|
16
|
+
const existing = txLog.get(tx.txId)
|
|
17
|
+
// A disappeared envelope must not prevent marking the stored transaction
|
|
18
|
+
// dropped. Only its status may change without reauthentication.
|
|
19
|
+
if (existing && tx.equals(existing.update({ dropped: true, error: tx.error }))) continue
|
|
20
|
+
}
|
|
21
|
+
if (!tx.sent) {
|
|
22
|
+
if (!tx.from?.length) continue
|
|
23
|
+
address ??= await this.aci.getReceiveAddress({
|
|
24
|
+
assetName: this.asset.name,
|
|
25
|
+
walletAccount,
|
|
26
|
+
useCache: true,
|
|
27
|
+
})
|
|
28
|
+
if (!tx.from.some((from) => from.toLowerCase() === address.toLowerCase())) continue
|
|
29
|
+
}
|
|
30
|
+
assert(
|
|
31
|
+
(await this.verifyTransactionAuthentication({ tx, assetName, walletAccount })) === true,
|
|
32
|
+
`Cannot authenticate outgoing transaction ${tx.txId}`
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async verifyTransactionAuthentication(params) {
|
|
38
|
+
if (typeof this.asset.verifyTransactionAuthentication === 'function') {
|
|
39
|
+
return super.verifyTransactionAuthentication(params)
|
|
40
|
+
}
|
|
41
|
+
const address = await this.aci.getReceiveAddress({
|
|
42
|
+
assetName: this.asset.name,
|
|
43
|
+
walletAccount: params.walletAccount,
|
|
44
|
+
useCache: true,
|
|
45
|
+
})
|
|
46
|
+
const transaction = await fetchAuthenticatedTransaction({
|
|
47
|
+
baseAsset: this.asset,
|
|
48
|
+
txId: params.tx.txId,
|
|
49
|
+
address,
|
|
50
|
+
server: this.server,
|
|
51
|
+
})
|
|
52
|
+
const tx = params.tx
|
|
53
|
+
// Bind the fields used by acceleration even for entries already in the log.
|
|
54
|
+
// Token destinations/amounts describe effects rather than the outer envelope;
|
|
55
|
+
// replacement creation always uses the verified envelope itself.
|
|
56
|
+
if (tx.data.nonce !== undefined && tx.data.nonce !== rpcInteger(transaction.nonce)) return false
|
|
57
|
+
if (tx.data.gasLimit !== undefined && tx.data.gasLimit !== rpcInteger(transaction.gas)) {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
if (
|
|
61
|
+
tx.data.data !== undefined &&
|
|
62
|
+
tx.data.data.toLowerCase() !== transaction.input.toLowerCase()
|
|
63
|
+
) {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
if (params.assetName === this.asset.name) {
|
|
67
|
+
// Base-asset fee entries for token operations can display the token
|
|
68
|
+
// recipient. That execution effect is not the envelope's destination.
|
|
69
|
+
if (
|
|
70
|
+
tx.tokens.length === 0 &&
|
|
71
|
+
tx.to &&
|
|
72
|
+
tx.to.toLowerCase() !== (transaction.to ?? address).toLowerCase()
|
|
73
|
+
) {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
if (
|
|
77
|
+
tx.data.rawCoinAmount !== undefined &&
|
|
78
|
+
BigInt(tx.data.rawCoinAmount) !== BigInt(transaction.value)
|
|
79
|
+
) {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { BaseMonitor } from '@exodus/asset-lib'
|
|
2
1
|
import { getAssetAddresses } from '@exodus/ethereum-lib'
|
|
3
2
|
import lodash from 'lodash'
|
|
4
3
|
import assert from 'minimalistic-assert'
|
|
5
4
|
|
|
5
|
+
import { AuthenticatedEthereumMonitor } from './authenticated-monitor.js'
|
|
6
6
|
import { applyRemoteConfigEip7702Whitelist, createEip7702Whitelist } from '../eip7702-whitelist.js'
|
|
7
|
+
import { authenticateHistoryTransactions } from '../transaction-authentication.js'
|
|
7
8
|
import { executeEthLikeFeeMonitorUpdate } from '../fee-utils.js'
|
|
8
9
|
import {
|
|
9
10
|
filterEffects,
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
|
|
26
27
|
const { isEmpty } = lodash
|
|
27
28
|
|
|
28
|
-
export class ClarityMonitor extends
|
|
29
|
+
export class ClarityMonitor extends AuthenticatedEthereumMonitor {
|
|
29
30
|
constructor({
|
|
30
31
|
server,
|
|
31
32
|
config,
|
|
@@ -42,6 +43,7 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
42
43
|
this.eip7702Whitelist = eip7702Whitelist ?? createEip7702Whitelist(eip7702Supported)
|
|
43
44
|
this.getBlackListStatus = getBlackListStatus
|
|
44
45
|
this.getAllLogItemsByAsset = getAllLogItemsByAsset
|
|
46
|
+
this.emittedUnknownTokenAddresses = new Set()
|
|
45
47
|
this.deriveDataNeededForTick = getDeriveDataNeededForTick(this.aci)
|
|
46
48
|
this.deriveTransactionsToCheck = getDeriveTransactionsToCheck({
|
|
47
49
|
getTxLog: (...args) => this.aci.getTxLog(...args),
|
|
@@ -240,7 +242,6 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
240
242
|
|
|
241
243
|
const batch = this.aci.createOperationsBatch()
|
|
242
244
|
let allTxs = []
|
|
243
|
-
let hasNewTxs = false
|
|
244
245
|
let historyResponse
|
|
245
246
|
let historyError
|
|
246
247
|
let processedLogItemsByAsset
|
|
@@ -255,7 +256,12 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
255
256
|
walletAccount,
|
|
256
257
|
}))
|
|
257
258
|
|
|
258
|
-
|
|
259
|
+
allTxs = await authenticateHistoryTransactions({
|
|
260
|
+
transactions: allTxs,
|
|
261
|
+
baseAsset: this.asset,
|
|
262
|
+
server: this.server,
|
|
263
|
+
address: derivedData.ourWalletAddress,
|
|
264
|
+
})
|
|
259
265
|
|
|
260
266
|
const logItemsByAsset = this.getAllLogItemsByAsset({
|
|
261
267
|
getLogItemsFromServerTx,
|
|
@@ -266,6 +272,10 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
266
272
|
assets,
|
|
267
273
|
})
|
|
268
274
|
|
|
275
|
+
if (this.isTipSync({ derivedData, refresh })) {
|
|
276
|
+
await this.warnReaddedTxs({ walletAccount, logItemsByAsset })
|
|
277
|
+
}
|
|
278
|
+
|
|
269
279
|
const { txsToRemove } = await this.checkPendingTransactions({
|
|
270
280
|
txlist: allTxs,
|
|
271
281
|
walletAccount,
|
|
@@ -275,6 +285,12 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
275
285
|
...derivedData,
|
|
276
286
|
})
|
|
277
287
|
|
|
288
|
+
await this.updateTxLogByAssetBatch({
|
|
289
|
+
logItemsByAsset,
|
|
290
|
+
walletAccount,
|
|
291
|
+
refresh,
|
|
292
|
+
batch,
|
|
293
|
+
})
|
|
278
294
|
this.aci.removeTxLogBatch({
|
|
279
295
|
assetName,
|
|
280
296
|
walletAccount,
|
|
@@ -282,16 +298,6 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
282
298
|
batch,
|
|
283
299
|
})
|
|
284
300
|
|
|
285
|
-
for (const [assetName, txs] of Object.entries(logItemsByAsset)) {
|
|
286
|
-
this.aci.updateTxLogAndNotifyBatch({
|
|
287
|
-
assetName,
|
|
288
|
-
walletAccount,
|
|
289
|
-
txs,
|
|
290
|
-
refresh,
|
|
291
|
-
batch,
|
|
292
|
-
})
|
|
293
|
-
}
|
|
294
|
-
|
|
295
301
|
historyResponse = response
|
|
296
302
|
processedLogItemsByAsset = logItemsByAsset
|
|
297
303
|
} catch (error) {
|
|
@@ -333,13 +339,44 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
333
339
|
: new Error(String(historyError), { cause: historyError })
|
|
334
340
|
}
|
|
335
341
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
342
|
+
const unknownTokenAddresses = this.getUnknownTokenAddresses({
|
|
343
|
+
transactions: allTxs,
|
|
344
|
+
tokensByAddress,
|
|
345
|
+
})
|
|
346
|
+
const addressesToEmit = refresh
|
|
347
|
+
? unknownTokenAddresses
|
|
348
|
+
: unknownTokenAddresses.filter((address) => !this.emittedUnknownTokenAddresses.has(address))
|
|
349
|
+
for (const address of unknownTokenAddresses) this.emittedUnknownTokenAddresses.add(address)
|
|
350
|
+
if (addressesToEmit.length > 0) {
|
|
351
|
+
this.emit('unknown-tokens', addressesToEmit)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async warnReaddedTxs({ walletAccount, logItemsByAsset }) {
|
|
356
|
+
const isConfirmed = (tx) => tx.confirmations > 0 && Number.isInteger(tx.data?.blockNumber)
|
|
357
|
+
|
|
358
|
+
for (const [assetName, txs] of Object.entries(logItemsByAsset)) {
|
|
359
|
+
const confirmedTxs = txs.filter(isConfirmed)
|
|
360
|
+
if (confirmedTxs.length === 0) continue
|
|
361
|
+
|
|
362
|
+
const txLog = await this.aci.getTxLog({ assetName, walletAccount })
|
|
363
|
+
const missingTxs = confirmedTxs.filter((tx) => !txLog.has(tx.txId))
|
|
364
|
+
if (missingTxs.length === 0) continue
|
|
365
|
+
|
|
366
|
+
let newestLoggedBlock = -1
|
|
367
|
+
for (const tx of txLog) {
|
|
368
|
+
if (isConfirmed(tx)) newestLoggedBlock = Math.max(newestLoggedBlock, tx.data.blockNumber)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const readdedTxIds = missingTxs
|
|
372
|
+
.filter((tx) => tx.data.blockNumber <= newestLoggedBlock)
|
|
373
|
+
.map((tx) => tx.txId)
|
|
374
|
+
if (readdedTxIds.length > 0) {
|
|
375
|
+
this.logger.warn('clarity tip window re-added txs missing from tx log', {
|
|
376
|
+
assetName,
|
|
377
|
+
walletAccount,
|
|
378
|
+
txIds: readdedTxIds,
|
|
379
|
+
})
|
|
343
380
|
}
|
|
344
381
|
}
|
|
345
382
|
}
|
|
@@ -482,11 +519,15 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
482
519
|
this.server.dispose()
|
|
483
520
|
}
|
|
484
521
|
|
|
522
|
+
isTipSync({ derivedData, refresh }) {
|
|
523
|
+
return !refresh && Boolean(derivedData.currentAccountState?.clarityCursor)
|
|
524
|
+
}
|
|
525
|
+
|
|
485
526
|
async getHistoryFromServer({ walletAccount, derivedData, refresh }) {
|
|
486
527
|
const address = derivedData.ourWalletAddress
|
|
487
|
-
const
|
|
488
|
-
const cursor =
|
|
489
|
-
return this.server.getAllTransactions({ walletAccount, address, cursor })
|
|
528
|
+
const tip = this.isTipSync({ derivedData, refresh })
|
|
529
|
+
const cursor = tip ? derivedData.currentAccountState.clarityCursor : null
|
|
530
|
+
return this.server.getAllTransactions({ walletAccount, address, cursor, tip })
|
|
490
531
|
}
|
|
491
532
|
|
|
492
533
|
listenToServerEvents() {
|
|
@@ -137,15 +137,15 @@ export class ClarityTruncatedHistoryMonitor extends ClarityMonitor {
|
|
|
137
137
|
|
|
138
138
|
async getHistoryFromServer({ walletAccount, derivedData, refresh }) {
|
|
139
139
|
const address = derivedData.ourWalletAddress
|
|
140
|
-
const
|
|
141
|
-
const cursor =
|
|
140
|
+
const tip = this.isTipSync({ derivedData, refresh })
|
|
141
|
+
const cursor = tip ? derivedData.currentAccountState.clarityCursor : null
|
|
142
142
|
return this.server.getAllTransactions({
|
|
143
143
|
walletAccount,
|
|
144
144
|
address,
|
|
145
145
|
cursor,
|
|
146
|
-
refresh,
|
|
147
146
|
withInput: true,
|
|
148
147
|
truncated: true,
|
|
148
|
+
tip,
|
|
149
149
|
})
|
|
150
150
|
}
|
|
151
151
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Extends `ClarityMonitor`. Used when Clarity only serves a **recent window** of txs and attaches pre-cursor snapshots in `accountInfo`.
|
|
4
4
|
|
|
5
5
|
- Sends `truncated=true` on `getAllTransactions`.
|
|
6
|
+
- Incremental ticks use Clarity `mode=tip`, like `clarity-v2`. The tip page carries `accountInfo`, so `account_balance` rows still update rebasing tokens. First sync and refresh use default mode.
|
|
6
7
|
- Folds `accountInfo` into `accountState`, including truncated-history baselines.
|
|
7
8
|
- Forces `rpcBalanceAssetNames = []` (no per-asset RPC `eth_getBalance` batch).
|
|
8
9
|
|
|
@@ -217,6 +218,7 @@ If the backend can't emit `accountInfo`, the monitor degrades gracefully to `cla
|
|
|
217
218
|
|
|
218
219
|
## Tests
|
|
219
220
|
|
|
221
|
+
- `src/tx-log/__tests__/clarity-tip-mode.test.js` — `clarity-v2` tip mode (dropped tx re-added, stable cursor, default mode on refresh); this monitor's tip ticks send `truncated=true` and `account_balance` rows track a rebase; refresh uses default mode and still collects `accountInfo`.
|
|
220
222
|
- `src/tx-log/__tests__/clarity-truncated-history-monitor.test.js` — `truncated: true`, no RPC balance batch, accountInfo → `balance`, token `tokenBalances`, and `truncatedAccountState`.
|
|
221
223
|
- `src/__tests__/create-asset-utils.test.js` — factory → `ClarityTruncatedHistoryMonitor`.
|
|
222
224
|
- `src/__tests__/get-balances.test.js` — accountState token short-circuit and truncated baseline cases.
|
|
@@ -55,9 +55,8 @@ export default function getLogItemsFromServerTx({
|
|
|
55
55
|
const toAddress = tryFindExternalRecipient(ethereumTransfers, ourWalletAddress)
|
|
56
56
|
const ourWalletWasSender = serverTx.from === ourWalletAddress
|
|
57
57
|
const METHOD_ID_LENGTH = 10
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
}
|
|
58
|
+
const serverMethodId = serverTx.methodId ?? serverTx.input?.slice(0, METHOD_ID_LENGTH)
|
|
59
|
+
const methodId = serverMethodId && { methodId: serverMethodId }
|
|
61
60
|
const data = (ourWalletWasSender ? serverTx.input : methodId?.methodId) || '0x'
|
|
62
61
|
const walletUpdates = getWalletUpdates(ourWalletAddress, serverTx.walletChanges || [])
|
|
63
62
|
const { baseBalanceUpdate, nonceUpdate, tokenBalancesUpdate } = walletUpdates
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { BaseMonitor } from '@exodus/asset-lib'
|
|
2
1
|
import { SynchronizedTime } from '@exodus/basic-utils'
|
|
3
2
|
import { Tx } from '@exodus/models'
|
|
4
3
|
import lodash from 'lodash'
|
|
5
4
|
|
|
5
|
+
import { AuthenticatedEthereumMonitor } from './authenticated-monitor.js'
|
|
6
6
|
import { applyRemoteConfigEip7702Whitelist, createEip7702Whitelist } from '../eip7702-whitelist.js'
|
|
7
7
|
import { getBatchedRpcBalances } from './monitor-utils/get-batched-rpc-balances.js'
|
|
8
8
|
import { UNCONFIRMED_TX_LIMIT } from './monitor-utils/get-derive-transactions-to-check.js'
|
|
@@ -25,7 +25,7 @@ const getMissingNonce = (tx, txFromNode) => {
|
|
|
25
25
|
|
|
26
26
|
// The base ethereum monitor no history class handles listening for assets with no history
|
|
27
27
|
|
|
28
|
-
export class EthereumNoHistoryMonitor extends
|
|
28
|
+
export class EthereumNoHistoryMonitor extends AuthenticatedEthereumMonitor {
|
|
29
29
|
constructor({ server, config, eip7702Supported, eip7702Whitelist, getBlackListStatus, ...args }) {
|
|
30
30
|
super(args)
|
|
31
31
|
this.server = server
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import assert from 'minimalistic-assert'
|
|
2
|
+
|
|
1
3
|
import { getPolygonUndelegateTxInEthereumTxLog } from '../staking/matic/matic-staking-utils.js'
|
|
2
4
|
import { assetStakingTxProps } from './asset-staking-tx-data.js'
|
|
3
5
|
|
|
@@ -17,7 +19,13 @@ const getAssetExpandedTxLog = async ({ assetName, aci, txs, walletAccount }) =>
|
|
|
17
19
|
return additionalTxs
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
const processTxLog = async ({
|
|
22
|
+
const processTxLog = async ({
|
|
23
|
+
asset,
|
|
24
|
+
assetClientInterface: aci,
|
|
25
|
+
walletAccount,
|
|
26
|
+
batch,
|
|
27
|
+
monitor,
|
|
28
|
+
}) => {
|
|
21
29
|
const { name: assetName, currency } = asset
|
|
22
30
|
const txs = await aci.getTxLog({ assetName, walletAccount })
|
|
23
31
|
|
|
@@ -36,6 +44,11 @@ const processTxLog = async ({ asset, assetClientInterface: aci, walletAccount, b
|
|
|
36
44
|
const expandedTxs = await getAssetExpandedTxLog({ assetName, aci, txs, walletAccount })
|
|
37
45
|
newTxs.push(...expandedTxs)
|
|
38
46
|
|
|
47
|
+
if (expandedTxs.length > 0) {
|
|
48
|
+
assert(monitor, 'History monitor is required to authenticate new staking transactions')
|
|
49
|
+
await monitor.verifyNewSentTransactions({ assetName, logItems: expandedTxs, walletAccount })
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
if (newTxs.length > 0) {
|
|
40
53
|
aci.updateTxLogAndNotifyBatch({ assetName, walletAccount, txs: newTxs, batch })
|
|
41
54
|
}
|