@exodus/ethereum-api 8.76.4 → 8.76.6

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,24 @@
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.76.6](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.76.5...@exodus/ethereum-api@8.76.6) (2026-06-11)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: Ledger clear-signing for EVM token approvals (#8207)
13
+
14
+
15
+
16
+ ## [8.76.5](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.76.4...@exodus/ethereum-api@8.76.5) (2026-06-03)
17
+
18
+ **Note:** Version bump only for package @exodus/ethereum-api
19
+
20
+
21
+
22
+
23
+
6
24
  ## [8.76.4](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.76.3...@exodus/ethereum-api@8.76.4) (2026-06-02)
7
25
 
8
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.76.4",
3
+ "version": "8.76.6",
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",
@@ -29,7 +29,7 @@
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.2",
32
+ "@exodus/ethereum-lib": "^5.24.3",
33
33
  "@exodus/ethereum-meta": "^2.9.1",
34
34
  "@exodus/ethereumholesky-meta": "^2.0.5",
35
35
  "@exodus/ethereumjs": "^1.11.0",
@@ -70,5 +70,5 @@
70
70
  "type": "git",
71
71
  "url": "git+https://github.com/ExodusMovement/assets.git"
72
72
  },
73
- "gitHead": "f0c007138096bf627e21fc9e757637394fe2a5b0"
73
+ "gitHead": "f9bb7fcf58132b8daa6db82edc0bc53f380029cd"
74
74
  }
@@ -1,6 +1,10 @@
1
1
  export const addressHasHistoryFactory =
2
2
  ({ server }) =>
3
3
  async (address) => {
4
- const history = await server.getHistoryV2(address, { index: 0, limit: 1 })
5
- return history.length > 0
4
+ const [nonceHex, balanceHex] = await Promise.all([
5
+ server.getTransactionCount(address, 'pending'),
6
+ server.getBalanceProxied(address, 'latest'),
7
+ ])
8
+
9
+ return Number.parseInt(nonceHex, 16) > 0 || Number.parseInt(balanceHex, 16) > 0
6
10
  }
@@ -0,0 +1,23 @@
1
+ import { fetchival } from '@exodus/fetch'
2
+ import assert from 'minimalistic-assert'
3
+
4
+ export const DEFAULT_ASSESS_TRANSACTION_API_URL =
5
+ 'https://simulation.a.exodus.io/simulation/transaction/assessment'
6
+
7
+ export const createAssessTransaction = (
8
+ { apiUrl = DEFAULT_ASSESS_TRANSACTION_API_URL, headers, request = fetchival } = Object.create(
9
+ null
10
+ )
11
+ ) => {
12
+ assert(typeof apiUrl === 'string' && apiUrl.length > 0, 'apiUrl must be a non-empty string')
13
+
14
+ return async function assessTransaction({ payload }) {
15
+ return request(apiUrl, {
16
+ method: 'POST',
17
+ headers: {
18
+ ...headers,
19
+ 'Content-Type': 'application/json',
20
+ },
21
+ }).post(payload)
22
+ }
23
+ }
@@ -1,5 +1,4 @@
1
1
  import { memoizeLruCache } from '@exodus/asset-lib'
2
- import { makeSimulationAPICall } from '@exodus/web3-utils'
3
2
  import assert from 'minimalistic-assert'
4
3
  import ms from 'ms'
5
4
 
@@ -37,25 +36,25 @@ const isRecipientFinding = ({ finding, recipientAddress }) => {
37
36
  })
38
37
  }
39
38
 
40
- const pickFindingForRecipient = ({ findings, recipientAddress }) => {
41
- if (!Array.isArray(findings) || !recipientAddress) return GENERIC_RISK_REASON
42
- const finding = findings.find((candidate) =>
43
- isRecipientFinding({ finding: candidate, recipientAddress })
44
- )
45
- return finding?.title || GENERIC_RISK_REASON
39
+ const findRecipientFinding = ({ findings, recipientAddress }) => {
40
+ if (!Array.isArray(findings) || !recipientAddress) return
41
+ return findings.find((candidate) => isRecipientFinding({ finding: candidate, recipientAddress }))
46
42
  }
47
43
 
48
44
  const parseAssessment = ({ body, recipientAddress }) => {
49
45
  if (!body?.success) return { action: 'NONE' }
50
46
  if (body.data?.recommendation !== 'deny') return { action: 'NONE' }
47
+ const finding = findRecipientFinding({ findings: body.data?.findings, recipientAddress })
48
+ if (!finding) return { action: 'NONE' }
49
+
51
50
  return {
52
51
  action: 'WARN',
53
- reason: pickFindingForRecipient({ findings: body.data?.findings, recipientAddress }),
52
+ reason: finding.title || GENERIC_RISK_REASON,
54
53
  }
55
54
  }
56
55
 
57
- // Rejects after `timeoutMs`. The underlying request keeps running because
58
- // `makeSimulationAPICall` doesn't accept an AbortSignal.
56
+ // Rejects after `timeoutMs`. The underlying request keeps running because the
57
+ // API helper doesn't accept an AbortSignal.
59
58
  const withTimeout = async (promise, timeoutMs) => {
60
59
  let timeoutId
61
60
  const timeoutPromise = new Promise((_resolve, reject) => {
@@ -71,14 +70,9 @@ const withTimeout = async (promise, timeoutMs) => {
71
70
  const noopLogger = { warn: () => {} }
72
71
 
73
72
  export const createCheckTx = (
74
- {
75
- apiUrl,
76
- timeout = DEFAULT_TIMEOUT_MS,
77
- makeApiCall = makeSimulationAPICall,
78
- logger = noopLogger,
79
- } = Object.create(null)
73
+ { timeout = DEFAULT_TIMEOUT_MS, makeApiCall, logger = noopLogger } = Object.create(null)
80
74
  ) => {
81
- assert(apiUrl, 'apiUrl is required')
75
+ assert(typeof makeApiCall === 'function', 'makeApiCall is required')
82
76
 
83
77
  const warn = typeof logger?.warn === 'function' ? logger.warn.bind(logger) : noopLogger.warn
84
78
 
@@ -90,13 +84,7 @@ export const createCheckTx = (
90
84
  if (input !== undefined) transaction.input = input
91
85
  if (hash !== undefined) transaction.hash = hash
92
86
 
93
- const body = await withTimeout(
94
- makeApiCall({
95
- url: apiUrl,
96
- payload: { serviceProvider: 'hypernative', transaction },
97
- }),
98
- timeout
99
- )
87
+ const body = await withTimeout(makeApiCall({ payload: { transaction } }), timeout)
100
88
 
101
89
  if (!body) throw new Error('checkTx: empty response')
102
90
  return parseAssessment({ body, recipientAddress: toAddress })
@@ -1,3 +1,4 @@
1
+ import { createConsoleLogger } from '@exodus/asset-lib'
1
2
  import { ASSET_FAMILY, connectAssetsList } from '@exodus/assets'
2
3
  import bip44Constants from '@exodus/bip44-constants/by-ticker.js'
3
4
  import {
@@ -21,6 +22,7 @@ import lodash from 'lodash'
21
22
  import assert from 'minimalistic-assert'
22
23
 
23
24
  import { addressHasHistoryFactory } from './address-has-history.js'
25
+ import { createAssessTransaction } from './check-tx/create-assess-transaction.js'
24
26
  import { createCheckTx } from './check-tx/index.js'
25
27
  import {
26
28
  createGetBlackListStatus,
@@ -32,7 +34,7 @@ import {
32
34
  } from './create-asset-utils.js'
33
35
  import { createTokenFactory } from './create-token-factory.js'
34
36
  import { createCustomFeesApi } from './custom-fees.js'
35
- import { getEIP7702Delegation } from './eth-like-util.js'
37
+ import { getEIP7702Delegation, getIsNftContract } from './eth-like-util.js'
36
38
  import { createEvmServer } from './exodus-eth-server/index.js'
37
39
  import { createFeeData } from './fee-data/index.js'
38
40
  import { createGetBalanceForAddress } from './get-balance-for-address.js'
@@ -78,9 +80,9 @@ export const createAssetFactory = ({
78
80
  useAbsoluteBalanceAndNonce = false,
79
81
  delisted = false,
80
82
  privacyRpcUrl: defaultPrivacyRpcUrl,
81
- riskAssessment: defaultRiskAssessment,
82
83
  wsGatewayUri: defaultWsGatewayUri,
83
84
  eip7702Supported,
85
+ transactionAssessment: defaultTransactionAssessment,
84
86
  }) => {
85
87
  assert(assetsList, 'assetsList is required')
86
88
  assert(providedFeeData || feeDataConfig, 'feeData or feeDataConfig is required')
@@ -104,7 +106,7 @@ export const createAssetFactory = ({
104
106
  nfts: defaultNfts,
105
107
  customTokens: defaultCustomTokens,
106
108
  privacyRpcUrl: defaultPrivacyRpcUrl,
107
- riskAssessment: defaultRiskAssessment,
109
+ transactionAssessment: defaultTransactionAssessment,
108
110
  }
109
111
  return (
110
112
  {
@@ -125,7 +127,7 @@ export const createAssetFactory = ({
125
127
  supportsCustomFees,
126
128
  useAbsoluteBalanceAndNonce: overrideUseAbsoluteBalanceAndNonce,
127
129
  privacyRpcUrl,
128
- riskAssessment,
130
+ transactionAssessment,
129
131
  } = configWithOverrides
130
132
 
131
133
  const asset = assets[base.name]
@@ -294,16 +296,18 @@ export const createAssetFactory = ({
294
296
 
295
297
  const securityChecks = createSecurityChecks({ eip7702Supported })
296
298
 
299
+ const web3 = createWeb3API({ asset })
300
+
297
301
  let checkTx
298
- if (riskAssessment?.apiUrl) {
302
+ let sendValidations = []
303
+ if (transactionAssessment?.enabled) {
299
304
  checkTx = createCheckTx({
300
- apiUrl: riskAssessment.apiUrl,
301
- logger: riskAssessment.logger,
305
+ makeApiCall: createAssessTransaction({ apiUrl: transactionAssessment.apiUrl }),
306
+ logger: createConsoleLogger('@exodus/ethereum-api:check-tx'),
302
307
  })
308
+ sendValidations = createSendValidations({ assetClientInterface, checkTx })
303
309
  }
304
310
 
305
- const sendValidations = checkTx ? createSendValidations({ assetClientInterface, checkTx }) : []
306
-
307
311
  const moveFunds = moveFundsFactory({
308
312
  baseAssetName: asset.name,
309
313
  assetClientInterface,
@@ -349,7 +353,10 @@ export const createAssetFactory = ({
349
353
  signer
350
354
  ? signUnsignedTxWithSigner(unsignedTx, signer)
351
355
  : signUnsignedTx(unsignedTx, privateKey),
352
- signHardware: signHardwareFactory({ baseAssetName: asset.name }),
356
+ signHardware: signHardwareFactory({
357
+ baseAssetName: asset.name,
358
+ getIsNft: ({ address }) => getIsNftContract({ server, address }),
359
+ }),
353
360
  signMessage: ({ message, privateKey, signer }) =>
354
361
  signer ? signMessageWithSigner({ message, signer }) : signMessage({ privateKey, message }),
355
362
  ...(checkTx && { checkTx }),
@@ -365,7 +372,7 @@ export const createAssetFactory = ({
365
372
  }),
366
373
  }),
367
374
  validateAssetId: address.validate,
368
- web3: createWeb3API({ asset }),
375
+ web3,
369
376
  }
370
377
 
371
378
  const fullAsset = {
@@ -159,6 +159,7 @@ export const getIsForwarderContract = memoizeLruCache(
159
159
 
160
160
  const ERC20 = new SolidityContract(ABI.erc20)
161
161
  const ERC20BytesParams = new SolidityContract(ABI.erc20BytesParams)
162
+ const ERC721 = new SolidityContract(ABI.erc721)
162
163
  const DEFAULT_PARAM_NAMES = ['decimals', 'name', 'symbol']
163
164
  const erc20ParamsCache = Object.create(null)
164
165
 
@@ -213,6 +214,58 @@ export const getERC20Params = async ({ asset, address, paramNames = DEFAULT_PARA
213
214
  return response
214
215
  }
215
216
 
217
+ // ERC-165 introspection used to drive Ledger's clear-signing for contract calls:
218
+ // it tells the device whether the target is an NFT (ERC-721/1155) or a fungible
219
+ // token. See `signHardwareFactory` in ethereum-lib for how this feeds the device's
220
+ // `isNft` hint. Display-only: never affects the signed transaction.
221
+ const buildSupportsInterfaceData = (interfaceId) => {
222
+ const erc165InterfaceId = `0x${interfaceId}`
223
+ return `0x${ERC721.supportsInterface.build(erc165InterfaceId).toString('hex')}`
224
+ }
225
+
226
+ const decodeBool = (result) => {
227
+ return ERC721.decodeOutput({ method: 'supportsInterface', data: result })[0]
228
+ }
229
+
230
+ // true = supported, false = not supported / reverted, undefined = transport failure
231
+ const probeSupportsInterface = async ({ server, address, interfaceId }) => {
232
+ try {
233
+ const result = await server.ethCall({
234
+ to: address,
235
+ data: buildSupportsInterfaceData(interfaceId),
236
+ })
237
+ return decodeBool(result)
238
+ } catch (err) {
239
+ // Empirically, a contract without supportsInterface comes through Clarity as
240
+ // "Bad rpc response: execution reverted". Unknown errors stay undefined so
241
+ // we don't force a wrong hint.
242
+ const EXECUTION_REVERT_MESSAGE = 'execution reverted'
243
+ const errorMessage = err?.message || String(err || '')
244
+ return errorMessage.toLowerCase().includes(EXECUTION_REVERT_MESSAGE) ? false : undefined
245
+ }
246
+ }
247
+
248
+ // Resolves whether `address` is an NFT contract (ERC-721 or ERC-1155) via ERC-165.
249
+ // Returns true (NFT), false (not an NFT), or undefined (couldn't determine).
250
+ export async function getIsNftContract({ server, address }) {
251
+ assert(server, 'getIsNftContract(): server required')
252
+ assert(address, 'getIsNftContract(): address required')
253
+
254
+ const ERC721_INTERFACE_ID = '80ac58cd'
255
+ const ERC1155_INTERFACE_ID = 'd9b67a26'
256
+
257
+ const [isErc721, isErc1155] = await Promise.all([
258
+ probeSupportsInterface({ server, address, interfaceId: ERC721_INTERFACE_ID }),
259
+ probeSupportsInterface({ server, address, interfaceId: ERC1155_INTERFACE_ID }),
260
+ ])
261
+
262
+ if (isErc721 === true || isErc1155 === true) {
263
+ return true
264
+ }
265
+
266
+ if (isErc721 === false && isErc1155 === false) return false
267
+ }
268
+
216
269
  export async function getEIP7702Delegation({ address, server }) {
217
270
  const code = await withErrorReason({
218
271
  promise: server.getCode(address),
@@ -39,14 +39,13 @@ export const createSendValidations = ({ assetClientInterface, checkTx }) => {
39
39
  })
40
40
  if (!fromAddress) return
41
41
 
42
- // Match Hypernative's documented native-send payload shape.
42
+ // Native send: empty calldata, no signed tx hash to pass yet.
43
43
  const result = await checkTx({
44
44
  chain: asset.chainId,
45
45
  fromAddress,
46
46
  toAddress: destinationAddress,
47
47
  value: sendAmount.toBaseString({ unit: false }),
48
48
  input: '0x',
49
- hash: '0x1',
50
49
  })
51
50
 
52
51
  if (result?.action !== 'WARN') return