@exodus/ethereum-api 8.75.1 → 8.76.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 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.76.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.75.1...@exodus/ethereum-api@8.76.0) (2026-05-25)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat(ethereum-api): warn on risky recipients for native ETH sends (#8110)
13
+
14
+
15
+
6
16
  ## [8.75.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.75.0...@exodus/ethereum-api@8.75.1) (2026-05-19)
7
17
 
8
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.75.1",
3
+ "version": "8.76.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",
@@ -36,10 +36,12 @@
36
36
  "@exodus/fetch": "^1.3.0",
37
37
  "@exodus/models": "^13.0.0",
38
38
  "@exodus/safe-string": "^1.4.0",
39
+ "@exodus/send-validation-model": "^1.2.0",
39
40
  "@exodus/simple-retry": "^0.0.6",
40
41
  "@exodus/solidity-contract": "^1.3.0",
41
42
  "@exodus/traceparent": "^3.0.1",
42
43
  "@exodus/web3-ethereum-utils": "^4.7.4",
44
+ "@exodus/web3-utils": "^1.51.2",
43
45
  "bn.js": "^5.2.1",
44
46
  "delay": "^4.0.1",
45
47
  "eventemitter3": "^4.0.7",
@@ -58,7 +60,8 @@
58
60
  "@exodus/ethereumarbone-meta": "^2.1.2",
59
61
  "@exodus/fantommainnet-meta": "^2.0.5",
60
62
  "@exodus/matic-meta": "^2.2.7",
61
- "@exodus/rootstock-meta": "^2.0.5"
63
+ "@exodus/rootstock-meta": "^2.0.5",
64
+ "@exodus/send-validation": "^5.4.1"
62
65
  },
63
66
  "bugs": {
64
67
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Aethereum-api"
@@ -67,5 +70,5 @@
67
70
  "type": "git",
68
71
  "url": "git+https://github.com/ExodusMovement/assets.git"
69
72
  },
70
- "gitHead": "ee5bf2d01055d8822c6421fa0ad4da84d4a33402"
73
+ "gitHead": "73ff97a75479c3b25fccc11f31879ec634818d63"
71
74
  }
@@ -0,0 +1,116 @@
1
+ import { memoizeLruCache } from '@exodus/asset-lib'
2
+ import { makeSimulationAPICall } from '@exodus/web3-utils'
3
+ import assert from 'minimalistic-assert'
4
+ import ms from 'ms'
5
+
6
+ const DEFAULT_TIMEOUT_MS = 5000
7
+ const DEFAULT_CACHE_MAX = 500
8
+ // Short TTL to dedupe rapid retypes without holding stale verdicts.
9
+ const DEFAULT_CACHE_TTL_MS = ms('1m')
10
+ const GENERIC_RISK_REASON = 'This transaction was flagged as risky.'
11
+ const RECIPIENT_INVOLVEMENT_TYPES = new Set(['scammer', 'destination'])
12
+
13
+ const normalizeAddress = (address) => (typeof address === 'string' ? address.toLowerCase() : '')
14
+
15
+ // `input` and `hash` are part of the key so different calldata gets its own verdict.
16
+ const buildCacheKey = ({ chain, fromAddress, toAddress, value, input, hash }) =>
17
+ [
18
+ chain,
19
+ normalizeAddress(fromAddress),
20
+ normalizeAddress(toAddress),
21
+ String(value),
22
+ input ?? '',
23
+ hash ?? '',
24
+ ].join(':')
25
+
26
+ const isRecipientFinding = ({ finding, recipientAddress }) => {
27
+ const relatedAssets = Array.isArray(finding?.relatedAssets) ? finding.relatedAssets : []
28
+ return relatedAssets.some((relatedAsset) => {
29
+ if (normalizeAddress(relatedAsset?.address) !== normalizeAddress(recipientAddress)) {
30
+ return false
31
+ }
32
+
33
+ const involvementTypes = Array.isArray(relatedAsset.involvementTypes)
34
+ ? relatedAsset.involvementTypes
35
+ : []
36
+ return involvementTypes.some((type) => RECIPIENT_INVOLVEMENT_TYPES.has(type))
37
+ })
38
+ }
39
+
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
46
+ }
47
+
48
+ const parseAssessment = ({ body, recipientAddress }) => {
49
+ if (!body?.success) return { action: 'NONE' }
50
+ if (body.data?.recommendation !== 'deny') return { action: 'NONE' }
51
+ return {
52
+ action: 'WARN',
53
+ reason: pickFindingForRecipient({ findings: body.data?.findings, recipientAddress }),
54
+ }
55
+ }
56
+
57
+ // Rejects after `timeoutMs`. The underlying request keeps running because
58
+ // `makeSimulationAPICall` doesn't accept an AbortSignal.
59
+ const withTimeout = async (promise, timeoutMs) => {
60
+ let timeoutId
61
+ const timeoutPromise = new Promise((_resolve, reject) => {
62
+ timeoutId = setTimeout(() => reject(new Error('checkTx: timeout')), timeoutMs)
63
+ })
64
+ try {
65
+ return await Promise.race([promise, timeoutPromise])
66
+ } finally {
67
+ clearTimeout(timeoutId)
68
+ }
69
+ }
70
+
71
+ const noopLogger = { warn: () => {} }
72
+
73
+ export const createCheckTx = (
74
+ {
75
+ apiUrl,
76
+ timeout = DEFAULT_TIMEOUT_MS,
77
+ makeApiCall = makeSimulationAPICall,
78
+ logger = noopLogger,
79
+ } = Object.create(null)
80
+ ) => {
81
+ assert(apiUrl, 'apiUrl is required')
82
+
83
+ const warn = typeof logger?.warn === 'function' ? logger.warn.bind(logger) : noopLogger.warn
84
+
85
+ // Errors and timeouts throw and are evicted by memoize-lru-cache, so only
86
+ // vendor-confirmed verdicts get cached.
87
+ const fetchVerdict = memoizeLruCache(
88
+ async ({ chain, fromAddress, toAddress, value, input, hash }) => {
89
+ const transaction = { chain, fromAddress, toAddress, value }
90
+ if (input !== undefined) transaction.input = input
91
+ if (hash !== undefined) transaction.hash = hash
92
+
93
+ const body = await withTimeout(
94
+ makeApiCall({
95
+ url: apiUrl,
96
+ payload: { serviceProvider: 'hypernative', transaction },
97
+ }),
98
+ timeout
99
+ )
100
+
101
+ if (!body) throw new Error('checkTx: empty response')
102
+ return parseAssessment({ body, recipientAddress: toAddress })
103
+ },
104
+ buildCacheKey,
105
+ { max: DEFAULT_CACHE_MAX, maxAge: DEFAULT_CACHE_TTL_MS }
106
+ )
107
+
108
+ return async (payload) => {
109
+ try {
110
+ return await fetchVerdict(payload)
111
+ } catch (error) {
112
+ warn('checkTx: failing open', error)
113
+ return { action: 'NONE' }
114
+ }
115
+ }
116
+ }
@@ -0,0 +1 @@
1
+ export { createCheckTx } from './create-check-tx.js'
@@ -21,6 +21,7 @@ import lodash from 'lodash'
21
21
  import assert from 'minimalistic-assert'
22
22
 
23
23
  import { addressHasHistoryFactory } from './address-has-history.js'
24
+ import { createCheckTx } from './check-tx/index.js'
24
25
  import {
25
26
  createGetBlackListStatus,
26
27
  createHistoryMonitorFactory,
@@ -39,6 +40,7 @@ import { getBalancesFactory } from './get-balances.js'
39
40
  import { getFeeFactory } from './get-fee.js'
40
41
  import { moveFundsFactory } from './move-funds.js'
41
42
  import { estimateL1DataFeeFactory, getL1GetFeeFactory } from './optimism-gas/index.js'
43
+ import { createSendValidations } from './send-validations.js'
42
44
  import { serverBasedFeeMonitorFactoryFactory } from './server-based-fee-monitor.js'
43
45
  import { stakingApiFactory } from './staking/api/index.js'
44
46
  import { createTxFactory } from './tx-create.js'
@@ -76,6 +78,7 @@ export const createAssetFactory = ({
76
78
  useAbsoluteBalanceAndNonce = false,
77
79
  delisted = false,
78
80
  privacyRpcUrl: defaultPrivacyRpcUrl,
81
+ riskAssessment: defaultRiskAssessment,
79
82
  wsGatewayUri: defaultWsGatewayUri,
80
83
  eip7702Supported,
81
84
  }) => {
@@ -101,6 +104,7 @@ export const createAssetFactory = ({
101
104
  nfts: defaultNfts,
102
105
  customTokens: defaultCustomTokens,
103
106
  privacyRpcUrl: defaultPrivacyRpcUrl,
107
+ riskAssessment: defaultRiskAssessment,
104
108
  }
105
109
  return (
106
110
  {
@@ -121,6 +125,7 @@ export const createAssetFactory = ({
121
125
  supportsCustomFees,
122
126
  useAbsoluteBalanceAndNonce: overrideUseAbsoluteBalanceAndNonce,
123
127
  privacyRpcUrl,
128
+ riskAssessment,
124
129
  } = configWithOverrides
125
130
 
126
131
  const asset = assets[base.name]
@@ -289,6 +294,16 @@ export const createAssetFactory = ({
289
294
 
290
295
  const securityChecks = createSecurityChecks({ eip7702Supported })
291
296
 
297
+ let checkTx
298
+ if (riskAssessment?.apiUrl) {
299
+ checkTx = createCheckTx({
300
+ apiUrl: riskAssessment.apiUrl,
301
+ logger: riskAssessment.logger,
302
+ })
303
+ }
304
+
305
+ const sendValidations = checkTx ? createSendValidations({ assetClientInterface, checkTx }) : []
306
+
292
307
  const moveFunds = moveFundsFactory({
293
308
  baseAssetName: asset.name,
294
309
  assetClientInterface,
@@ -324,6 +339,7 @@ export const createAssetFactory = ({
324
339
  assetName: asset.name,
325
340
  }),
326
341
  getSupportedPurposes: () => [44],
342
+ ...(sendValidations.length > 0 && { getSendValidations: () => sendValidations }),
327
343
  getTokens,
328
344
  hasFeature: (feature) => !!features[feature], // @deprecated use api.features instead
329
345
  moveFunds,
@@ -336,6 +352,7 @@ export const createAssetFactory = ({
336
352
  signHardware: signHardwareFactory({ baseAssetName: asset.name }),
337
353
  signMessage: ({ message, privateKey, signer }) =>
338
354
  signer ? signMessageWithSigner({ message, signer }) : signMessage({ privateKey, message }),
355
+ ...(checkTx && { checkTx }),
339
356
  ...(supportsStaking &&
340
357
  stakingDependencies[asset.name] && {
341
358
  staking: stakingApiFactory({
package/src/index.js CHANGED
@@ -99,6 +99,7 @@ export {
99
99
  export { txSendFactory } from './tx-send/index.js'
100
100
 
101
101
  export { createAssetFactory } from './create-asset.js'
102
+ export { createCheckTx } from './check-tx/index.js'
102
103
 
103
104
  export { moveFundsFactory } from './move-funds.js'
104
105
 
@@ -0,0 +1,59 @@
1
+ import sendValidationModel from '@exodus/send-validation-model'
2
+ import assert from 'minimalistic-assert'
3
+
4
+ const { FIELDS, PRIORITY_LEVELS, VALIDATION_TYPES } = sendValidationModel
5
+
6
+ export const SCAM_RECIPIENT = 'SCAM_RECIPIENT'
7
+ export const SCAM_RECIPIENT_MESSAGE =
8
+ 'This recipient was flagged as risky. Please verify before sending.'
9
+
10
+ const toWalletAccountId = (fromWalletAccount) =>
11
+ typeof fromWalletAccount === 'string' ? fromWalletAccount : fromWalletAccount?.toString?.()
12
+
13
+ export const createSendValidations = ({ assetClientInterface, checkTx }) => {
14
+ assert(assetClientInterface, 'assetClientInterface is required')
15
+ assert(typeof checkTx === 'function', 'checkTx must be a function')
16
+
17
+ const scamRecipientValidator = {
18
+ id: SCAM_RECIPIENT,
19
+ type: VALIDATION_TYPES.WARN,
20
+ field: FIELDS.ADDRESS,
21
+ priority: PRIORITY_LEVELS.MIDDLE,
22
+ validateAndGetMessage: async ({ asset, destinationAddress, sendAmount, fromWalletAccount }) => {
23
+ // M1: native sends only. M2 will cover tokens/approvals/contract calls
24
+ // via a post-createTx call site on the review screen.
25
+ if (!asset || asset.name !== asset.baseAsset.name) return
26
+ if (!destinationAddress || !sendAmount) return
27
+ if (sendAmount.isZero) return
28
+
29
+ const isValidAddress = await asset.address.validate(destinationAddress)
30
+ if (!isValidAddress) return
31
+
32
+ const walletAccountId = toWalletAccountId(fromWalletAccount)
33
+ if (!walletAccountId) return
34
+
35
+ const fromAddress = await assetClientInterface.getReceiveAddress({
36
+ assetName: asset.name,
37
+ walletAccount: walletAccountId,
38
+ useCache: true,
39
+ })
40
+ if (!fromAddress) return
41
+
42
+ // Match Hypernative's documented native-send payload shape.
43
+ const result = await checkTx({
44
+ chain: asset.chainId,
45
+ fromAddress,
46
+ toAddress: destinationAddress,
47
+ value: sendAmount.toBaseString({ unit: false }),
48
+ input: '0x',
49
+ hash: '0x1',
50
+ })
51
+
52
+ if (result?.action !== 'WARN') return
53
+
54
+ return result.reason || SCAM_RECIPIENT_MESSAGE
55
+ },
56
+ }
57
+
58
+ return [scamRecipientValidator]
59
+ }