@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 +10 -0
- package/package.json +6 -3
- package/src/check-tx/create-check-tx.js +116 -0
- package/src/check-tx/index.js +1 -0
- package/src/create-asset.js +17 -0
- package/src/index.js +1 -0
- package/src/send-validations.js +59 -0
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.
|
|
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": "
|
|
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'
|
package/src/create-asset.js
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|