@exodus/ethereum-api 8.71.3 → 8.72.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 +10 -0
- package/package.json +4 -4
- package/src/create-asset-utils.js +22 -4
- package/src/create-asset.js +5 -1
- package/src/exodus-eth-server/clarity-v2.js +37 -10
- package/src/exodus-eth-server/index.js +11 -1
- package/src/get-balances.js +13 -3
- package/src/tx-log/clarity-truncated-history-monitor.js +207 -0
- package/src/tx-log/clarity-truncated-history-monitor.md +208 -0
- package/src/tx-log/index.js +1 -0
- package/src/tx-log/monitor-utils/extract-balance-from-clarity-account-info.js +62 -0
- package/src/tx-log/monitor-utils/index.js +1 -0
- package/src/tx-send/nonce-utils.js +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
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.72.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.71.3...@exodus/ethereum-api@8.72.0) (2026-04-30)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
* feat: start adding truncated history for clarity evm (#7604)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
6
16
|
## [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
17
|
|
|
8
18
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/ethereum-api",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.72.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.
|
|
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.
|
|
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": "
|
|
71
|
+
"gitHead": "87bf2c314792d465c10789267316e74d7a19301a"
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
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,
|
package/src/create-asset.js
CHANGED
|
@@ -137,7 +137,11 @@ export const createAssetFactory = ({
|
|
|
137
137
|
useAbsoluteBalanceAndNonce = overrideUseAbsoluteBalanceAndNonce
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
const server = createEvmServer({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
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 = [
|
|
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':
|
package/src/get-balances.js
CHANGED
|
@@ -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 =
|
|
203
|
+
const absoluteBalance =
|
|
204
|
+
useAbsoluteBalance && getAbsoluteBalance({ asset, txLog, accountState })
|
|
195
205
|
|
|
196
206
|
if (absoluteBalance) {
|
|
197
207
|
// NOTE: The returned `absoluteBalance` returns only confirmed
|
|
@@ -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).
|
package/src/tx-log/index.js
CHANGED
|
@@ -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
|
-
|
|
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 }) => {
|