@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 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.2.3",
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": "1fc0966a5723988e8151f3f93486d31cd95ede4f"
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
@@ -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
- let replacedTokenTx
336
- if (replacedTx.tokens.length > 0) {
337
- const [tokenAssetName] = replacedTx.tokens
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
- Number.isInteger(replacedTxNonce),
396
- `Cannot bump transaction ${bumpTxId}: data object seems to be corrupted`
340
+ fromAddress.toLowerCase() === walletAddress.toLowerCase(),
341
+ 'Replacement sender must be the wallet address'
397
342
  )
398
-
399
- // If we have evaluated a bump transaction and the `providedNonce` differs
400
- // from the `bumpNonce`, we've encountered a conflict and cannot respect
401
- // the caller's request.
402
- if (maybeProvidedNonce && maybeProvidedNonce !== replacedTxNonce) {
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
- const nonce = maybeProvidedNonce ?? replacedTxNonce
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 gasLimit = replacedTx.data.gasLimit
425
-
426
- const maybeTxToReplace = await getHighestIncentiveTxByNonce({
384
+ const prices = await resolveTxFactoryGasPrices({
427
385
  assetClientInterface,
428
- asset,
429
- nonce: resolvedTxAttributes.nonce,
430
- walletAccount,
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
- assert(
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
- txToReplace: maybeTxToReplace,
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: maybeTxToReplace,
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 BaseMonitor {
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
- hasNewTxs = allTxs.length > 0
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
- if (refresh || hasNewTxs) {
337
- const unknownTokenAddresses = this.getUnknownTokenAddresses({
338
- transactions: allTxs,
339
- tokensByAddress,
340
- })
341
- if (unknownTokenAddresses.length > 0) {
342
- this.emit('unknown-tokens', unknownTokenAddresses)
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 currentCursor = derivedData.currentAccountState?.clarityCursor
488
- const cursor = currentCursor && !refresh ? currentCursor : null
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 currentCursor = derivedData.currentAccountState?.clarityCursor
141
- const cursor = currentCursor && !refresh ? currentCursor : null
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 methodId = serverTx.input && {
59
- methodId: serverTx.input.slice(0, Math.max(0, METHOD_ID_LENGTH)),
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 BaseMonitor {
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 ({ asset, assetClientInterface: aci, walletAccount, batch }) => {
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
  }