@exodus/ethereum-api 9.0.1 → 9.0.3

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
+ ## [9.0.3](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@9.0.2...@exodus/ethereum-api@9.0.3) (2026-10-05)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **ethereum-api:** prefer newer tx-log drain over stale token balance ([#8793](https://github.com/ExodusMovement/assets/issues/8793)) ([f6cc0e4](https://github.com/ExodusMovement/assets/commit/f6cc0e4031f89c71951e206c9c09fe042aa9354c))
12
+
13
+
14
+
15
+ ## [9.0.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@9.0.1...@exodus/ethereum-api@9.0.2) (2026-10-02)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **ethereum-api:** keep balances on truncated refresh when history fails ([#8739](https://github.com/ExodusMovement/assets/issues/8739)) ([2c8bdb5](https://github.com/ExodusMovement/assets/commit/2c8bdb590ae0f91a93eae377c965dda9bda90a74))
21
+
22
+
23
+
6
24
  ## [9.0.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@9.0.0...@exodus/ethereum-api@9.0.1) (2026-10-02)
7
25
 
8
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "9.0.1",
3
+ "version": "9.0.3",
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",
@@ -66,5 +66,5 @@
66
66
  "type": "git",
67
67
  "url": "git+https://github.com/ExodusMovement/assets.git"
68
68
  },
69
- "gitHead": "28868209b3e87ea927600231556d6728fc319fcd"
69
+ "gitHead": "e783e7a31c40807698ae2b9629e1c2dd5c03c228"
70
70
  }
@@ -38,12 +38,17 @@ const getBaseBalanceFromAccountState = ({ asset, txLog, accountState }) => {
38
38
  * Token counterpart of {@link getBaseBalance}. Tokens are sparse — there's no
39
39
  * model default for `tokenBalances[name]`, so any value present (including an
40
40
  * explicit ZERO from `accountInfo` after the user drained the token) is
41
- * authoritative and returned as-is. Only when the key is missing entirely do
42
- * we fall through to the truncated baseline / txLog walk.
41
+ * authoritative and returned as-is, unless the txLog holds a canonical
42
+ * `balanceChange` from a newer block than the `account_balance` row (or the
43
+ * row's block is unknown). Only when the key is missing entirely do we fall
44
+ * through to the truncated baseline / txLog walk.
43
45
  */
44
- const getTokenBalanceFromAccountState = ({ asset, txLog, accountState }) => {
46
+ const getTokenBalanceFromAccountState = ({ asset, txLog, accountState, latestBalanceTx }) => {
45
47
  const balance = getBalanceFromAccountStateIfAny({ asset, accountState })
46
- if (balance) return balance
48
+ const balanceBlockNumber = accountState?.tokenBalanceBlockNumbers?.[asset.name]
49
+ const isOlderThanTxLog =
50
+ latestBalanceTx && !(balanceBlockNumber >= latestBalanceTx.data.blockNumber)
51
+ if (balance && !isOlderThanTxLog) return balance
47
52
 
48
53
  if (txLog.size === 0) {
49
54
  return getTruncatedTokenBalance({ asset, accountState }) ?? asset.currency.ZERO
@@ -96,15 +101,6 @@ export const getAbsoluteBalance = ({ asset, txLog, accountState }) => {
96
101
  assert(asset, 'asset is required')
97
102
  assert(txLog, 'txLog is required')
98
103
 
99
- const isBase = asset.name === asset.baseAsset.name
100
- const accountStateBalance = isBase
101
- ? getBaseBalanceFromAccountState({ asset, txLog, accountState })
102
- : getTokenBalanceFromAccountState({ asset, txLog, accountState })
103
- if (accountStateBalance) {
104
- // Authoritative `accountState` snapshot (or empty-txLog terminal value).
105
- return accountStateBalance
106
- }
107
-
108
104
  // NOTE: We reverse the `txLog` to prioritize the handling
109
105
  // of the most recent transactions first. The reason
110
106
  // we do this is to terminate early if we find an
@@ -113,6 +109,20 @@ export const getAbsoluteBalance = ({ asset, txLog, accountState }) => {
113
109
 
114
110
  const maybeLatestAbsoluteBalanceTx = getLatestCanonicalAbsoluteBalanceTx({ reversedTxLog })
115
111
 
112
+ const isBase = asset.name === asset.baseAsset.name
113
+ const accountStateBalance = isBase
114
+ ? getBaseBalanceFromAccountState({ asset, txLog, accountState })
115
+ : getTokenBalanceFromAccountState({
116
+ asset,
117
+ txLog,
118
+ accountState,
119
+ latestBalanceTx: maybeLatestAbsoluteBalanceTx,
120
+ })
121
+ if (accountStateBalance) {
122
+ // Authoritative `accountState` snapshot (or empty-txLog terminal value).
123
+ return accountStateBalance
124
+ }
125
+
116
126
  let balance = asset.currency.ZERO
117
127
  let hasAbsoluteBalance = false
118
128
 
@@ -1,14 +1,23 @@
1
1
  import { ClarityMonitor } from './clarity-monitor.js'
2
+ import { isCanonicalAbsoluteBalanceTx } from './clarity-utils/index.js'
2
3
  import { extractBalanceFromClarityAccountInfo } from './monitor-utils/index.js'
3
4
 
4
- const omitTokensWithConfirmedBalanceChange = ({ tokenBalances, logItemsByAsset }) => {
5
- if (!tokenBalances || !logItemsByAsset) return tokenBalances
6
- const result = { ...tokenBalances }
7
- for (const [assetName, txs] of Object.entries(logItemsByAsset)) {
8
- if (txs.some((tx) => tx.confirmations > 0 && tx.data?.balanceChange)) delete result[assetName]
5
+ const omitTokensWithNewerConfirmedBalanceChange = ({
6
+ tokenBalances,
7
+ tokenBalanceBlockNumbers,
8
+ logItemsByAsset,
9
+ }) => {
10
+ const balances = { ...tokenBalances }
11
+ const blockNumbers = { ...tokenBalanceBlockNumbers }
12
+ for (const [assetName, txs] of Object.entries(logItemsByAsset ?? {})) {
13
+ const isNewer = (tx) => !(blockNumbers[assetName] >= tx.data.blockNumber)
14
+ if (txs.some((tx) => tx.confirmations > 0 && isCanonicalAbsoluteBalanceTx(tx) && isNewer(tx))) {
15
+ delete balances[assetName]
16
+ delete blockNumbers[assetName]
17
+ }
9
18
  }
10
19
 
11
- return result
20
+ return { tokenBalances: tokenBalances && balances, tokenBalanceBlockNumbers: blockNumbers }
12
21
  }
13
22
 
14
23
  /**
@@ -49,11 +58,12 @@ export class ClarityTruncatedHistoryMonitor extends ClarityMonitor {
49
58
  // truncated), we must ensure older checkpoints cannot
50
59
  // be inadvertently relied upon.
51
60
  //
52
- if (refresh) {
61
+ if (refresh && historyResponse) {
53
62
  const defaultAccountState = this.asset.api.createAccountState().create()
54
63
  resetBalances = {
55
64
  balance: defaultAccountState.balance,
56
65
  tokenBalances: defaultAccountState.tokenBalances,
66
+ tokenBalanceBlockNumbers: defaultAccountState.tokenBalanceBlockNumbers,
57
67
  }
58
68
  currentAccountState = (currentAccountState ?? defaultAccountState).merge(resetBalances)
59
69
  }
@@ -64,10 +74,12 @@ export class ClarityTruncatedHistoryMonitor extends ClarityMonitor {
64
74
  tokensByAddress,
65
75
  })
66
76
 
67
- const prevTokenBalances = omitTokensWithConfirmedBalanceChange({
68
- tokenBalances: currentAccountState?.tokenBalances,
69
- logItemsByAsset,
70
- })
77
+ const { tokenBalances: prevTokenBalances, tokenBalanceBlockNumbers: prevBlockNumbers } =
78
+ omitTokensWithNewerConfirmedBalanceChange({
79
+ tokenBalances: currentAccountState?.tokenBalances,
80
+ tokenBalanceBlockNumbers: currentAccountState?.tokenBalanceBlockNumbers,
81
+ logItemsByAsset,
82
+ })
71
83
 
72
84
  const { accountState } = await this.getStateUpdate({
73
85
  derivedData,
@@ -92,6 +104,15 @@ export class ClarityTruncatedHistoryMonitor extends ClarityMonitor {
92
104
  ...balanceFromAccountInfo.tokenBalances,
93
105
  ...accountState.tokenBalances,
94
106
  }
107
+ const tokenBalanceBlockNumbers = { ...prevBlockNumbers }
108
+ for (const tokenName of Object.keys(balanceFromAccountInfo.tokenBalances ?? {})) {
109
+ delete tokenBalanceBlockNumbers[tokenName]
110
+ }
111
+
112
+ newData.tokenBalanceBlockNumbers = {
113
+ ...tokenBalanceBlockNumbers,
114
+ ...balanceFromAccountInfo.tokenBalanceBlockNumbers,
115
+ }
95
116
  }
96
117
 
97
118
  if (
@@ -131,6 +131,9 @@ Wallet `0x3F80…9164` on Ethereum. Two txs + the one real `accountInfo` row:
131
131
  tokenBalances: {
132
132
  ousd_ethereum_48fcf72d: NumberUnit('20191.469… OUSD'), // account_balance row
133
133
  },
134
+ tokenBalanceBlockNumbers: {
135
+ ousd_ethereum_48fcf72d: 24933420, // account_balance row blockNumber
136
+ },
134
137
  truncatedAccountState: {
135
138
  balance: ZERO,
136
139
  tokenBalances: {},
@@ -162,7 +165,7 @@ Source: `getAbsoluteBalance` in `src/get-balances.js`, ordered branches:
162
165
 
163
166
  1. Non-zero `accountState` balance → return it.
164
167
  2. `txLog.size === 0` → return `truncatedAccountState` balance or `ZERO`.
165
- 3. Token value exists in `accountState.tokenBalances` → return accountState balance, including explicit zero.
168
+ 3. Token value exists in `accountState.tokenBalances` → return accountState balance, including explicit zero, unless the txLog's latest canonical `balanceChange` is in a newer block than `tokenBalanceBlockNumbers[token]` (or that block is unknown).
166
169
  4. Walk reversed `txLog` for canonical `balanceChange` tx.
167
170
  5. If no absolute balance exists, fall back to the txLog-derived balance path.
168
171
 
@@ -205,7 +208,8 @@ case 'clarity-truncated-history': // normalized to clarity-v2
205
208
  4. Emit `source: 'truncated'` rows on the initial `cursor=0` fetch for state that existed before the returned history window.
206
209
  5. Row shapes: `{ type: 'balance' | 'nonce' | 'token', source: 'account_balance' | 'truncated', value, blockNumber, assetId?, assetName?, decimals? }`. Sorted client-side by `blockNumber` desc; first value per type/assetId wins for `balance`/`token`. `nonce` rows are collapsed via `Math.max` regardless of source.
207
210
  6. Unknown `assetId` → client drops the row.
208
- 7. `value` arrives as a decimal string. `null`, `undefined`, and empty-string `""` mean "no value" and the row is skipped.
211
+ 7. `blockNumber` on token `account_balance` rows is the block the balance was read at. The client stores it and prefers the txLog when its latest `balanceChange` is newer. So a drained token with no new row falls back to the txLog value.
212
+ 8. `value` arrives as a decimal string. `null`, `undefined`, and empty-string `""` mean "no value" and the row is skipped.
209
213
 
210
214
  If the backend can't emit `accountInfo`, the monitor degrades gracefully to `clarity-v2` behavior (tx-log walk only).
211
215
 
@@ -3,7 +3,8 @@
3
3
  * with Ethereum-like account state: `balance`, `tokenBalances`, `nonce` (plus base units
4
4
  * for amounts via `currency.baseUnit`).
5
5
  * `account_balance` source rows are stored in `balance`/`tokenBalances`; truncated source rows
6
- * are stored in `truncatedAccountState`.
6
+ * are stored in `truncatedAccountState`. Token `account_balance` rows also record their
7
+ * `blockNumber` in `tokenBalanceBlockNumbers` so reads can tell whether the tx-log is newer.
7
8
  *
8
9
  * NOTE: All `row.value` payloads arrive over the wire as decimal strings (e.g. `"0"`,
9
10
  * `"20191469428776270391810"`). They're parsed with `currency.baseUnit(...)` for amounts
@@ -28,6 +29,7 @@ export const extractBalanceFromClarityAccountInfo = ({ accountInfo, asset, token
28
29
  const result = {}
29
30
  const seenAssetIds = new Set()
30
31
  const tokenBalances = Object.create(null)
32
+ const tokenBalanceBlockNumbers = Object.create(null)
31
33
  const truncatedTokenBalances = Object.create(null)
32
34
  let truncatedBalance
33
35
 
@@ -70,6 +72,9 @@ export const extractBalanceFromClarityAccountInfo = ({ accountInfo, asset, token
70
72
  const balance = token.currency.baseUnit(row.value)
71
73
  if (row.source === ACCOUNT_BALANCE) {
72
74
  tokenBalances[token.name] = balance
75
+ if (Number.isInteger(row.blockNumber)) {
76
+ tokenBalanceBlockNumbers[token.name] = row.blockNumber
77
+ }
73
78
  } else if (row.source === TRUNCATED) {
74
79
  truncatedTokenBalances[token.name] = balance
75
80
  } else {
@@ -87,6 +92,7 @@ export const extractBalanceFromClarityAccountInfo = ({ accountInfo, asset, token
87
92
 
88
93
  if (Object.keys(tokenBalances).length > 0) {
89
94
  result.tokenBalances = tokenBalances
95
+ result.tokenBalanceBlockNumbers = tokenBalanceBlockNumbers
90
96
  }
91
97
 
92
98
  const hasTruncatedTokenBalances = Object.keys(truncatedTokenBalances).length > 0