@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 +16 -0
- package/package.json +4 -4
- package/src/get-balances.js +81 -8
- package/src/tx-log/clarity-truncated-history-monitor.js +19 -1
- package/src/tx-log/clarity-truncated-history-monitor.md +32 -21
- package/src/tx-log/get-optimistic-txlog-effects.js +133 -0
- package/src/tx-log/monitor-utils/extract-balance-from-clarity-account-info.js +60 -14
- package/src/tx-send/nonce-utils.js +2 -2
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.
|
|
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.
|
|
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.
|
|
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": "
|
|
70
|
+
"gitHead": "8cf5e6a5fc7d235c2c57ea51219bb1e3469329c5"
|
|
71
71
|
}
|
package/src/get-balances.js
CHANGED
|
@@ -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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
//
|
|
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/>
|
|
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?
|
|
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,
|
|
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
|
|
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`
|
|
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
|
-
"
|
|
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'), //
|
|
132
|
+
ousd_ethereum_48fcf72d: NumberUnit('20191.469… OUSD'), // account_balance row
|
|
133
133
|
},
|
|
134
|
-
|
|
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
|
-
(
|
|
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. `
|
|
160
|
-
2. `
|
|
161
|
-
3.
|
|
163
|
+
1. Non-zero `accountState` balance → return 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 //
|
|
174
|
+
accountState.nonce || 0 // any `accountInfo` `type: 'nonce'` row, regardless of source
|
|
169
175
|
)
|
|
170
176
|
```
|
|
171
177
|
|
|
172
|
-
`
|
|
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 `
|
|
196
|
-
4.
|
|
197
|
-
5.
|
|
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
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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 (
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
66
|
+
if (!assetId || seenAssetIds.has(assetId) || !hasValue(row.value)) break
|
|
45
67
|
const token = tokensByAddress.get(assetId)
|
|
46
68
|
if (!token) break
|
|
47
|
-
|
|
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
|
-
//
|
|
32
|
-
//
|
|
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)
|