@exodus/ethereum-api 10.2.2 → 10.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,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.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@10.2.3...@exodus/ethereum-api@10.3.0) (2026-10-30)
7
+
8
+
9
+ ### Features
10
+
11
+ * **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))
12
+
13
+
14
+
15
+ ## [10.2.3](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@10.2.2...@exodus/ethereum-api@10.2.3) (2026-10-23)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **ethereum-api:** allow accelerating a tx parked below the base fee ([#9049](https://github.com/ExodusMovement/assets/issues/9049)) ([2dac03f](https://github.com/ExodusMovement/assets/commit/2dac03f68d1de7cc56a191e82e4e217ad8a5bf07))
21
+
22
+
23
+
6
24
  ## [10.2.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@10.2.1...@exodus/ethereum-api@10.2.2) (2026-10-22)
7
25
 
8
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "10.2.2",
3
+ "version": "10.3.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",
@@ -65,5 +65,5 @@
65
65
  "type": "git",
66
66
  "url": "git+https://github.com/ExodusMovement/assets.git"
67
67
  },
68
- "gitHead": "8b769034df65590a5fcab8a40d0929c162b117c5"
68
+ "gitHead": "99c7d9d4aae75358a0269479e056e5785fbad5ed"
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/tx-create.js CHANGED
@@ -291,10 +291,11 @@ async function verifyBumpTxCanReplace({ baseAsset, bumpTxId, fromAddress, nonce
291
291
  // [0][1][2][3]{4}{5}{6}...
292
292
  // ^--- next empty slot (pendingNonce)
293
293
  //
294
- // Nonce to replace must be < pendingNonce (i.e. slot is already accounted for).
295
- // If nonce >= pendingNonce, the slot is empty or gapped — not safe to replace.
294
+ // Nonce to replace must be <= pendingNonce. At pendingNonce the node holds the tx
295
+ // without counting it, e.g. its fee cap is below the base fee, and it is the next
296
+ // executable slot. If nonce > pendingNonce, the slots below it are gapped — not safe to replace.
296
297
  const pendingNonce = parseInt(pendingNonceHex, 16)
297
- if (pendingNonce <= nonce) {
298
+ if (pendingNonce < nonce) {
298
299
  // A predecessor tx was dropped by the node, leaving a gap below this tx.
299
300
  // The tx itself is still in the node's queued pool but can't execute until
300
301
  // the missing nonce slot is filled.
@@ -304,7 +305,7 @@ async function verifyBumpTxCanReplace({ baseAsset, bumpTxId, fromAddress, nonce
304
305
  // first, then you'll be able to accelerate this one."
305
306
  throw new Error(
306
307
  `ERR_BUMP_TX_NONCE_GAP: Cannot bump transaction ${bumpTxId}: nonce gap detected — ` +
307
- `node's pending nonce (${pendingNonce}) ≤ tx nonce (${nonce}), ` +
308
+ `node's pending nonce (${pendingNonce}) < tx nonce (${nonce}), ` +
308
309
  `broadcasting would produce ErrFutureReplacePending`
309
310
  )
310
311
  }
@@ -42,6 +42,7 @@ export class ClarityMonitor extends BaseMonitor {
42
42
  this.eip7702Whitelist = eip7702Whitelist ?? createEip7702Whitelist(eip7702Supported)
43
43
  this.getBlackListStatus = getBlackListStatus
44
44
  this.getAllLogItemsByAsset = getAllLogItemsByAsset
45
+ this.emittedUnknownTokenAddresses = new Set()
45
46
  this.deriveDataNeededForTick = getDeriveDataNeededForTick(this.aci)
46
47
  this.deriveTransactionsToCheck = getDeriveTransactionsToCheck({
47
48
  getTxLog: (...args) => this.aci.getTxLog(...args),
@@ -240,7 +241,6 @@ export class ClarityMonitor extends BaseMonitor {
240
241
 
241
242
  const batch = this.aci.createOperationsBatch()
242
243
  let allTxs = []
243
- let hasNewTxs = false
244
244
  let historyResponse
245
245
  let historyError
246
246
  let processedLogItemsByAsset
@@ -255,8 +255,6 @@ export class ClarityMonitor extends BaseMonitor {
255
255
  walletAccount,
256
256
  }))
257
257
 
258
- hasNewTxs = allTxs.length > 0
259
-
260
258
  const logItemsByAsset = this.getAllLogItemsByAsset({
261
259
  getLogItemsFromServerTx,
262
260
  ourWalletAddress: derivedData.ourWalletAddress,
@@ -266,6 +264,10 @@ export class ClarityMonitor extends BaseMonitor {
266
264
  assets,
267
265
  })
268
266
 
267
+ if (this.isTipSync({ derivedData, refresh })) {
268
+ await this.warnReaddedTxs({ walletAccount, logItemsByAsset })
269
+ }
270
+
269
271
  const { txsToRemove } = await this.checkPendingTransactions({
270
272
  txlist: allTxs,
271
273
  walletAccount,
@@ -333,13 +335,44 @@ export class ClarityMonitor extends BaseMonitor {
333
335
  : new Error(String(historyError), { cause: historyError })
334
336
  }
335
337
 
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)
338
+ const unknownTokenAddresses = this.getUnknownTokenAddresses({
339
+ transactions: allTxs,
340
+ tokensByAddress,
341
+ })
342
+ const addressesToEmit = refresh
343
+ ? unknownTokenAddresses
344
+ : unknownTokenAddresses.filter((address) => !this.emittedUnknownTokenAddresses.has(address))
345
+ for (const address of unknownTokenAddresses) this.emittedUnknownTokenAddresses.add(address)
346
+ if (addressesToEmit.length > 0) {
347
+ this.emit('unknown-tokens', addressesToEmit)
348
+ }
349
+ }
350
+
351
+ async warnReaddedTxs({ walletAccount, logItemsByAsset }) {
352
+ const isConfirmed = (tx) => tx.confirmations > 0 && Number.isInteger(tx.data?.blockNumber)
353
+
354
+ for (const [assetName, txs] of Object.entries(logItemsByAsset)) {
355
+ const confirmedTxs = txs.filter(isConfirmed)
356
+ if (confirmedTxs.length === 0) continue
357
+
358
+ const txLog = await this.aci.getTxLog({ assetName, walletAccount })
359
+ const missingTxs = confirmedTxs.filter((tx) => !txLog.has(tx.txId))
360
+ if (missingTxs.length === 0) continue
361
+
362
+ let newestLoggedBlock = -1
363
+ for (const tx of txLog) {
364
+ if (isConfirmed(tx)) newestLoggedBlock = Math.max(newestLoggedBlock, tx.data.blockNumber)
365
+ }
366
+
367
+ const readdedTxIds = missingTxs
368
+ .filter((tx) => tx.data.blockNumber <= newestLoggedBlock)
369
+ .map((tx) => tx.txId)
370
+ if (readdedTxIds.length > 0) {
371
+ this.logger.warn('clarity tip window re-added txs missing from tx log', {
372
+ assetName,
373
+ walletAccount,
374
+ txIds: readdedTxIds,
375
+ })
343
376
  }
344
377
  }
345
378
  }
@@ -482,11 +515,15 @@ export class ClarityMonitor extends BaseMonitor {
482
515
  this.server.dispose()
483
516
  }
484
517
 
518
+ isTipSync({ derivedData, refresh }) {
519
+ return !refresh && Boolean(derivedData.currentAccountState?.clarityCursor)
520
+ }
521
+
485
522
  async getHistoryFromServer({ walletAccount, derivedData, refresh }) {
486
523
  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 })
524
+ const tip = this.isTipSync({ derivedData, refresh })
525
+ const cursor = tip ? derivedData.currentAccountState.clarityCursor : null
526
+ return this.server.getAllTransactions({ walletAccount, address, cursor, tip })
490
527
  }
491
528
 
492
529
  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