@exodus/ethereum-api 8.71.3 → 8.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,26 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [8.73.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.72.0...@exodus/ethereum-api@8.73.0) (2026-05-01)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: Faster Security Hydration Across Monitor (#7824)
13
+
14
+
15
+
16
+ ## [8.72.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.71.3...@exodus/ethereum-api@8.72.0) (2026-04-30)
17
+
18
+
19
+ ### Features
20
+
21
+
22
+ * feat: start adding truncated history for clarity evm (#7604)
23
+
24
+
25
+
6
26
  ## [8.71.3](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.71.2...@exodus/ethereum-api@8.71.3) (2026-04-24)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.71.3",
3
+ "version": "8.73.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",
@@ -23,13 +23,13 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@exodus/asset": "^2.0.4",
26
- "@exodus/asset-lib": "^5.8.0",
26
+ "@exodus/asset-lib": "^5.9.0",
27
27
  "@exodus/assets": "^11.4.0",
28
28
  "@exodus/basic-utils": "^3.0.1",
29
29
  "@exodus/bip44-constants": "^195.0.0",
30
30
  "@exodus/crypto": "^1.0.0-rc.26",
31
31
  "@exodus/currency": "^6.0.1",
32
- "@exodus/ethereum-lib": "^5.22.0",
32
+ "@exodus/ethereum-lib": "^5.24.0",
33
33
  "@exodus/ethereum-meta": "^2.9.1",
34
34
  "@exodus/ethereumholesky-meta": "^2.0.5",
35
35
  "@exodus/ethereumjs": "^1.11.0",
@@ -68,5 +68,5 @@
68
68
  "type": "git",
69
69
  "url": "git+https://github.com/ExodusMovement/assets.git"
70
70
  },
71
- "gitHead": "9f37c342087c5e2971af624b4067fd36b0d81ed5"
71
+ "gitHead": "d17ee07f3f8bf4cc67518f8a8395522578e8dae1"
72
72
  }
@@ -9,6 +9,7 @@ import { createEvmServer, createWsGateway, ValidMonitorTypes } from './exodus-et
9
9
  import { createEthereumHooks } from './hooks/index.js'
10
10
  import { ClarityMonitor } from './tx-log/clarity-monitor.js'
11
11
  import { ClarityMonitorV2 } from './tx-log/clarity-monitor-v2.js'
12
+ import { ClarityTruncatedHistoryMonitor } from './tx-log/clarity-truncated-history-monitor.js'
12
13
  import { EthereumMonitor } from './tx-log/ethereum-monitor.js'
13
14
  import { EthereumNoHistoryMonitor } from './tx-log/ethereum-no-history-monitor.js'
14
15
  import { BLOCK_TAG_LATEST, BLOCK_TAG_PENDING, resolveNonce } from './tx-send/nonce-utils.js'
@@ -169,6 +170,16 @@ export const createHistoryMonitorFactory = ({
169
170
  ...args,
170
171
  })
171
172
  break
173
+ case 'clarity-truncated-history':
174
+ monitor = new ClarityTruncatedHistoryMonitor({
175
+ assetClientInterface,
176
+ interval: ms(monitorInterval || '5m'),
177
+ server,
178
+ eip7702Supported,
179
+ getBlackListStatus,
180
+ ...args,
181
+ })
182
+ break
172
183
  case 'clarity-v3':
173
184
  monitor = new ClarityMonitorV2({
174
185
  assetClientInterface,
@@ -265,15 +276,22 @@ export const getNonceFactory = ({ assetClientInterface, useAbsoluteBalanceAndNon
265
276
  assert(typeof fromAddress === 'string', 'expected fromAddress')
266
277
  assert(walletAccount, 'expected walletAccount')
267
278
 
268
- const txLog = await assetClientInterface.getTxLog({
269
- assetName: asset.baseAsset.name,
270
- walletAccount,
271
- })
279
+ const [txLog, accountState] = await Promise.all([
280
+ assetClientInterface.getTxLog({
281
+ assetName: asset.baseAsset.name,
282
+ walletAccount,
283
+ }),
284
+ assetClientInterface.getAccountState({
285
+ assetName: asset.baseAsset.name,
286
+ walletAccount,
287
+ }),
288
+ ])
272
289
 
273
290
  return resolveNonce({
274
291
  asset,
275
292
  fromAddress,
276
293
  txLog,
294
+ accountState,
277
295
  forceFromNode,
278
296
  tag,
279
297
  useAbsoluteNonce: useAbsoluteBalanceAndNonce,
@@ -137,7 +137,11 @@ export const createAssetFactory = ({
137
137
  useAbsoluteBalanceAndNonce = overrideUseAbsoluteBalanceAndNonce
138
138
  }
139
139
 
140
- const server = createEvmServer({ assetName: asset.name, serverUrl, monitorType })
140
+ const server = createEvmServer({
141
+ assetName: asset.name,
142
+ serverUrl,
143
+ monitorType,
144
+ })
141
145
 
142
146
  const address = {
143
147
  validate: validateFactory({ chainId, useEip1191ChainIdChecksum }),
@@ -108,10 +108,18 @@ export default class ClarityServerV2 extends ClarityServer {
108
108
  this.updateBaseApiPath(uri) // pass in the uri from remote config to override assets-gateway
109
109
  }
110
110
 
111
- getTransactionsAtBlockNumber = async ({ address, blockNumber, withInput = true }) => {
111
+ getTransactionsFromBlockNumber = async ({ address, blockNumber, withInput, truncated }) => {
112
112
  const url = new URL(`${this.baseApiPath}/addresses/${encodeURIComponent(address)}/transactions`)
113
- url.searchParams.set('cursor', blockNumber)
114
- url.searchParams.set('withInput', Boolean(withInput).toString())
113
+ url.searchParams.set('cursor', blockNumber || '0')
114
+
115
+ if (withInput) {
116
+ url.searchParams.set('withInput', 'true')
117
+ }
118
+
119
+ if (truncated) {
120
+ url.searchParams.set('truncated', 'true')
121
+ }
122
+
115
123
  return fetchJsonRetry(url)
116
124
  }
117
125
 
@@ -154,19 +162,37 @@ export default class ClarityServerV2 extends ClarityServer {
154
162
  }
155
163
  }
156
164
 
157
- async getAllTransactions({ address, cursor }) {
165
+ /**
166
+ * Paginates GET .../addresses/:addr/transactions. Pass `truncated: true` to opt
167
+ * into the backend's truncation mode + `accountInfo` pre-cursor snapshots (used by
168
+ * `ClarityTruncatedHistoryMonitor`). For `clarity`/`clarity-v2`/`clarity-v3` flows,
169
+ * leave the default so Clarity returns full history.
170
+ */
171
+ async getAllTransactions({ address, cursor, withInput = true, truncated = false }) {
158
172
  let { blockNumber } = decodeCursor(cursor)
159
173
 
160
174
  let transactions = []
161
175
 
176
+ // accountInfo rows carry balance/nonce snapshots for blocks before the
177
+ // initial cursor — used to derive correct state with truncated history.
178
+ // Only collected (and returned) when the caller opted into `truncated`.
179
+ const accountInfo = truncated ? [] : null
180
+
162
181
  while (true) {
163
- const { transactions: txs, cursor: nextBlockNumberInteger } =
164
- await this.getTransactionsAtBlockNumber({
165
- address,
166
- blockNumber: blockNumber.toString(),
167
- })
182
+ const result = await this.getTransactionsFromBlockNumber({
183
+ address,
184
+ blockNumber: blockNumber.toString(),
185
+ withInput,
186
+ truncated,
187
+ })
188
+
189
+ const txs = result.transactions || []
190
+
191
+ if (accountInfo && Array.isArray(result.accountInfo)) {
192
+ accountInfo.push(...result.accountInfo)
193
+ }
168
194
 
169
- const nextBlockNumber = BigInt(nextBlockNumberInteger)
195
+ const nextBlockNumber = BigInt(result.cursor)
170
196
 
171
197
  if (txs.length === 0) {
172
198
  // fetch until no more new transactions
@@ -200,6 +226,7 @@ export default class ClarityServerV2 extends ClarityServer {
200
226
  return {
201
227
  transactions: { confirmed, pending },
202
228
  cursor: newCursor,
229
+ ...(accountInfo && { accountInfo }),
203
230
  }
204
231
  }
205
232
 
@@ -14,18 +14,28 @@ import ClarityServer from './clarity.js'
14
14
  import ClarityServerV2 from './clarity-v2.js'
15
15
  import WsGateway from './ws-gateway.js'
16
16
 
17
- export const ValidMonitorTypes = ['no-history', 'clarity', 'clarity-v2', 'clarity-v3', 'magnifier']
17
+ export const ValidMonitorTypes = [
18
+ 'no-history',
19
+ 'clarity',
20
+ 'clarity-v2',
21
+ 'clarity-truncated-history',
22
+ 'clarity-v3',
23
+ 'magnifier',
24
+ ]
18
25
 
19
26
  export function createEvmServer({ assetName, serverUrl, monitorType }) {
20
27
  assert(assetName, 'assetName is required')
21
28
  assert(serverUrl, 'serverUrl is required')
22
29
  assert(monitorType, 'monitorType is required')
30
+
31
+ // Truncated-history uses the same Clarity v2 HTTP/WS server; only the monitor class differs.
23
32
  switch (monitorType) {
24
33
  case 'no-history':
25
34
  return new ApiCoinNodesServer({ baseAssetName: assetName, uri: serverUrl })
26
35
  case 'clarity':
27
36
  return new ClarityServer({ baseAssetName: assetName, uri: serverUrl })
28
37
  case 'clarity-v2':
38
+ case 'clarity-truncated-history':
29
39
  case 'clarity-v3':
30
40
  return new ClarityServerV2({ baseAssetName: assetName, uri: serverUrl })
31
41
  case 'magnifier':
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  getBalanceFromAccountState,
3
+ getBalanceFromAccountStateIfAny,
3
4
  getBalanceFromTxLog,
4
5
  getUnconfirmedReceivedBalance,
5
6
  getUnconfirmedSentBalance,
@@ -34,12 +35,20 @@ import { getLatestCanonicalAbsoluteBalanceTx } from './tx-log/clarity-utils/inde
34
35
  *
35
36
  * See: docs/balances-model.md for the intended balance model spec.
36
37
  */
37
- export const getAbsoluteBalance = ({ asset, txLog }) => {
38
+ export const getAbsoluteBalance = ({ asset, txLog, accountState }) => {
38
39
  assert(asset, 'asset is required')
39
40
  assert(txLog, 'txLog is required')
40
41
 
42
+ const accountStateBalance = getBalanceFromAccountStateIfAny({ asset, accountState })
43
+
41
44
  if (txLog.size === 0) {
42
- return asset.currency.ZERO
45
+ return accountStateBalance || asset.currency.ZERO
46
+ }
47
+
48
+ // Prefer an explicit `accountState` snapshot (e.g. populated from Clarity `accountInfo`
49
+ // in truncated-history mode) over walking a potentially truncated `txLog`.
50
+ if (accountStateBalance) {
51
+ return accountStateBalance
43
52
  }
44
53
 
45
54
  // NOTE: We reverse the `txLog` to prioritize the handling
@@ -191,7 +200,8 @@ export const getBalancesFactory = ({ monitorType, useAbsoluteBalance, rpcBalance
191
200
  // pessimistic reading that's safe for consumers.
192
201
  spendable = getBalanceFromAccountState({ asset, accountState }).sub(unconfirmedSent)
193
202
  } else {
194
- const absoluteBalance = useAbsoluteBalance && getAbsoluteBalance({ asset, txLog })
203
+ const absoluteBalance =
204
+ useAbsoluteBalance && getAbsoluteBalance({ asset, txLog, accountState })
195
205
 
196
206
  if (absoluteBalance) {
197
207
  // NOTE: The returned `absoluteBalance` returns only confirmed
@@ -163,6 +163,23 @@ export class ClarityMonitorV2 extends BaseMonitor {
163
163
  return { txsToRemove }
164
164
  }
165
165
 
166
+ async persistSecurityState({ walletAccount, accountState, isBlacklisted, eip7702Delegation }) {
167
+ const securityStatePatch = {
168
+ ...(isBlacklisted !== undefined && { isBlacklisted }),
169
+ ...(eip7702Delegation !== undefined && { eip7702Delegation }),
170
+ }
171
+
172
+ if (isEmpty(securityStatePatch)) {
173
+ return
174
+ }
175
+
176
+ await this.updateAccountState({
177
+ walletAccount,
178
+ accountState,
179
+ newData: securityStatePatch,
180
+ })
181
+ }
182
+
166
183
  async tick({ walletAccount, refresh }) {
167
184
  await this.subscribeWalletAddresses(walletAccount)
168
185
 
@@ -173,17 +190,26 @@ export class ClarityMonitorV2 extends BaseMonitor {
173
190
  }
174
191
 
175
192
  const { derivedData, tokensByAddress, assets, tokens, assetName } = walletAccountInfo
176
- const { accountState, eip7702Delegation, isBlacklisted } = await this.getStateUpdate({
193
+ const { eip7702Delegation, isBlacklisted } = await this.getSecurityAccountState({
177
194
  derivedData,
178
- tokens,
179
195
  walletAccount,
180
196
  })
197
+
198
+ await this.persistSecurityState({
199
+ walletAccount,
200
+ accountState: derivedData.currentAccountState,
201
+ isBlacklisted,
202
+ eip7702Delegation,
203
+ })
204
+
205
+ const accountState = await this.getNewAccountState({
206
+ tokens,
207
+ currentTokenBalances: derivedData.currentAccountState?.tokenBalances,
208
+ ourWalletAddress: derivedData.ourWalletAddress,
209
+ })
210
+
181
211
  const batch = this.aci.createOperationsBatch()
182
- const newData = {
183
- ...accountState,
184
- ...(isBlacklisted !== undefined && { isBlacklisted }),
185
- ...(eip7702Delegation !== undefined && { eip7702Delegation }),
186
- }
212
+ const newData = { ...accountState }
187
213
  let allTxs = []
188
214
  let hasNewTxs = false
189
215
  let historyError
@@ -285,6 +311,24 @@ export class ClarityMonitorV2 extends BaseMonitor {
285
311
  }) {
286
312
  const hasNewTxs = allTxs.length > 0
287
313
 
314
+ const { eip7702Delegation, isBlacklisted } = await this.getSecurityAccountState({
315
+ derivedData,
316
+ walletAccount,
317
+ })
318
+
319
+ await this.persistSecurityState({
320
+ walletAccount,
321
+ accountState: derivedData.currentAccountState,
322
+ isBlacklisted,
323
+ eip7702Delegation,
324
+ })
325
+
326
+ const accountState = await this.getNewAccountState({
327
+ tokens,
328
+ currentTokenBalances: derivedData.currentAccountState?.tokenBalances,
329
+ ourWalletAddress: derivedData.ourWalletAddress,
330
+ })
331
+
288
332
  const logItemsByAsset = this.getAllLogItemsByAsset({
289
333
  getLogItemsFromServerTx,
290
334
  ourWalletAddress: derivedData.ourWalletAddress,
@@ -303,12 +347,6 @@ export class ClarityMonitorV2 extends BaseMonitor {
303
347
  ...derivedData,
304
348
  })
305
349
 
306
- const { accountState, eip7702Delegation, isBlacklisted } = await this.getStateUpdate({
307
- derivedData,
308
- tokens,
309
- walletAccount,
310
- })
311
-
312
350
  const batch = this.aci.createOperationsBatch()
313
351
 
314
352
  this.aci.removeTxLogBatch({
@@ -329,11 +367,7 @@ export class ClarityMonitorV2 extends BaseMonitor {
329
367
  }
330
368
 
331
369
  // All updates must go through newData (accountState param is only used for mem merging)
332
- const newData = {
333
- ...accountState,
334
- ...(isBlacklisted !== undefined && { isBlacklisted }),
335
- ...(eip7702Delegation !== undefined && { eip7702Delegation }),
336
- }
370
+ const newData = { ...accountState }
337
371
 
338
372
  if (cursor) {
339
373
  newData.clarityCursor = cursor
@@ -360,14 +394,8 @@ export class ClarityMonitorV2 extends BaseMonitor {
360
394
  }
361
395
  }
362
396
 
363
- async getStateUpdate({ derivedData, tokens, walletAccount }) {
397
+ async getSecurityAccountState({ derivedData, walletAccount }) {
364
398
  const shouldCheckBlacklist = this.tickCount[walletAccount] === 0
365
- const accountState = await this.getNewAccountState({
366
- tokens,
367
- currentTokenBalances: derivedData.currentAccountState?.tokenBalances,
368
- ourWalletAddress: derivedData.ourWalletAddress,
369
- })
370
-
371
399
  const eip7702Delegation = await getCurrentEIP7702Delegation({
372
400
  server: this.server,
373
401
  address: derivedData.ourWalletAddress,
@@ -384,7 +412,7 @@ export class ClarityMonitorV2 extends BaseMonitor {
384
412
  })
385
413
  : undefined
386
414
 
387
- return { accountState, eip7702Delegation, isBlacklisted }
415
+ return { eip7702Delegation, isBlacklisted }
388
416
  }
389
417
 
390
418
  async addSingleTx({ tx, address, cursor }) {
@@ -151,6 +151,23 @@ export class ClarityMonitor extends BaseMonitor {
151
151
  return { txsToRemove }
152
152
  }
153
153
 
154
+ async persistSecurityState({ walletAccount, accountState, isBlacklisted, eip7702Delegation }) {
155
+ const securityStatePatch = {
156
+ ...(isBlacklisted !== undefined && { isBlacklisted }),
157
+ ...(eip7702Delegation !== undefined && { eip7702Delegation }),
158
+ }
159
+
160
+ if (isEmpty(securityStatePatch)) {
161
+ return
162
+ }
163
+
164
+ await this.updateAccountState({
165
+ walletAccount,
166
+ accountState,
167
+ newData: securityStatePatch,
168
+ })
169
+ }
170
+
154
171
  async tick({ walletAccount, refresh }) {
155
172
  await this.subscribeWalletAddresses()
156
173
  const tickCount = this.tickCount[walletAccount]
@@ -165,18 +182,26 @@ export class ClarityMonitor extends BaseMonitor {
165
182
  }, new Map())
166
183
  const assetName = this.asset.name
167
184
  const derivedData = await this.deriveData({ assetName, walletAccount, tokens })
168
- const { accountState, eip7702Delegation, isBlacklisted } = await this.getStateUpdate({
185
+ const { eip7702Delegation, isBlacklisted } = await this.getSecurityAccountState({
169
186
  derivedData,
170
- tokens,
171
187
  shouldCheckBlacklist,
172
188
  })
173
189
 
190
+ await this.persistSecurityState({
191
+ walletAccount,
192
+ accountState: derivedData.currentAccountState,
193
+ isBlacklisted,
194
+ eip7702Delegation,
195
+ })
196
+
197
+ const accountState = await this.getNewAccountState({
198
+ tokens,
199
+ currentTokenBalances: derivedData.currentAccountState?.tokenBalances,
200
+ ourWalletAddress: derivedData.ourWalletAddress,
201
+ })
202
+
174
203
  const batch = this.aci.createOperationsBatch()
175
- const newData = {
176
- ...accountState,
177
- ...(isBlacklisted !== undefined && { isBlacklisted }),
178
- ...(eip7702Delegation !== undefined && { eip7702Delegation }),
179
- }
204
+ const newData = { ...accountState }
180
205
  let allTxs = []
181
206
  let hasNewTxs = false
182
207
  let historyError
@@ -265,13 +290,7 @@ export class ClarityMonitor extends BaseMonitor {
265
290
  }
266
291
  }
267
292
 
268
- async getStateUpdate({ derivedData, tokens, shouldCheckBlacklist }) {
269
- const accountState = await this.getNewAccountState({
270
- tokens,
271
- currentTokenBalances: derivedData.currentAccountState?.tokenBalances,
272
- ourWalletAddress: derivedData.ourWalletAddress,
273
- })
274
-
293
+ async getSecurityAccountState({ derivedData, shouldCheckBlacklist }) {
275
294
  const eip7702Delegation = await getCurrentEIP7702Delegation({
276
295
  server: this.server,
277
296
  address: derivedData.ourWalletAddress,
@@ -289,7 +308,7 @@ export class ClarityMonitor extends BaseMonitor {
289
308
  })
290
309
  : undefined
291
310
 
292
- return { accountState, eip7702Delegation, isBlacklisted }
311
+ return { eip7702Delegation, isBlacklisted }
293
312
  }
294
313
 
295
314
  async getNewAccountState({ tokens, currentTokenBalances, ourWalletAddress }) {
@@ -0,0 +1,207 @@
1
+ import { getAssetAddresses } from '@exodus/ethereum-lib'
2
+
3
+ import { ClarityMonitor } from './clarity-monitor.js'
4
+ import { getLogItemsFromServerTx, normalizeTransactionsResponse } from './clarity-utils/index.js'
5
+ import {
6
+ extractBalanceFromClarityAccountInfo,
7
+ getCurrentBlackListStatus,
8
+ getCurrentEIP7702Delegation,
9
+ } from './monitor-utils/index.js'
10
+
11
+ /**
12
+ * Clarity v2 + HTTP `getAllTransactions` with `truncated: true` so the backend can attach
13
+ * `accountInfo` (pre-cursor balance/nonce/token snapshots). Merges that data in `tick` before
14
+ * optional RPC follow-up; does not use `rpcBalanceAssetNames` (forced empty).
15
+ */
16
+ export class ClarityTruncatedHistoryMonitor extends ClarityMonitor {
17
+ constructor({ rpcBalanceAssetNames, ...args } = {}) {
18
+ super({ ...args, rpcBalanceAssetNames: [] })
19
+ }
20
+
21
+ getNewAccountState(_args) {
22
+ return Object.create(null)
23
+ }
24
+
25
+ async getStateUpdate({ derivedData, tokens, shouldCheckBlacklist, currentTokenBalances }) {
26
+ const accountState = await this.getNewAccountState({
27
+ tokens,
28
+ currentTokenBalances: currentTokenBalances || derivedData.currentAccountState?.tokenBalances,
29
+ ourWalletAddress: derivedData.ourWalletAddress,
30
+ })
31
+
32
+ const eip7702Delegation = await getCurrentEIP7702Delegation({
33
+ server: this.server,
34
+ address: derivedData.ourWalletAddress,
35
+ eip7702Supported: this.eip7702Supported,
36
+ currentDelegation: derivedData.currentAccountState?.eip7702Delegation,
37
+ logger: this.logger,
38
+ })
39
+
40
+ const isBlacklisted = shouldCheckBlacklist
41
+ ? await getCurrentBlackListStatus({
42
+ getBlackListStatus: this.getBlackListStatus,
43
+ address: derivedData.ourWalletAddress,
44
+ currentIsBlacklisted: derivedData.currentAccountState?.isBlacklisted,
45
+ logger: this.logger,
46
+ })
47
+ : undefined
48
+
49
+ return { accountState, eip7702Delegation, isBlacklisted }
50
+ }
51
+
52
+ /**
53
+ * Apply tx history and `accountInfo` first, then merge `getStateUpdate` (empty RPC snapshot)
54
+ * and persist — opposite order from {@link ClarityMonitor#tick} on master.
55
+ */
56
+ async tick({ walletAccount, refresh }) {
57
+ await this.subscribeWalletAddresses()
58
+ const tickCount = this.tickCount[walletAccount]
59
+ const shouldCheckBlacklist = tickCount === 0
60
+
61
+ const assets = await this.aci.getAssetsForNetwork({ baseAssetName: this.asset.name })
62
+ const tokens = Object.values(assets).filter((asset) => asset.baseAsset.name !== asset.name)
63
+ const tokensByAddress = tokens.reduce((map, token) => {
64
+ const addresses = getAssetAddresses(token)
65
+ for (const address of addresses) map.set(address.toLowerCase(), token)
66
+ return map
67
+ }, new Map())
68
+ const assetName = this.asset.name
69
+ const derivedData = await this.deriveData({ assetName, walletAccount, tokens })
70
+ const batch = this.aci.createOperationsBatch()
71
+ let allTxs = []
72
+ let hasNewTxs = false
73
+ let historyError
74
+ let balanceFromAccountInfo = Object.create(null)
75
+ let clarityCursor
76
+
77
+ try {
78
+ const response = await this.getHistoryFromServer({ walletAccount, derivedData, refresh })
79
+
80
+ ;({ allTxs } = await normalizeTransactionsResponse({
81
+ asset: this.asset,
82
+ fromAddress: derivedData.ourWalletAddress,
83
+ response,
84
+ walletAccount,
85
+ }))
86
+
87
+ hasNewTxs = allTxs.length > 0
88
+
89
+ const logItemsByAsset = this.getAllLogItemsByAsset({
90
+ getLogItemsFromServerTx,
91
+ ourWalletAddress: derivedData.ourWalletAddress,
92
+ allTransactionsFromServer: allTxs,
93
+ asset: this.asset,
94
+ tokensByAddress,
95
+ assets,
96
+ })
97
+
98
+ const { txsToRemove } = await this.checkPendingTransactions({
99
+ txlist: allTxs,
100
+ walletAccount,
101
+ refresh,
102
+ logItemsByAsset,
103
+ asset: this.asset,
104
+ ...derivedData,
105
+ })
106
+
107
+ this.aci.removeTxLogBatch({
108
+ assetName,
109
+ walletAccount,
110
+ txs: txsToRemove,
111
+ batch,
112
+ })
113
+
114
+ for (const [an, txs] of Object.entries(logItemsByAsset)) {
115
+ this.aci.updateTxLogAndNotifyBatch({
116
+ assetName: an,
117
+ walletAccount,
118
+ txs,
119
+ refresh,
120
+ batch,
121
+ })
122
+ }
123
+
124
+ balanceFromAccountInfo = extractBalanceFromClarityAccountInfo({
125
+ accountInfo: response.accountInfo,
126
+ asset: this.asset,
127
+ tokensByAddress,
128
+ })
129
+ clarityCursor = response.cursor
130
+ } catch (error) {
131
+ historyError = error
132
+ }
133
+
134
+ const currentTokenBalances = {
135
+ ...derivedData.currentAccountState?.tokenBalances,
136
+ ...balanceFromAccountInfo.tokenBalances,
137
+ }
138
+
139
+ const { accountState, eip7702Delegation, isBlacklisted } = await this.getStateUpdate({
140
+ derivedData,
141
+ tokens,
142
+ shouldCheckBlacklist,
143
+ currentTokenBalances,
144
+ })
145
+
146
+ // Pre-cursor snapshot from Clarity `accountInfo`, then RPC `accountState` (empty here) overlays.
147
+ const prevTokenBalances = derivedData.currentAccountState?.tokenBalances
148
+ const newData = {
149
+ ...balanceFromAccountInfo,
150
+ ...accountState,
151
+ ...(isBlacklisted !== undefined && { isBlacklisted }),
152
+ ...(eip7702Delegation !== undefined && { eip7702Delegation }),
153
+ ...(clarityCursor !== undefined && { clarityCursor }),
154
+ }
155
+
156
+ if (prevTokenBalances || balanceFromAccountInfo.tokenBalances || accountState.tokenBalances) {
157
+ newData.tokenBalances = {
158
+ ...prevTokenBalances,
159
+ ...balanceFromAccountInfo.tokenBalances,
160
+ ...accountState.tokenBalances,
161
+ }
162
+ }
163
+
164
+ try {
165
+ this.aci.updateAccountStateBatch({
166
+ assetName,
167
+ walletAccount,
168
+ accountState,
169
+ newData,
170
+ batch,
171
+ })
172
+
173
+ await this.aci.executeOperationsBatch(batch)
174
+ } catch (batchError) {
175
+ if (!historyError) throw batchError
176
+ this.logger.warn('error persisting account state after history failure', batchError)
177
+ }
178
+
179
+ if (historyError) {
180
+ throw historyError
181
+ }
182
+
183
+ if (refresh || hasNewTxs) {
184
+ const unknownTokenAddresses = this.getUnknownTokenAddresses({
185
+ transactions: allTxs,
186
+ tokensByAddress,
187
+ })
188
+ if (unknownTokenAddresses.length > 0) {
189
+ this.emit('unknown-tokens', unknownTokenAddresses)
190
+ }
191
+ }
192
+ }
193
+
194
+ async getHistoryFromServer({ walletAccount, derivedData, refresh }) {
195
+ const address = derivedData.ourWalletAddress
196
+ const currentCursor = derivedData.currentAccountState?.clarityCursor
197
+ const cursor = currentCursor && !refresh ? currentCursor : null
198
+ return this.server.getAllTransactions({
199
+ walletAccount,
200
+ address,
201
+ cursor,
202
+ refresh,
203
+ withInput: true,
204
+ truncated: true,
205
+ })
206
+ }
207
+ }
@@ -0,0 +1,208 @@
1
+ # `clarity-truncated-history` monitor
2
+
3
+ Extends `ClarityMonitor`. Used when Clarity only serves a **recent window** of txs and attaches pre-cursor snapshots in `accountInfo`.
4
+
5
+ - Sends `truncated=true` on `getAllTransactions`.
6
+ - Folds `accountInfo` into `accountState`.
7
+ - Forces `rpcBalanceAssetNames = []` (no per-asset RPC `eth_getBalance` batch).
8
+
9
+ Plugins opting in: `bsc-plugin`, `basemainnet-plugin`, `ethereum-plugin`.
10
+
11
+ ---
12
+
13
+ ## Flow
14
+
15
+ ```mermaid
16
+ flowchart TD
17
+ A[Clarity HTTP<br/>GET /addresses/:addr/transactions<br/>truncated=true & cursor] --> B[response]
18
+ B --> B1[transactions.confirmed + .pending]
19
+ B --> B2[accountInfo rows<br/>rebase/special tokens only]
20
+ B --> B3[cursor]
21
+
22
+ B1 --> C[normalizeTransactionsResponse<br/>filter spam + stale pending]
23
+ C --> D[getAllLogItemsByAsset<br/>bucket per assetName]
24
+ D --> E[aci.updateTxLogAndNotifyBatch<br/>per asset]
25
+
26
+ B2 --> F[extractBalanceFromClarityAccountInfo<br/>balance? nonce? tokenBalances?]
27
+
28
+ E --> G[accountState]
29
+ F --> G
30
+ B3 --> G[accountState<br/>balance, nonce, tokenBalances,<br/>clarityCursor]
31
+
32
+ G --> H1[getBalances - asset.api.getBalances]
33
+ G --> H2[resolveNonce - tx send path]
34
+
35
+ H1 --> I1{txLog empty?}
36
+ I1 -- yes --> I2[accountState.balance]
37
+ I1 -- no --> I3{accountState<br/>balance<br/>non-zero?}
38
+ I3 -- yes --> I2
39
+ I3 -- no --> I4[walk txLog for latest<br/>balanceChange]
40
+
41
+ H2 --> J1[max - nonceFromTxLog,<br/>accountState.nonce]
42
+ ```
43
+
44
+ Left-to-right: `accountInfo` only writes `accountState` for what Clarity can't derive from `walletChanges` (rebase tokens). Regular ERC-20s and ETH flow through the tx log. `getBalances` and `resolveNonce` then read `accountState` + `txLog` together.
45
+
46
+ ---
47
+
48
+ ## Example response (trimmed)
49
+
50
+ Wallet `0x3F80…9164` on Ethereum. Two txs + the one real `accountInfo` row:
51
+
52
+ ```json
53
+ {
54
+ "transactions": [
55
+ {
56
+ "blockNumber": 24190233,
57
+ "hash": "0x7544…35bb",
58
+ "from": "0xccef06b2…8136c",
59
+ "to": "0xfa7093cd…fa4b9",
60
+ "effects": [
61
+ {
62
+ "address": "0xa0b86991…0eb48",
63
+ "effect": "erc20",
64
+ "from": "0xfa7093cd…fa4b9",
65
+ "to": "0x3f80…9164",
66
+ "value": "9668069"
67
+ }
68
+ ],
69
+ "walletChanges": [
70
+ {
71
+ "wallet": "0x3f80…9164",
72
+ "type": "token",
73
+ "from": "0",
74
+ "to": "9668069",
75
+ "contract": "0xa0b86991…0eb48"
76
+ }
77
+ ]
78
+ },
79
+ {
80
+ "blockNumber": 24204497,
81
+ "hash": "0xb5fa…5b23",
82
+ "from": "0x3f80…9164",
83
+ "to": "0x2a8e…5e86",
84
+ "nonce": "0",
85
+ "effects": [
86
+ {
87
+ "address": "0x2a8e…5e86",
88
+ "effect": "erc20",
89
+ "from": "0x3f80…9164",
90
+ "to": "0xcbad…4853",
91
+ "value": "10000000000000000000"
92
+ }
93
+ ],
94
+ "walletChanges": [
95
+ {
96
+ "wallet": "0x3f80…9164",
97
+ "type": "balance",
98
+ "from": "254470008324222",
99
+ "to": "101067364839680"
100
+ },
101
+ { "wallet": "0x3f80…9164", "type": "nonce", "from": "0", "to": "1" }
102
+ ]
103
+ }
104
+ ],
105
+ "cursor": 24211168,
106
+ "accountInfo": [
107
+ {
108
+ "type": "token",
109
+ "balanceType": "account_balance",
110
+ "value": "20191469428776270391810",
111
+ "blockNumber": 24933420,
112
+ "assetId": "0x2a8e…5e86",
113
+ "assetName": "ousd_ethereum_48fcf72d",
114
+ "decimals": 18
115
+ }
116
+ ]
117
+ }
118
+ ```
119
+
120
+ - **Tx A** — incoming USDC (9.668069). Balance derivable from `walletChanges`.
121
+ - **Tx B** — first outgoing OUSD send, nonce `0 → 1`, ETH balance delta.
122
+ - **`accountInfo`** — OUSD rebase balance ≈ **20,191.469 OUSD**, authoritative.
123
+
124
+ ---
125
+
126
+ ## `accountState` after this tick
127
+
128
+ ```js
129
+ {
130
+ clarityCursor: <Buffer LE 24211168>,
131
+ tokenBalances: {
132
+ ousd_ethereum_48fcf72d: NumberUnit('20191.469… OUSD'), // from accountInfo
133
+ },
134
+ balance: ZERO, // unchanged by monitor; derived live by getBalances
135
+ nonce: 0, // unchanged by monitor; derived live by resolveNonce
136
+ }
137
+ ```
138
+
139
+ `accountInfo` **only** wrote `tokenBalances.ousd_...` here — no `type: 'balance'` or `type: 'nonce'` rows were sent. Everything else is derived at read time from the tx log.
140
+
141
+ ---
142
+
143
+ ## Read paths
144
+
145
+ ### `getBalances`
146
+
147
+ ```js
148
+ asset branch taken result
149
+ ethereum (ETH) walk txLog → latest 43_326_434_520_430 wei
150
+ balanceChange.to
151
+ usdc walk txLog → latest 95_948_474_200 base
152
+ walletChange.to
153
+ ousd_ethereum_48fcf72d accountState short-circuit 20_191_469_428_… base
154
+ (non-zero tokenBalances) (from accountInfo)
155
+ ```
156
+
157
+ Source: `getAbsoluteBalance` in `src/get-balances.js`, ordered branches:
158
+
159
+ 1. `txLog.size === 0` → `getBalanceFromAccountState`
160
+ 2. `accountState` non-zero → return it (`getBalanceFromAccountStateIfAny` skips default `ZERO` via `NumberUnit.isZero` getter)
161
+ 3. Walk reversed `txLog` for canonical `balanceChange` tx
162
+
163
+ ### `resolveNonce` (`src/tx-send/nonce-utils.js`)
164
+
165
+ ```js
166
+ nonce = Math.max(
167
+ nonceFromTxLog, // latest canonical `type: 'nonce'` walletChange
168
+ accountState.nonce || 0 // populated from accountInfo row if sent
169
+ )
170
+ ```
171
+
172
+ `accountInfo` `nonce` row patches the gap when truncation hides the latest `nonceChange`.
173
+
174
+ ---
175
+
176
+ ## Monitor + server wiring
177
+
178
+ ```js
179
+ // create-asset-utils.js
180
+ case 'clarity-truncated-history':
181
+ monitor = new ClarityTruncatedHistoryMonitor({ server, ...args }) // server is ClarityServerV2
182
+
183
+ // exodus-eth-server/index.js — no new server class
184
+ case 'clarity-v2':
185
+ case 'clarity-truncated-history': // normalized to clarity-v2
186
+ return new ClarityServerV2(...)
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Backend contract
192
+
193
+ 1. Accept `?truncated=true` on `GET /addresses/:addr/transactions`.
194
+ 2. Attach `accountInfo: []` (or omit) when nothing to snapshot.
195
+ 3. Emit `accountInfo` **only** for assets that can't be derived from `walletChanges` (rebase / `balanceType: 'account_balance'`).
196
+ 4. Row shapes: `{ type: 'balance' | 'nonce' | 'token', value, blockNumber, assetId?, assetName?, decimals? }`. Sorted client-side by `blockNumber` desc; first value per type/assetId wins.
197
+ 5. Unknown `assetId` → client drops the row.
198
+
199
+ If the backend can't emit `accountInfo`, the monitor degrades gracefully to `clarity-v2` behavior (tx-log walk only).
200
+
201
+ ---
202
+
203
+ ## Tests
204
+
205
+ - `src/tx-log/__tests__/clarity-truncated-history-monitor.test.js` — `truncated: true`, no RPC balance batch, accountInfo → `tokenBalances`.
206
+ - `src/__tests__/create-asset-utils.test.js` — factory → `ClarityTruncatedHistoryMonitor`.
207
+ - `src/__tests__/get-balances.test.js` — accountState short-circuit (truncated history cases).
208
+ - `src/__tests__/balances-model.test.js` — default-ZERO accountState still falls through (regression for `NumberUnit.isZero` getter).
@@ -1,4 +1,5 @@
1
1
  export { EthereumMonitor } from './ethereum-monitor.js'
2
2
  export { EthereumNoHistoryMonitor } from './ethereum-no-history-monitor.js'
3
3
  export { ClarityMonitor } from './clarity-monitor.js'
4
+ export { ClarityTruncatedHistoryMonitor } from './clarity-truncated-history-monitor.js'
4
5
  export { getOptimisticTxLogEffects } from './get-optimistic-txlog-effects.js'
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Maps Clarity `accountInfo` rows (GET .../transactions?truncated=true) into fields aligned
3
+ * with Ethereum-like account state: `balance`, `tokenBalances`, `nonce` (plus base units
4
+ * for amounts via `currency.baseUnit`).
5
+ * Rows are applied newest-first by `blockNumber` so duplicate types resolve like
6
+ * walletChanges-derived snapshots.
7
+ */
8
+ export const extractBalanceFromClarityAccountInfo = ({ accountInfo, asset, tokensByAddress }) => {
9
+ if (!accountInfo || accountInfo.length === 0) return {}
10
+
11
+ const sorted = [...accountInfo].sort((a, b) => (b.blockNumber || 0) - (a.blockNumber || 0))
12
+
13
+ const result = {}
14
+ let haveBalance = false
15
+ let haveNonce = false
16
+ const seenAssetIds = new Set()
17
+ const tokenBalances = Object.create(null)
18
+
19
+ for (const row of sorted) {
20
+ switch (row.type) {
21
+ case 'balance':
22
+ if (!haveBalance && row.value != null) {
23
+ result.balance = asset.currency.baseUnit(row.value)
24
+ haveBalance = true
25
+ }
26
+
27
+ break
28
+
29
+ case 'nonce': {
30
+ if (haveNonce) break
31
+ const nonce = Number(row.value)
32
+
33
+ if (Number.isFinite(nonce) && nonce >= 0) {
34
+ result.nonce = nonce
35
+ haveNonce = true
36
+ }
37
+
38
+ break
39
+ }
40
+
41
+ case 'token': {
42
+ const assetId = row.assetId?.toLowerCase()
43
+
44
+ if (!assetId || seenAssetIds.has(assetId) || row.value == null) break
45
+ const token = tokensByAddress.get(assetId)
46
+ if (!token) break
47
+ tokenBalances[token.name] = token.currency.baseUnit(row.value)
48
+ seenAssetIds.add(assetId)
49
+ break
50
+ }
51
+
52
+ default:
53
+ break
54
+ }
55
+ }
56
+
57
+ if (Object.keys(tokenBalances).length > 0) {
58
+ result.tokenBalances = tokenBalances
59
+ }
60
+
61
+ return result
62
+ }
@@ -8,4 +8,5 @@ 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 { extractBalanceFromClarityAccountInfo } from './extract-balance-from-clarity-account-info.js'
11
12
  export * from './exclude-unchanged-token-balances.js'
@@ -16,6 +16,7 @@ export const resolveNonce = async ({
16
16
  forceFromNode,
17
17
  fromAddress,
18
18
  txLog = [],
19
+ accountState,
19
20
  tag = BLOCK_TAG_LATEST,
20
21
  useAbsoluteNonce,
21
22
  }) => {
@@ -25,7 +26,13 @@ export const resolveNonce = async ({
25
26
  return getNonce({ asset: asset.baseAsset, address: fromAddress, tag })
26
27
  }
27
28
 
28
- return getNonceFromTxLog({ txLog, useAbsoluteNonce, tag })
29
+ const nonceFromTxLog = getNonceFromTxLog({ txLog, useAbsoluteNonce, tag })
30
+
31
+ // Fallback to accountState nonce when txLog has no nonce info (truncated history).
32
+ // accountState.nonce is populated from Clarity accountInfo during monitor ticks.
33
+ const nonceFromAccountState = accountState?.nonce || 0
34
+
35
+ return Math.max(nonceFromTxLog, nonceFromAccountState)
29
36
  }
30
37
 
31
38
  const getLatestTxWithNonceChange = ({ reversedTxLog }) => {