@exodus/ethereum-api 8.74.1 → 8.75.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,22 @@
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.75.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.74.1...@exodus/ethereum-api@8.75.0) (2026-05-13)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat(ethereum): add no-history receive logs for portfolio transfers (#7967)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+
18
+ * fix: stale truncated balances after new transaction (#7897)
19
+
20
+
21
+
6
22
  ## [8.74.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.74.0...@exodus/ethereum-api@8.74.1) (2026-05-12)
7
23
 
8
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.74.1",
3
+ "version": "8.75.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.9.0",
26
+ "@exodus/asset-lib": "^5.9.1",
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.24.1",
32
+ "@exodus/ethereum-lib": "^5.24.2",
33
33
  "@exodus/ethereum-meta": "^2.9.1",
34
34
  "@exodus/ethereumholesky-meta": "^2.0.5",
35
35
  "@exodus/ethereumjs": "^1.11.0",
@@ -67,5 +67,5 @@
67
67
  "type": "git",
68
68
  "url": "git+https://github.com/ExodusMovement/assets.git"
69
69
  },
70
- "gitHead": "7f184588091acba3b358e4af13783ee1e2bab4ba"
70
+ "gitHead": "8cf5e6a5fc7d235c2c57ea51219bb1e3469329c5"
71
71
  }
@@ -9,6 +9,63 @@ import assert from 'minimalistic-assert'
9
9
 
10
10
  import { getLatestCanonicalAbsoluteBalanceTx } from './tx-log/clarity-utils/index.js'
11
11
 
12
+ /**
13
+ * Native balance helper for {@link getAbsoluteBalance}. Only consults
14
+ * `accountState` when the txLog is empty; otherwise the caller walks the
15
+ * txLog for a `balanceChange`. Inside the empty-txLog branch, picks a
16
+ * signal in decreasing order of trust:
17
+ *
18
+ * 1. Non-zero `accountState.balance` (authoritative — RPC `eth_getBalance`
19
+ * result or `accountInfo` `account_balance` row from truncated history).
20
+ * 2. `truncatedAccountState.balance` (state at the cursor), default ZERO.
21
+ *
22
+ * ZERO is treated as "no signal" because `EthereumLikeAccountState` defaults
23
+ * `balance` to ZERO; we cannot distinguish "model default" from "chain says
24
+ * you hold 0". The truncated baseline acts as the explicit fallback.
25
+ */
26
+ const getBaseBalanceFromAccountState = ({ asset, txLog, accountState }) => {
27
+ if (txLog.size === 0) {
28
+ const balance = getBalanceFromAccountStateIfAny({ asset, accountState })
29
+ if (balance && !balance.isZero) return balance
30
+
31
+ return getTruncatedBaseBalance({ accountState }) ?? asset.currency.ZERO
32
+ }
33
+
34
+ return null
35
+ }
36
+
37
+ /**
38
+ * Token counterpart of {@link getBaseBalance}. Tokens are sparse — there's no
39
+ * model default for `tokenBalances[name]`, so any value present (including an
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.
43
+ */
44
+ const getTokenBalanceFromAccountState = ({ asset, txLog, accountState }) => {
45
+ const balance = getBalanceFromAccountStateIfAny({ asset, accountState })
46
+ if (balance) return balance
47
+
48
+ if (txLog.size === 0) {
49
+ return getTruncatedTokenBalance({ asset, accountState }) ?? asset.currency.ZERO
50
+ }
51
+
52
+ return null
53
+ }
54
+
55
+ /**
56
+ * Cursor-baseline native balance from {@link accountState.truncatedAccountState},
57
+ * sourced from Clarity `truncated` `accountInfo` rows on initial `cursor=0`
58
+ * fetch. Returns `undefined` when the snapshot isn't populated (e.g. the
59
+ * server didn't ship a baseline for this account).
60
+ */
61
+ const getTruncatedBaseBalance = ({ accountState }) => accountState?.truncatedAccountState?.balance
62
+
63
+ /**
64
+ * Token counterpart of {@link getTruncatedBaseBalance}.
65
+ */
66
+ const getTruncatedTokenBalance = ({ asset, accountState }) =>
67
+ accountState?.truncatedAccountState?.tokenBalances?.[asset.name]
68
+
12
69
  /**
13
70
  * TODO: Balance model gaps for EVM staking assets
14
71
  *
@@ -39,15 +96,12 @@ export const getAbsoluteBalance = ({ asset, txLog, accountState }) => {
39
96
  assert(asset, 'asset is required')
40
97
  assert(txLog, 'txLog is required')
41
98
 
42
- const accountStateBalance = getBalanceFromAccountStateIfAny({ asset, accountState })
43
-
44
- if (txLog.size === 0) {
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`.
99
+ const isBase = asset.name === asset.baseAsset.name
100
+ const accountStateBalance = isBase
101
+ ? getBaseBalanceFromAccountState({ asset, txLog, accountState })
102
+ : getTokenBalanceFromAccountState({ asset, txLog, accountState })
50
103
  if (accountStateBalance) {
104
+ // Authoritative `accountState` snapshot (or empty-txLog terminal value).
51
105
  return accountStateBalance
52
106
  }
53
107
 
@@ -98,6 +152,25 @@ export const getAbsoluteBalance = ({ asset, txLog, accountState }) => {
98
152
  }
99
153
 
100
154
  if (!hasAbsoluteBalance) {
155
+ // Last line of defense: under truncated history the post-cursor txLog can
156
+ // omit the most recent `balanceChange` (e.g. only a pending send remains).
157
+ // Anchor on the `accountInfo` snapshot, mirroring the same precedence as
158
+ // {@link getBaseBalanceFromAccountState} / {@link getTokenBalanceFromAccountState}:
159
+ // prefer the current `accountState.balance` (`account_balance` row), and
160
+ // fall back to `truncatedAccountState.balance` (cursor baseline) when the
161
+ // former is the model default ZERO.
162
+ const accountBalance = isBase
163
+ ? accountState?.balance
164
+ : accountState?.tokenBalances?.[asset.name]
165
+ if (accountBalance && (!isBase || !accountBalance.isZero)) {
166
+ return balance.add(accountBalance)
167
+ }
168
+
169
+ const truncatedBalance = isBase
170
+ ? getTruncatedBaseBalance({ accountState })
171
+ : getTruncatedTokenBalance({ asset, accountState })
172
+ if (truncatedBalance) return balance.add(truncatedBalance)
173
+
101
174
  console.warn('No absolute balance found', { assetName: asset.name, txLogSize: txLog.size })
102
175
  return
103
176
  }
@@ -143,8 +143,9 @@ export class ClarityTruncatedHistoryMonitor extends ClarityMonitor {
143
143
  currentTokenBalances,
144
144
  })
145
145
 
146
- // Pre-cursor snapshot from Clarity `accountInfo`, then RPC `accountState` (empty here) overlays.
146
+ // Clarity `accountInfo`, then RPC `accountState` (empty here) overlays.
147
147
  const prevTokenBalances = derivedData.currentAccountState?.tokenBalances
148
+ const prevTruncatedAccountState = derivedData.currentAccountState?.truncatedAccountState
148
149
  const newData = {
149
150
  ...balanceFromAccountInfo,
150
151
  ...accountState,
@@ -161,6 +162,23 @@ export class ClarityTruncatedHistoryMonitor extends ClarityMonitor {
161
162
  }
162
163
  }
163
164
 
165
+ if (
166
+ prevTruncatedAccountState ||
167
+ balanceFromAccountInfo.truncatedAccountState ||
168
+ accountState.truncatedAccountState
169
+ ) {
170
+ newData.truncatedAccountState = {
171
+ ...prevTruncatedAccountState,
172
+ ...balanceFromAccountInfo.truncatedAccountState,
173
+ ...accountState.truncatedAccountState,
174
+ tokenBalances: {
175
+ ...prevTruncatedAccountState?.tokenBalances,
176
+ ...balanceFromAccountInfo.truncatedAccountState?.tokenBalances,
177
+ ...accountState.truncatedAccountState?.tokenBalances,
178
+ },
179
+ }
180
+ }
181
+
164
182
  try {
165
183
  this.aci.updateAccountStateBatch({
166
184
  assetName,
@@ -3,7 +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
- - Folds `accountInfo` into `accountState`.
6
+ - Folds `accountInfo` into `accountState`, including truncated-history baselines.
7
7
  - Forces `rpcBalanceAssetNames = []` (no per-asset RPC `eth_getBalance` batch).
8
8
 
9
9
  Plugins opting in: `bsc-plugin`, `basemainnet-plugin`, `ethereum-plugin`.
@@ -16,18 +16,18 @@ Plugins opting in: `bsc-plugin`, `basemainnet-plugin`, `ethereum-plugin`.
16
16
  flowchart TD
17
17
  A[Clarity HTTP<br/>GET /addresses/:addr/transactions<br/>truncated=true & cursor] --> B[response]
18
18
  B --> B1[transactions.confirmed + .pending]
19
- B --> B2[accountInfo rows<br/>rebase/special tokens only]
19
+ B --> B2[accountInfo rows<br/>account_balance or truncated baseline]
20
20
  B --> B3[cursor]
21
21
 
22
22
  B1 --> C[normalizeTransactionsResponse<br/>filter spam + stale pending]
23
23
  C --> D[getAllLogItemsByAsset<br/>bucket per assetName]
24
24
  D --> E[aci.updateTxLogAndNotifyBatch<br/>per asset]
25
25
 
26
- B2 --> F[extractBalanceFromClarityAccountInfo<br/>balance? nonce? tokenBalances?]
26
+ B2 --> F[extractBalanceFromClarityAccountInfo<br/>balance? tokenBalances?<br/>truncatedAccountState?]
27
27
 
28
28
  E --> G[accountState]
29
29
  F --> G
30
- B3 --> G[accountState<br/>balance, nonce, tokenBalances,<br/>clarityCursor]
30
+ B3 --> G[accountState<br/>balance, tokenBalances,<br/>truncatedAccountState,<br/>clarityCursor]
31
31
 
32
32
  G --> H1[getBalances - asset.api.getBalances]
33
33
  G --> H2[resolveNonce - tx send path]
@@ -35,13 +35,13 @@ flowchart TD
35
35
  H1 --> I1{txLog empty?}
36
36
  I1 -- yes --> I2[accountState.balance]
37
37
  I1 -- no --> I3{accountState<br/>balance<br/>non-zero?}
38
- I3 -- yes --> I2
39
- I3 -- no --> I4[walk txLog for latest<br/>balanceChange]
38
+ I3 -- yes + token account balance --> I2
39
+ I3 -- no / native --> I4[walk txLog<br/>use truncated baseline only when no balanceChange exists]
40
40
 
41
41
  H2 --> J1[max - nonceFromTxLog,<br/>accountState.nonce]
42
42
  ```
43
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.
44
+ Left-to-right: `accountInfo` can write native balance baselines, authoritative token balances, and truncated baselines to `accountState`. Native ETH still prioritizes tx history when history exists. Rows with `source: 'account_balance'` go into `balance`/`tokenBalances`; rows with `source: 'truncated'` go into `truncatedAccountState`.
45
45
 
46
46
  ---
47
47
 
@@ -106,7 +106,7 @@ Wallet `0x3F80…9164` on Ethereum. Two txs + the one real `accountInfo` row:
106
106
  "accountInfo": [
107
107
  {
108
108
  "type": "token",
109
- "balanceType": "account_balance",
109
+ "source": "account_balance",
110
110
  "value": "20191469428776270391810",
111
111
  "blockNumber": 24933420,
112
112
  "assetId": "0x2a8e…5e86",
@@ -129,9 +129,13 @@ Wallet `0x3F80…9164` on Ethereum. Two txs + the one real `accountInfo` row:
129
129
  {
130
130
  clarityCursor: <Buffer LE 24211168>,
131
131
  tokenBalances: {
132
- ousd_ethereum_48fcf72d: NumberUnit('20191.469… OUSD'), // from accountInfo
132
+ ousd_ethereum_48fcf72d: NumberUnit('20191.469… OUSD'), // account_balance row
133
133
  },
134
- balance: ZERO, // unchanged by monitor; derived live by getBalances
134
+ truncatedAccountState: {
135
+ balance: ZERO,
136
+ tokenBalances: {},
137
+ },
138
+ balance: ZERO, // no native balance row in this response
135
139
  nonce: 0, // unchanged by monitor; derived live by resolveNonce
136
140
  }
137
141
  ```
@@ -151,25 +155,30 @@ ethereum (ETH) walk txLog → latest 43_326_434_
151
155
  usdc walk txLog → latest 95_948_474_200 base
152
156
  walletChange.to
153
157
  ousd_ethereum_48fcf72d accountState short-circuit 20_191_469_428_… base
154
- (non-zero tokenBalances) (from accountInfo)
158
+ (account_balance row) (from accountInfo)
155
159
  ```
156
160
 
157
161
  Source: `getAbsoluteBalance` in `src/get-balances.js`, ordered branches:
158
162
 
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
163
+ 1. Non-zero `accountState` balancereturn it.
164
+ 2. `txLog.size === 0` → return `truncatedAccountState` balance or `ZERO`.
165
+ 3. Token value exists in `accountState.tokenBalances` return accountState balance, including explicit zero.
166
+ 4. Walk reversed `txLog` for canonical `balanceChange` tx.
167
+ 5. If no absolute balance exists, fall back to the txLog-derived balance path.
162
168
 
163
169
  ### `resolveNonce` (`src/tx-send/nonce-utils.js`)
164
170
 
165
171
  ```js
166
172
  nonce = Math.max(
167
173
  nonceFromTxLog, // latest canonical `type: 'nonce'` walletChange
168
- accountState.nonce || 0 // populated from accountInfo row if sent
174
+ accountState.nonce || 0 // any `accountInfo` `type: 'nonce'` row, regardless of source
169
175
  )
170
176
  ```
171
177
 
172
- `accountInfo` `nonce` row patches the gap when truncation hides the latest `nonceChange`.
178
+ Both `account_balance` and `truncated` source nonce rows collapse into `accountState.nonce`
179
+ (the max wins) — they represent the same chain-known nonce, just at different observation
180
+ points. `accountInfo` `nonce` row patches the gap when truncation hides the latest
181
+ `nonceChange`.
173
182
 
174
183
  ---
175
184
 
@@ -192,9 +201,11 @@ case 'clarity-truncated-history': // normalized to clarity-v2
192
201
 
193
202
  1. Accept `?truncated=true` on `GET /addresses/:addr/transactions`.
194
203
  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.
204
+ 3. Emit `source: 'account_balance'` rows for current account/token balances. These may appear on every fetch.
205
+ 4. Emit `source: 'truncated'` rows on the initial `cursor=0` fetch for state that existed before the returned history window.
206
+ 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
+ 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.
198
209
 
199
210
  If the backend can't emit `accountInfo`, the monitor degrades gracefully to `clarity-v2` behavior (tx-log walk only).
200
211
 
@@ -202,7 +213,7 @@ If the backend can't emit `accountInfo`, the monitor degrades gracefully to `cla
202
213
 
203
214
  ## Tests
204
215
 
205
- - `src/tx-log/__tests__/clarity-truncated-history-monitor.test.js` — `truncated: true`, no RPC balance batch, accountInfo → `tokenBalances`.
216
+ - `src/tx-log/__tests__/clarity-truncated-history-monitor.test.js` — `truncated: true`, no RPC balance batch, accountInfo → `balance`, token `tokenBalances`, and `truncatedAccountState`.
206
217
  - `src/__tests__/create-asset-utils.test.js` — factory → `ClarityTruncatedHistoryMonitor`.
207
- - `src/__tests__/get-balances.test.js` — accountState short-circuit (truncated history cases).
218
+ - `src/__tests__/get-balances.test.js` — accountState token short-circuit and truncated baseline cases.
208
219
  - `src/__tests__/balances-model.test.js` — default-ZERO accountState still falls through (regression for `NumberUnit.isZero` getter).
@@ -123,6 +123,34 @@ export const getOptimisticTxLogEffects = async ({
123
123
  },
124
124
  }
125
125
 
126
+ const receiveWalletAccount = await findOtherPortfolioReceiveWalletAccount({
127
+ amount,
128
+ asset,
129
+ assetClientInterface,
130
+ methodId,
131
+ selfSend,
132
+ to,
133
+ txToAddress,
134
+ walletAccount,
135
+ })
136
+
137
+ const receiveSideEffects = receiveWalletAccount
138
+ ? getOtherPortfolioReceiveEffects({
139
+ amount,
140
+ asset,
141
+ confirmations,
142
+ date,
143
+ fromAddress,
144
+ gasLimit,
145
+ methodId,
146
+ maybeTipGasPrice,
147
+ nonce,
148
+ receiveWalletAccount,
149
+ txId,
150
+ data,
151
+ })
152
+ : []
153
+
126
154
  const optimisticTxLogEffects = [
127
155
  {
128
156
  assetName: asset.name,
@@ -156,11 +184,116 @@ export const getOptimisticTxLogEffects = async ({
156
184
  ],
157
185
  },
158
186
  ...methodOptimisticSideEffects,
187
+ ...receiveSideEffects,
159
188
  ].filter(Boolean)
160
189
 
161
190
  return { optimisticTxLogEffects, nonce }
162
191
  }
163
192
 
193
+ const findOtherPortfolioReceiveWalletAccount = async ({
194
+ amount,
195
+ asset,
196
+ assetClientInterface,
197
+ methodId,
198
+ selfSend,
199
+ to,
200
+ txToAddress,
201
+ walletAccount,
202
+ }) => {
203
+ // Exclude cases where another-portfolio receive logs would be misleading:
204
+ // history-backed assets, self-sends, incomplete/zero-value transfers, and
205
+ // token approvals/swaps/router calls that are not plain ERC-20 transfers.
206
+ if (!asset.baseAsset.api.features.noHistory || selfSend || !to || amount.isZero) return
207
+
208
+ if (isEthereumLikeToken(asset)) {
209
+ const isPlainTransfer =
210
+ txToAddress?.toLowerCase() === asset.contract.address.toLowerCase() &&
211
+ methodId === asset.contract.transfer.methodId
212
+ if (!isPlainTransfer) return
213
+ }
214
+
215
+ try {
216
+ return await getWalletAccountForReceiveAddress({
217
+ assetClientInterface,
218
+ baseAsset: asset.baseAsset,
219
+ sendingWalletAccount: walletAccount,
220
+ to,
221
+ })
222
+ } catch {}
223
+ }
224
+
225
+ const getOtherPortfolioReceiveEffects = ({
226
+ amount,
227
+ asset,
228
+ confirmations,
229
+ date,
230
+ fromAddress,
231
+ gasLimit,
232
+ methodId,
233
+ maybeTipGasPrice,
234
+ nonce,
235
+ receiveWalletAccount,
236
+ txId,
237
+ data,
238
+ }) => {
239
+ const receiveProps = {
240
+ confirmations,
241
+ date,
242
+ selfSend: false,
243
+ from: [fromAddress],
244
+ txId,
245
+ data: {
246
+ gasLimit,
247
+ nonce,
248
+ ...(maybeTipGasPrice ? { tipGasPrice: maybeTipGasPrice.toBaseString() } : null),
249
+ ...(methodId ? { methodId } : null),
250
+ ...(data ? { data: bufferToHex(data) } : null),
251
+ },
252
+ }
253
+
254
+ return [
255
+ {
256
+ assetName: asset.name,
257
+ walletAccount: receiveWalletAccount,
258
+ txs: [
259
+ {
260
+ ...receiveProps,
261
+ coinAmount: amount.abs(),
262
+ coinName: asset.name,
263
+ currencies: {
264
+ [asset.name]: asset.currency,
265
+ [asset.feeAsset.name]: asset.feeAsset.currency,
266
+ },
267
+ },
268
+ ],
269
+ },
270
+ ]
271
+ }
272
+
273
+ const getWalletAccountForReceiveAddress = async ({
274
+ assetClientInterface,
275
+ baseAsset,
276
+ sendingWalletAccount,
277
+ to,
278
+ }) => {
279
+ const normalizedTo = to.toLowerCase()
280
+ const walletAccounts = await assetClientInterface.getWalletAccounts({ assetName: baseAsset.name })
281
+
282
+ for (const walletAccount of walletAccounts) {
283
+ if (walletAccount === sendingWalletAccount) continue
284
+
285
+ const receiveAddresses = await assetClientInterface.getReceiveAddresses({
286
+ assetName: baseAsset.name,
287
+ walletAccount,
288
+ useCache: true,
289
+ })
290
+
291
+ if (receiveAddresses.some((address) => String(address).toLowerCase() === normalizedTo)) {
292
+ return walletAccount
293
+ }
294
+ }
295
+ }
296
+
164
297
  const operationTxLogSideEffects = async ({
165
298
  txToAddress,
166
299
  methodId,
@@ -2,38 +2,60 @@
2
2
  * Maps Clarity `accountInfo` rows (GET .../transactions?truncated=true) into fields aligned
3
3
  * with Ethereum-like account state: `balance`, `tokenBalances`, `nonce` (plus base units
4
4
  * for amounts via `currency.baseUnit`).
5
- * Rows are applied newest-first by `blockNumber` so duplicate types resolve like
6
- * walletChanges-derived snapshots.
5
+ * `account_balance` source rows are stored in `balance`/`tokenBalances`; truncated source rows
6
+ * are stored in `truncatedAccountState`.
7
+ *
8
+ * NOTE: All `row.value` payloads arrive over the wire as decimal strings (e.g. `"0"`,
9
+ * `"20191469428776270391810"`). They're parsed with `currency.baseUnit(...)` for amounts
10
+ * and `Number(...)` for nonce. `null`, `undefined`, and empty-string `""` all mean "row
11
+ * carries no value" and the row is skipped — `currency.baseUnit("")` throws and `Number("")`
12
+ * returns 0, both of which would silently corrupt accountState.
7
13
  */
14
+ const ACCOUNT_BALANCE = 'account_balance'
15
+ const TRUNCATED = 'truncated'
16
+
17
+ const hasValue = (value) => value !== null && value !== undefined && value !== ''
18
+
19
+ const warnUnknownSource = (row) => {
20
+ console.warn('Unknown accountInfo source', row)
21
+ }
22
+
8
23
  export const extractBalanceFromClarityAccountInfo = ({ accountInfo, asset, tokensByAddress }) => {
9
24
  if (!accountInfo || accountInfo.length === 0) return {}
10
25
 
11
26
  const sorted = [...accountInfo].sort((a, b) => (b.blockNumber || 0) - (a.blockNumber || 0))
12
27
 
13
28
  const result = {}
14
- let haveBalance = false
15
- let haveNonce = false
16
29
  const seenAssetIds = new Set()
17
30
  const tokenBalances = Object.create(null)
31
+ const truncatedTokenBalances = Object.create(null)
32
+ let truncatedBalance
18
33
 
19
34
  for (const row of sorted) {
20
35
  switch (row.type) {
21
36
  case 'balance':
22
- if (!haveBalance && row.value != null) {
23
- result.balance = asset.currency.baseUnit(row.value)
24
- haveBalance = true
37
+ if (hasValue(row.value)) {
38
+ const balance = asset.currency.baseUnit(row.value)
39
+ if (row.source === ACCOUNT_BALANCE) {
40
+ result.balance = balance
41
+ } else if (row.source === TRUNCATED) {
42
+ truncatedBalance = balance
43
+ } else {
44
+ warnUnknownSource(row)
45
+ }
25
46
  }
26
47
 
27
48
  break
28
49
 
29
50
  case 'nonce': {
30
- if (haveNonce) break
51
+ // Nonce collapses regardless of source — both `account_balance` and
52
+ // `truncated` rows represent the latest chain-known nonce; max wins
53
+ // for safety against out-of-order rows.
54
+ if (!hasValue(row.value)) break
31
55
  const nonce = Number(row.value)
56
+ if (!Number.isFinite(nonce) || nonce < 0) break
32
57
 
33
- if (Number.isFinite(nonce) && nonce >= 0) {
34
- result.nonce = nonce
35
- haveNonce = true
36
- }
58
+ result.nonce = Math.max(result.nonce ?? 0, nonce)
37
59
 
38
60
  break
39
61
  }
@@ -41,10 +63,19 @@ export const extractBalanceFromClarityAccountInfo = ({ accountInfo, asset, token
41
63
  case 'token': {
42
64
  const assetId = row.assetId?.toLowerCase()
43
65
 
44
- if (!assetId || seenAssetIds.has(assetId) || row.value == null) break
66
+ if (!assetId || seenAssetIds.has(assetId) || !hasValue(row.value)) break
45
67
  const token = tokensByAddress.get(assetId)
46
68
  if (!token) break
47
- tokenBalances[token.name] = token.currency.baseUnit(row.value)
69
+
70
+ const balance = token.currency.baseUnit(row.value)
71
+ if (row.source === ACCOUNT_BALANCE) {
72
+ tokenBalances[token.name] = balance
73
+ } else if (row.source === TRUNCATED) {
74
+ truncatedTokenBalances[token.name] = balance
75
+ } else {
76
+ warnUnknownSource(row)
77
+ }
78
+
48
79
  seenAssetIds.add(assetId)
49
80
  break
50
81
  }
@@ -58,5 +89,20 @@ export const extractBalanceFromClarityAccountInfo = ({ accountInfo, asset, token
58
89
  result.tokenBalances = tokenBalances
59
90
  }
60
91
 
92
+ const hasTruncatedTokenBalances = Object.keys(truncatedTokenBalances).length > 0
93
+ if (truncatedBalance || hasTruncatedTokenBalances) {
94
+ const truncatedAccountState = Object.create(null)
95
+
96
+ if (truncatedBalance) {
97
+ truncatedAccountState.balance = truncatedBalance
98
+ }
99
+
100
+ if (hasTruncatedTokenBalances) {
101
+ truncatedAccountState.tokenBalances = truncatedTokenBalances
102
+ }
103
+
104
+ result.truncatedAccountState = truncatedAccountState
105
+ }
106
+
61
107
  return result
62
108
  }
@@ -28,8 +28,8 @@ export const resolveNonce = async ({
28
28
 
29
29
  const nonceFromTxLog = getNonceFromTxLog({ txLog, useAbsoluteNonce, tag })
30
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.
31
+ // `accountState.nonce` is the single source of truth — clarity-truncated-history
32
+ // collapses both `account_balance` and `truncated` source nonces into this field.
33
33
  const nonceFromAccountState = accountState?.nonce || 0
34
34
 
35
35
  return Math.max(nonceFromTxLog, nonceFromAccountState)