@exodus/ethereum-api 8.77.2 → 8.77.4

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,25 @@
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.77.4](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.77.1...@exodus/ethereum-api@8.77.4) (2026-07-14)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **ethereum-api:** detect legacy delegate transactions ([#8357](https://github.com/ExodusMovement/assets/issues/8357)) ([070e529](https://github.com/ExodusMovement/assets/commit/070e5295f21f12695e97e721ff8c0601c553e447))
12
+ * **simulation:** route recipient changes to dedicated field, guard asset-less entries ([#8370](https://github.com/ExodusMovement/assets/issues/8370)) ([c0a082b](https://github.com/ExodusMovement/assets/commit/c0a082b6c80710408337ff9285a19fb657c656be))
13
+
14
+
15
+
16
+ ## [8.77.3](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.77.1...@exodus/ethereum-api@8.77.3) (2026-07-11)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **ethereum-api:** detect legacy delegate transactions ([#8357](https://github.com/ExodusMovement/assets/issues/8357)) ([070e529](https://github.com/ExodusMovement/assets/commit/070e5295f21f12695e97e721ff8c0601c553e447))
22
+
23
+
24
+
6
25
  ## [8.77.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.77.1...@exodus/ethereum-api@8.77.2) (2026-07-10)
7
26
 
8
27
  **Note:** Version bump only for package @exodus/ethereum-api
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.77.2",
3
+ "version": "8.77.4",
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",
@@ -69,5 +69,5 @@
69
69
  "type": "git",
70
70
  "url": "git+https://github.com/ExodusMovement/assets.git"
71
71
  },
72
- "gitHead": "c9b1bbede2e2e7b0f2359ec736e3e92f402b2eba"
72
+ "gitHead": "480ce44451fc531981074b3062ccc7df72735aa9"
73
73
  }
@@ -162,6 +162,15 @@ export default class ApiCoinNodesServer extends EthLikeServerBase {
162
162
  return this.sendRequest(request)
163
163
  }
164
164
 
165
+ async contractStaticCallFrom(contract, functionName, ...params) {
166
+ const request = this.contractStaticCallFromRequest(contract, functionName, ...params)
167
+ return contract[functionName].parse(await this.sendRequest(request))
168
+ }
169
+
170
+ async contractStaticCall(contract, functionName, ...params) {
171
+ return this.contractStaticCallFrom(contract, functionName, undefined, ...params)
172
+ }
173
+
165
174
  stop() {
166
175
  // no web socket to stop!
167
176
  }
@@ -364,6 +364,17 @@ export default class ClarityServer extends EthLikeServerBase {
364
364
  return this.sendRequest(request)
365
365
  }
366
366
 
367
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
368
+ async contractStaticCallFrom(contract, functionName, ...params) {
369
+ const request = this.contractStaticCallFromRequest(contract, functionName, ...params)
370
+ return contract[functionName].parse(await this.sendRequest(request))
371
+ }
372
+
373
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
374
+ async contractStaticCall(contract, functionName, ...params) {
375
+ return this.contractStaticCallFrom(contract, functionName, undefined, ...params)
376
+ }
377
+
367
378
  // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
368
379
  async getCoinbase() {
369
380
  const request = this.coinbaseRequest()
@@ -116,6 +116,20 @@ export default class EthLikeServerBase extends EventEmitter {
116
116
  })
117
117
  }
118
118
 
119
+ contractStaticCallFromRequest(contract, functionName, from, ...args) {
120
+ assert(contract instanceof SolidityContract, 'expected SolidityContract contract')
121
+ assert(typeof functionName === 'string', 'expected string functionName')
122
+
123
+ const requestProps = {
124
+ to: contract.address,
125
+ data: bufferToHex(contract[functionName].build(...args)),
126
+ }
127
+
128
+ if (from) requestProps.from = from
129
+
130
+ return this.ethCallRequest(requestProps)
131
+ }
132
+
119
133
  sendBundleRequest({ txs }) {
120
134
  assert(Array.isArray(txs), 'expected array txs')
121
135
  return this.buildRequest({ method: 'eth_sendBundle', params: [{ txs }] })
@@ -73,6 +73,11 @@ export class EthLikeRpcRequestAccumulator extends EthLikeServerBase {
73
73
  assertValidMulticall3BlockTag(tag)
74
74
  }
75
75
 
76
+ if (method === 'eth_call') {
77
+ const { from } = params[0]
78
+ assert(!from, 'cannot specify "from"')
79
+ }
80
+
76
81
  void this.#requests.push({
77
82
  originalRequest,
78
83
  request: this._serializeRequest({ originalRequest }),
@@ -103,19 +108,17 @@ export class EthLikeRpcRequestAccumulator extends EthLikeServerBase {
103
108
  )(...args)
104
109
  }
105
110
 
106
- contractStaticCallRequest(contract, functionName, ...args) {
107
- assert(contract instanceof SolidityContract, 'expected SolidityContract contract')
108
- assert(typeof functionName === 'string', 'expected string functionName')
109
-
111
+ contractStaticCallFromRequest(contract, functionName, ...args) {
110
112
  const requestParser = ({ result }) => contract[functionName].parse(result)
113
+ return this.#withRequestParser('contractStaticCallFromRequest', requestParser)(
114
+ contract,
115
+ functionName,
116
+ ...args
117
+ )
118
+ }
111
119
 
112
- return this.#withRequestParser(
113
- 'ethCallRequest',
114
- requestParser
115
- )({
116
- to: contract.address,
117
- data: bufferToHex(contract[functionName].build(...args)),
118
- })
120
+ contractStaticCallRequest(contract, functionName, ...args) {
121
+ return this.contractStaticCallFromRequest(contract, functionName, undefined, ...args)
119
122
  }
120
123
 
121
124
  length() {
@@ -39,8 +39,8 @@ const createBalanceAmount = (amount, { decimals, symbol }) => {
39
39
  return beforeAmount.sub(afterAmount)
40
40
  }
41
41
 
42
- const handleExpectedBalanceChange = (expectedBalanceChange, simulationResult, { network }) => {
43
- const { willApprove, willSend, willReceive } = simulationResult.balanceChanges
42
+ const handleExpectedBalanceChange = (expectedBalanceChange, balanceChanges, { network }) => {
43
+ const { willApprove, willSend, willReceive } = balanceChanges
44
44
  const { data, kind } = expectedBalanceChange.rawInfo
45
45
 
46
46
  if (kind === 'FARCASTER_CHANGE_RECOVERY_ADDRESS' || kind === 'ERC20_PERMIT') {
@@ -49,6 +49,10 @@ const handleExpectedBalanceChange = (expectedBalanceChange, simulationResult, {
49
49
 
50
50
  const { asset: simulationAsset } = data
51
51
 
52
+ if (!simulationAsset) {
53
+ return
54
+ }
55
+
52
56
  const isNftTransfer =
53
57
  kind === 'ERC721_TRANSFER' || (kind === 'ERC1155_TRANSFER' && !simulationAsset.decimals)
54
58
  const isNftTransferApproval =
@@ -132,17 +136,6 @@ const handleExpectedBalanceChange = (expectedBalanceChange, simulationResult, {
132
136
  }
133
137
  }
134
138
 
135
- const simulationResultHasAssets = (expectedStateChanges) => {
136
- for (const expectedChange of expectedStateChanges) {
137
- const { asset: simulationAsset } = expectedChange.rawInfo.data
138
- if (!simulationAsset) {
139
- return false
140
- }
141
- }
142
-
143
- return true
144
- }
145
-
146
139
  export const SimulationErrorMessages = {
147
140
  APICallFailed: 0,
148
141
  SimulationFailed: 1,
@@ -230,7 +223,25 @@ export const simulateTransactionsApi = async ({
230
223
  })
231
224
  }
232
225
 
233
- const noExpectedStateChangesDetected = Object.keys(expectedStateChanges).length === 0
226
+ const userAddressLower = userAddress.toLowerCase()
227
+ const recipientAddressLower =
228
+ recipientAddress && recipientAddress.toLowerCase() !== userAddressLower
229
+ ? recipientAddress.toLowerCase()
230
+ : undefined
231
+
232
+ const relevantExpectedChanges = Object.entries(expectedStateChanges).flatMap(
233
+ ([address, changes]) => {
234
+ const addressLower = address.toLowerCase()
235
+ const isUserOwned = addressLower === userAddressLower
236
+ if (!isUserOwned && addressLower !== recipientAddressLower) {
237
+ return []
238
+ }
239
+
240
+ return (changes ?? []).map((change) => ({ change, isUserOwned }))
241
+ }
242
+ )
243
+
244
+ const noExpectedStateChangesDetected = relevantExpectedChanges.length === 0
234
245
 
235
246
  if (noExpectedStateChangesDetected) {
236
247
  simulationResult.balanceChanges.willSend.push({
@@ -244,22 +255,11 @@ export const simulateTransactionsApi = async ({
244
255
  return successDefaultResult
245
256
  }
246
257
 
247
- const addressesToProcess = [userAddress.toLowerCase()]
248
- if (recipientAddress && recipientAddress.toLowerCase() !== userAddress.toLowerCase()) {
249
- addressesToProcess.push(recipientAddress.toLowerCase())
250
- }
251
-
252
- const relevantExpectedChanges = Object.entries(expectedStateChanges).flatMap(
253
- ([address, changes]) =>
254
- addressesToProcess.includes(address.toLowerCase()) ? changes ?? [] : []
255
- )
256
-
257
- if (!simulationResultHasAssets(relevantExpectedChanges)) {
258
- return successDefaultResult
259
- }
260
-
261
- relevantExpectedChanges.forEach((expectedChange) => {
262
- handleExpectedBalanceChange(expectedChange, simulationResult, { network })
258
+ relevantExpectedChanges.forEach(({ change, isUserOwned }) => {
259
+ const balanceChanges = isUserOwned
260
+ ? simulationResult.balanceChanges
261
+ : simulationResult.recipientBalanceChanges
262
+ handleExpectedBalanceChange(change, balanceChanges, { network })
263
263
  })
264
264
 
265
265
  return successDefaultResult
@@ -23,7 +23,10 @@ export const isEthereumStakingTx = ({ coinName }) =>
23
23
  export const isEthereumStakingPoolContract = (address) =>
24
24
  typeof address === 'string' && STAKING_MANAGER_CONTRACTS.has(address.toLowerCase())
25
25
  export const isEthereumDelegate = (tx) =>
26
- isEthereumStakingTx(tx) && STAKING_MANAGER_CONTRACTS.has(tx.to) && tx.data?.methodId === DELEGATE
26
+ isEthereumStakingTx(tx) &&
27
+ ((STAKING_MANAGER_CONTRACTS.has(tx.to) && tx.data?.methodId === DELEGATE) ||
28
+ tx.data?.delegate === true ||
29
+ typeof tx.data?.delegate === 'string')
27
30
  export const isEthereumUndelegatePending = (tx) =>
28
31
  isEthereumStakingTx(tx) && tx.data?.methodId === UNSTAKE_PENDING
29
32
  export const isEthereumUndelegate = (tx) =>
@@ -4,12 +4,12 @@ import lodash from 'lodash'
4
4
  import assert from 'minimalistic-assert'
5
5
 
6
6
  import { executeEthLikeFeeMonitorUpdate } from '../fee-utils.js'
7
- import { fromHexToString } from '../number-utils.js'
8
7
  import {
9
8
  filterEffects,
10
9
  getLogItemsFromServerTx,
11
10
  normalizeTransactionsResponse,
12
11
  } from './clarity-utils/index.js'
12
+ import { getBatchedRpcBalances } from './monitor-utils/get-batched-rpc-balances.js'
13
13
  import {
14
14
  checkPendingTransactions,
15
15
  excludeUnchangedTokenBalances,
@@ -364,33 +364,12 @@ export class ClarityMonitor extends BaseMonitor {
364
364
  }
365
365
 
366
366
  async getBalances({ tokens, ourWalletAddress }) {
367
- const batch = Object.create(null)
368
- if (this.rpcBalanceAssetNames.includes(this.asset.name)) {
369
- const request = this.server.getBalanceRequest(ourWalletAddress)
370
- batch[this.asset.name] = request
371
- }
372
-
373
- for (const token of tokens) {
374
- if (this.rpcBalanceAssetNames.includes(token.name) && token.contract.address) {
375
- const request = this.server.balanceOfRequest(ourWalletAddress, token.contract.address)
376
- batch[token.name] = request
377
- }
378
- }
379
-
380
- const pairs = Object.entries(batch)
381
- if (pairs.length === 0) {
382
- return {}
383
- }
384
-
385
- const requests = pairs.map((pair) => pair[1])
386
- const responses = await this.server.sendBatchRequest(requests)
387
- const entries = pairs.map((pair, idx) => {
388
- const balanceHex = responses[idx]
389
- const name = pair[0]
390
- const balance = fromHexToString(balanceHex)
391
- return [name, balance]
367
+ return getBatchedRpcBalances({
368
+ baseAsset: this.asset,
369
+ ourWalletAddress,
370
+ tokens,
371
+ rpcBalanceAssetNames: this.rpcBalanceAssetNames,
392
372
  })
393
- return Object.fromEntries(entries)
394
373
  }
395
374
 
396
375
  getUnknownTokenAddresses({ transactions, tokensByAddress }) {
@@ -2,27 +2,46 @@ import assert from 'minimalistic-assert'
2
2
 
3
3
  import { fromHexToString } from '../../number-utils.js'
4
4
 
5
- export const getBatchedRpcBalances = async ({ baseAsset, tokens, ourWalletAddress }) => {
5
+ export const getBatchedRpcBalances = async ({
6
+ baseAsset,
7
+ tokens,
8
+ ourWalletAddress,
9
+ rpcBalanceAssetNames,
10
+ }) => {
6
11
  assert(baseAsset, 'expected baseAsset')
7
12
  assert(Array.isArray(tokens), 'expected array tokens')
8
13
  assert(ourWalletAddress, 'expected ourWalletAddress')
9
14
 
10
- const [balance, ...tokenBalances] = await tokens
15
+ const batch = (assetName) =>
16
+ !Array.isArray(rpcBalanceAssetNames) || rpcBalanceAssetNames.includes(assetName)
17
+
18
+ const isBaseAssetBatched = batch(baseAsset.name)
19
+
20
+ const rpcRequestAccumulator = tokens
21
+ .filter(({ name, contract }) => batch(name) && contract?.address)
11
22
  .reduce(
12
23
  (acc, { contract: { address: tokenAddress } }) =>
13
24
  acc.balanceOfRequest(ourWalletAddress, tokenAddress),
14
- baseAsset.createRpcRequestAccumulator().getBalanceRequest(ourWalletAddress)
25
+ isBaseAssetBatched
26
+ ? baseAsset.createRpcRequestAccumulator().getBalanceRequest(ourWalletAddress)
27
+ : baseAsset.createRpcRequestAccumulator()
15
28
  )
16
- .flush(baseAsset.server)
29
+
30
+ if (rpcRequestAccumulator.length() === 0) return Object.create(null)
31
+
32
+ const results = await rpcRequestAccumulator.flush(baseAsset.server)
17
33
 
18
34
  return {
19
- [baseAsset.name]: fromHexToString(balance),
35
+ ...(isBaseAssetBatched && { [baseAsset.name]: fromHexToString(results[0]) }),
20
36
  ...Object.fromEntries(
21
- tokens.map(({ name, contract: { address: tokenAddress } }, i) => {
22
- const tokenBalance = tokenBalances[i]?.confirmed?.[tokenAddress]
23
- assert(tokenBalance != null, `missing token balance for ${name} (${tokenAddress})`)
24
- return [name, tokenBalance]
25
- })
37
+ tokens
38
+ .filter(({ name, contract }) => batch(name) && contract?.address)
39
+ .map(({ name, contract: { address: tokenAddress } }, i) => {
40
+ const tokenBalance = results[i + (isBaseAssetBatched ? 1 : 0)]?.confirmed?.[tokenAddress]
41
+
42
+ assert(tokenBalance != null, `missing token balance for ${name} (${tokenAddress})`)
43
+ return [name, tokenBalance]
44
+ })
26
45
  ),
27
46
  }
28
47
  }