@exodus/ethereum-api 8.73.2 → 8.73.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,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.73.3](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.73.2...@exodus/ethereum-api@8.73.3) (2026-05-06)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: correct misleading assertion and error messages (#7965)
13
+
14
+ * fix(ethereum-api): avoid mutating caller's txLog in resolveNonce (#7996)
15
+
16
+ * fix(ethereum-api): filter hints before pushing to stack in EthLikeError (#7991)
17
+
18
+ * fix: missing matic claim unstake transactions (#8006)
19
+
20
+
21
+
6
22
  ## [8.73.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.73.1...@exodus/ethereum-api@8.73.2) (2026-05-05)
7
23
 
8
24
  **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.73.2",
3
+ "version": "8.73.3",
4
4
  "description": "Transaction monitors, fee monitors, RPC with the blockchain node, and other networking code for Ethereum and EVM-based blockchains",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -49,8 +49,7 @@
49
49
  "make-concurrent": "^4.0.0",
50
50
  "minimalistic-assert": "^1.0.1",
51
51
  "ms": "^2.1.1",
52
- "socket.io-client": "^2.1.1",
53
- "ws": "^6.1.0"
52
+ "socket.io-client": "^2.1.1"
54
53
  },
55
54
  "devDependencies": {
56
55
  "@exodus/assets-testing": "^1.0.0",
@@ -68,5 +67,5 @@
68
67
  "type": "git",
69
68
  "url": "git+https://github.com/ExodusMovement/assets.git"
70
69
  },
71
- "gitHead": "69553b4abf01b543d0e6006299166b8f7cc6271c"
70
+ "gitHead": "fe7c27f31aa14bf777660af02300c92def4b4a8c"
72
71
  }
@@ -293,9 +293,12 @@ export class EthLikeError extends Error {
293
293
  constructor({ message, errorReasonInfo, hint, traceId, baseAssetName }) {
294
294
  super(message)
295
295
  this.name = safeString`EthLikeError`
296
- this.#hintStack = [hint] // NOTE: we can add more hints to the stack
296
+ const filteredHint = this.#extractHint(hint)
297
+ // Only filtered hints are kept on the stack so that subsequent joins via
298
+ // `addHint` cannot leak sensitive info or untruncated content.
299
+ this.#hintStack = filteredHint ? [filteredHint] : []
297
300
  this.reason = errorReasonInfo.reason
298
- this.hint = this.#extractHint(hint)
301
+ this.hint = filteredHint
299
302
  this.type = errorReasonInfo.type
300
303
  this.traceId = traceId
301
304
  this.baseAssetName = baseAssetName
@@ -307,8 +310,8 @@ export class EthLikeError extends Error {
307
310
  return this
308
311
  }
309
312
 
310
- this.#hintStack.push(hint)
311
- this.hint = `${this.#hintStack.join(':')}`
313
+ this.#hintStack.push(filteredHint)
314
+ this.hint = this.#hintStack.join(':')
312
315
  return this
313
316
  }
314
317
 
@@ -300,7 +300,7 @@ export class EthereumStaking {
300
300
  throw new Error(err)
301
301
  }
302
302
  } else {
303
- throw new Error(`Min Amount ${this.minAmount}`)
303
+ throw new Error(`Max Amount for unstakePending ${pendingBalance}`)
304
304
  }
305
305
  }
306
306
  }
@@ -28,7 +28,10 @@ const getEthereumStakingTxData = ({ tx, currency }) => {
28
28
  const txAmount = tx.coinAmount.toDefaultString()
29
29
 
30
30
  if (isEthereumDelegate(tx)) {
31
- return { delegate: txAmount, txAmount }
31
+ return {
32
+ coinAmount: currency.ZERO,
33
+ data: { delegate: txAmount, txAmount },
34
+ }
32
35
  }
33
36
 
34
37
  // undelegate must be taken in consideration, if unstaked ETH is still
@@ -38,28 +41,33 @@ const getEthereumStakingTxData = ({ tx, currency }) => {
38
41
  .baseUnit(decodeEthLikeStakingTxInputAmount(tx))
39
42
  .toDefaultString()
40
43
  return {
41
- undelegatePending,
42
- txAmount,
44
+ coinAmount: currency.ZERO,
45
+ data: { undelegatePending, txAmount },
43
46
  }
44
47
  }
45
48
 
46
49
  if (isEthereumUndelegate(tx)) {
47
50
  const undelegate = currency.baseUnit(decodeEthLikeStakingTxInputAmount(tx)).toDefaultString()
48
- return { undelegate, txAmount }
51
+ return {
52
+ coinAmount: currency.ZERO,
53
+ data: { undelegate, txAmount },
54
+ }
49
55
  }
50
56
 
51
57
  // In the case of the ETH being actually staked and earning,
52
58
  // unstake has a withdraw period, after that, unstaked can be claimed.
53
59
  if (isEthereumClaimUndelegate(tx)) {
54
- return { claimUndelegate: txAmount, txAmount }
60
+ return {
61
+ coinAmount: currency.ZERO,
62
+ data: { claimUndelegate: txAmount, txAmount },
63
+ }
55
64
  }
56
65
  }
57
66
 
58
67
  const getPolygonStakingTxData = ({ tx, currency }) => {
59
- if (
60
- ['delegate', 'undelegate', 'claimUndelegate'].some((stakeTx) => tx.data?.[stakeTx]) &&
61
- tx.coinAmount.isZero
62
- ) {
68
+ if (tx.data?.claimUndelegate) return
69
+
70
+ if (['delegate', 'undelegate'].some((stakeTx) => tx.data?.[stakeTx]) && tx.coinAmount.isZero) {
63
71
  return
64
72
  }
65
73
 
@@ -69,22 +77,41 @@ const getPolygonStakingTxData = ({ tx, currency }) => {
69
77
  const delegate = currency.baseUnit(decodePolygonStakingTxInputAmount(tx)).toDefaultString()
70
78
  // MATIC returned in unstake tx is always reward
71
79
  const rewards = calculateRewardsFromStakeTx({ tx, currency })
72
- return { delegate, txAmount, ...(rewards ? { rewards } : {}) }
80
+ return {
81
+ coinAmount: currency.ZERO,
82
+ data: {
83
+ delegate,
84
+ txAmount,
85
+ ...(rewards ? { rewards } : {}),
86
+ },
87
+ }
73
88
  }
74
89
 
75
90
  if (isPolygonUndelegate(tx)) {
76
91
  const undelegate = currency.baseUnit(decodePolygonStakingTxInputAmount(tx)).toDefaultString()
77
92
  // MATIC returned in unstake tx is always reward
78
93
  const rewards = txAmount
79
- return { undelegate, txAmount, rewards }
94
+ return {
95
+ coinAmount: currency.ZERO,
96
+ data: { undelegate, txAmount, rewards },
97
+ }
80
98
  }
81
99
 
82
100
  if (isPolygonClaimUndelegate(tx)) {
83
- return { claimUndelegate: txAmount, txAmount }
101
+ return {
102
+ // NOTE: We intentionally omit for compatibility with `rx`:
103
+ // https://github.com/ExodusMovement/exodus-mobile/blob/229c9c0634af3e8e17eb1624019682c6fffe29bf/src/utils/getTxTag.js#L186
104
+ //
105
+ // coinAmount: currency.ZERO,
106
+ data: {
107
+ claimUndelegate: txAmount,
108
+ txAmount,
109
+ },
110
+ }
84
111
  }
85
112
  }
86
113
 
87
- export const assetStakingTxData = {
114
+ export const assetStakingTxProps = {
88
115
  polygon: getPolygonStakingTxData,
89
116
  ethereum: getEthereumStakingTxData,
90
117
  ethereumholesky: getEthereumStakingTxData,
@@ -1,9 +1,5 @@
1
1
  import { getPolygonUndelegateTxInEthereumTxLog } from '../staking/matic/matic-staking-utils.js'
2
- import { assetStakingTxData } from './asset-staking-tx-data.js'
3
-
4
- const getTxStakingData = ({ assetName, currency, tx }) => {
5
- return assetStakingTxData[assetName]({ tx, currency })
6
- }
2
+ import { assetStakingTxProps } from './asset-staking-tx-data.js'
7
3
 
8
4
  const getAssetExpandedTxLog = async ({ assetName, aci, txs, walletAccount }) => {
9
5
  const additionalTxs = []
@@ -27,14 +23,14 @@ const processTxLog = async ({ asset, assetClientInterface: aci, walletAccount, b
27
23
 
28
24
  const newTxs = []
29
25
  for (const tx of txs) {
30
- const stakingData = getTxStakingData({ assetName, currency, tx })
31
- if (stakingData) {
32
- newTxs.push({
33
- ...tx,
34
- coinAmount: currency.ZERO,
35
- data: { ...tx.data, ...stakingData },
36
- })
37
- }
26
+ const stakingProps = assetStakingTxProps[assetName]?.({ tx, currency })
27
+ if (!stakingProps) continue
28
+
29
+ newTxs.push({
30
+ ...tx,
31
+ ...stakingProps,
32
+ data: { ...tx.data, ...stakingProps.data },
33
+ })
38
34
  }
39
35
 
40
36
  const expandedTxs = await getAssetExpandedTxLog({ assetName, aci, txs, walletAccount })
@@ -55,7 +55,9 @@ const getNonceFromTxLog = ({ txLog, useAbsoluteNonce, tag }) => {
55
55
  let absoluteNonce = 0
56
56
 
57
57
  if (useAbsoluteNonce) {
58
- const reversedTxLog = txLog.reverse()
58
+ // NOTE: Use a copy to avoid mutating the caller's `txLog` array, since
59
+ // `Array.prototype.reverse()` reverses in-place.
60
+ const reversedTxLog = [...txLog].reverse()
59
61
  const maybeLatestTxWithNonceChange = getLatestTxWithNonceChange({ reversedTxLog })
60
62
 
61
63
  if (maybeLatestTxWithNonceChange) {
@@ -1,50 +0,0 @@
1
- import assert from 'minimalistic-assert'
2
-
3
- import request from './request.js'
4
-
5
- const isValidResponseCheck = (x) =>
6
- (x.status === '1' && x.message === 'OK') || x.message === 'No transactions found'
7
- const _request = async (...args) => request(isValidResponseCheck, 'account', ...args)
8
-
9
- export async function fetchBalance(address) {
10
- const balance = await _request('balance', { address })
11
-
12
- const isValid = /^\d+$/.test(balance)
13
- if (!isValid) throw new RangeError(`Invalid balance: ${balance}`)
14
-
15
- return balance
16
- }
17
-
18
- export async function fetchTxlist(address, options) {
19
- const params = { startblock: 0, endblock: 'latest', ...options, address }
20
- const txlist = await _request('txlist', params)
21
-
22
- // simple check
23
- assert(Array.isArray(txlist), `Invalid transactions: ${txlist}`)
24
-
25
- return txlist
26
- }
27
-
28
- export async function fetchTxlistinternal(address, options) {
29
- const params = { startblock: 0, endblock: 'latest', ...options, address }
30
- const txlist = await _request('txlistinternal', params)
31
-
32
- // simple check
33
- assert(Array.isArray(txlist), `Invalid transactions: ${txlist}`)
34
-
35
- return txlist
36
- }
37
-
38
- export async function tokenBalance(token, address) {
39
- const params = {
40
- [token.length === 42 ? 'contractaddress' : 'tokenname']: token,
41
- address,
42
- tag: 'latest',
43
- }
44
- const balance = await _request('tokenbalance', params)
45
-
46
- const isValid = /^\d+$/.test(balance)
47
- if (!isValid) throw new RangeError(`Invalid balance: ${balance}`)
48
-
49
- return balance
50
- }
@@ -1,28 +0,0 @@
1
- import createWebSocket from './ws.js'
2
-
3
- export const ETHERSCAN_WS_URL = 'wss://socket.etherscan.io/wshandler'
4
-
5
- export const ws = createWebSocket(ETHERSCAN_WS_URL)
6
-
7
- export function filterTxsSent(addr, etherscanTxs) {
8
- return etherscanTxs.filter((tx) => tx.from.toLowerCase() === addr.toLowerCase())
9
- }
10
-
11
- export function filterTxsReceived(addr, etherscanTxs) {
12
- return etherscanTxs.filter((tx) => tx.to.toLowerCase() === addr.toLowerCase())
13
- }
14
-
15
- export { fetchBalance, fetchTxlistinternal, fetchTxlist, tokenBalance } from './account.js'
16
- export {
17
- sendRawTransaction,
18
- getTransactionCount,
19
- estimateGas,
20
- getTransactionReceipt,
21
- getCode,
22
- ethCall,
23
- gasPrice,
24
- } from './proxy.js'
25
-
26
- export { setEtherscanApiKey as setApiKey } from './request.js'
27
-
28
- export { getLogs } from './logs.js'
@@ -1,17 +0,0 @@
1
- import assert from 'minimalistic-assert'
2
-
3
- import request from './request.js'
4
-
5
- const isValidResponseCheck = (x) =>
6
- (x.status === '1' && x.message === 'OK') || x.message === 'No records found'
7
- const _request = async (...args) => request(isValidResponseCheck, 'logs', ...args)
8
-
9
- export async function getLogs(address, fromBlock, toBlock, options) {
10
- const params = { ...options, address, fromBlock, toBlock }
11
- const events = await _request('getLogs', params)
12
-
13
- // simple check
14
- assert(Array.isArray(events), `Invalid transactions: ${events}`)
15
-
16
- return events
17
- }
@@ -1,48 +0,0 @@
1
- import request from './request.js'
2
-
3
- const isValidResponseCheck = (x) => x.result !== undefined
4
- const _request = async (...args) => request(isValidResponseCheck, 'proxy', ...args)
5
-
6
- export async function sendRawTransaction(data) {
7
- const _data = data instanceof Uint8Array ? Buffer.from(data).toString('hex') : data
8
- const txhash = await _request('eth_sendRawTransaction', { hex: '0x' + _data })
9
-
10
- const isValidTxHash = /^0x[\dA-Fa-f]{64}$/.test(txhash)
11
- if (!isValidTxHash) throw new Error(`Invalid tx hash: ${txhash}`)
12
-
13
- return txhash.slice(2)
14
- }
15
-
16
- export async function getTransactionCount(address, tag = 'latest') {
17
- return _request('eth_getTransactionCount', { address, tag })
18
- }
19
-
20
- export async function getTransactionReceipt(txhash) {
21
- return _request('eth_getTransactionReceipt', { txhash })
22
- }
23
-
24
- export async function estimateGas(data) {
25
- return _request('eth_estimateGas', data)
26
- }
27
-
28
- export async function getCode(address) {
29
- const code = await _request('eth_getCode', { address })
30
-
31
- const isValidCode = /^0x[\dA-Fa-f]*$/.test(code) && code.length % 2 === 0
32
- if (!isValidCode) throw new Error(`Invalid address code: ${code}`)
33
-
34
- return code
35
- }
36
-
37
- export async function gasPrice() {
38
- const price = await _request('eth_gasPrice')
39
-
40
- const isValidPrice = /^0x[\dA-Fa-f]+$/.test(price)
41
- if (!isValidPrice) throw new Error(`Invalid price: ${price}`)
42
-
43
- return price
44
- }
45
-
46
- export async function ethCall(data) {
47
- return _request('eth_call', data)
48
- }
@@ -1,26 +0,0 @@
1
- import fetchival from '@exodus/fetch/experimental/fetchival'
2
- import makeConcurrent from 'make-concurrent'
3
- import ms from 'ms'
4
-
5
- const ETHERSCAN_API_URL = 'https://api.etherscan.io/api'
6
- const DEFAULT_ETHERSCAN_API_KEY = 'XM3VGRSNW1TMSIR14I9MVFP15X74GNHTRI'
7
-
8
- let etherscanApiKey = DEFAULT_ETHERSCAN_API_KEY
9
-
10
- export function setEtherscanApiKey(apiKey) {
11
- etherscanApiKey = apiKey || DEFAULT_ETHERSCAN_API_KEY
12
- }
13
-
14
- export default makeConcurrent(
15
- async function (isValidResponseCheck, module, action, params = {}) {
16
- const data = await fetchival(new URL(ETHERSCAN_API_URL), { timeout: ms('15s') }).get({
17
- ...params,
18
- module,
19
- action,
20
- apiKey: etherscanApiKey,
21
- })
22
- if (!isValidResponseCheck(data)) throw new Error(`Invalid response: ${JSON.stringify(data)}`)
23
- return data.result
24
- },
25
- { concurrency: 3 }
26
- )
@@ -1,88 +0,0 @@
1
- import WebSocket from '@exodus/fetch/websocket'
2
- import EventEmitter from 'events/events.js' // forces it to use the module from node_modules
3
- import ms from 'ms'
4
-
5
- const RECONNECT_INTERVAL = ms('10s')
6
- const PING_INTERVAL = ms('20s')
7
-
8
- export default function createWebSocket(url) {
9
- const addresses = new Set()
10
- const events = new EventEmitter().setMaxListeners(20)
11
- const pingMessage = JSON.stringify({ event: 'ping' })
12
- let ws
13
- let wsOpened = false
14
- let opened = false
15
- let openTimeoutId
16
- let pingIntervalId
17
-
18
- function subscribeAddress(address) {
19
- const data = JSON.stringify({ event: 'txlist', address })
20
- ws.send(data)
21
- }
22
-
23
- function onMessage(data) {
24
- data = JSON.parse(data)
25
- switch (data.event) {
26
- case 'txlist':
27
- for (const tx of data.result) events.emit(`address-${data.address}`, tx)
28
- break
29
-
30
- case 'subscribe-txlist':
31
- const match = data.message.toLowerCase().match(/0x[\da-f]{40}/)
32
- if (match && data.status === '1') events.emit(`address-${match[0]}-subscribed`)
33
- else ws.close()
34
- break
35
- }
36
- }
37
-
38
- function isOpened() {
39
- return wsOpened
40
- }
41
-
42
- function open() {
43
- opened = true
44
- clearTimeout(openTimeoutId)
45
- if (ws) return
46
-
47
- ws = new WebSocket(url)
48
-
49
- ws.on('message', (data) => {
50
- try {
51
- onMessage(data)
52
- } catch {}
53
- })
54
- ws.once('open', () => {
55
- for (const address of addresses.values()) subscribeAddress(address)
56
- pingIntervalId = setInterval(() => ws && ws.send(pingMessage), PING_INTERVAL)
57
- wsOpened = true
58
- events.emit('open')
59
- })
60
- ws.once('close', () => {
61
- ws = null
62
- clearInterval(pingIntervalId)
63
- if (opened) openTimeoutId = setTimeout(open, RECONNECT_INTERVAL)
64
- wsOpened = false
65
- events.emit('close')
66
- })
67
- }
68
-
69
- function close() {
70
- opened = false
71
- clearTimeout(openTimeoutId)
72
- if (!ws) return
73
-
74
- ws.close()
75
- ws = null
76
- }
77
-
78
- function watch(address) {
79
- address = address.toLowerCase()
80
-
81
- if (addresses.has(address)) return
82
- addresses.add(address)
83
-
84
- if (wsOpened) subscribeAddress(address)
85
- }
86
-
87
- return { events, isOpened, open, close, watch }
88
- }