@exodus/ethereum-api 8.75.0 → 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,26 @@
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
+
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)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+
22
+ * fix: use correct fromAddress for gasLimit multiplication (#8078)
23
+
24
+
25
+
6
26
  ## [8.75.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.74.1...@exodus/ethereum-api@8.75.0) (2026-05-13)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.75.0",
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": "8cf5e6a5fc7d235c2c57ea51219bb1e3469329c5"
73
+ "gitHead": "73ff97a75479c3b25fccc11f31879ec634818d63"
71
74
  }
@@ -120,11 +120,9 @@ export const buildLegacyApproveTx = async ({
120
120
  const baseAsset = asset.baseAsset
121
121
 
122
122
  return {
123
- asset: baseAsset.name,
124
- receiver: {
125
- address: unsignedTx.txData.to,
126
- amount: buffer2currency({ asset: baseAsset, value: unsignedTx.txData.value }),
127
- },
123
+ asset: baseAsset,
124
+ address: unsignedTx.txData.to,
125
+ amount: buffer2currency({ asset: baseAsset, value: unsignedTx.txData.value }),
128
126
  txInput: unsignedTx.txData.data,
129
127
  gasPrice: buffer2currency({ asset: baseAsset, value: unsignedTx.txData.gasPrice }),
130
128
  tipGasPrice: unsignedTx.txData.tipGasPrice
@@ -132,13 +130,12 @@ export const buildLegacyApproveTx = async ({
132
130
  : undefined,
133
131
  gasLimit: bufferToInt(unsignedTx.txData.gasLimit),
134
132
  fromAddress: unsignedTx.txMeta.fromAddress,
135
- silent: true,
136
133
  }
137
134
  }
138
135
 
139
136
  // @deprecated use buildApproveTx instead
140
137
  export const createApprove =
141
- ({ assetClientInterface, sendTx }) =>
138
+ ({ assetClientInterface } = Object.create(null)) =>
142
139
  async ({
143
140
  spenderAddress,
144
141
  asset,
@@ -164,7 +161,16 @@ export const createApprove =
164
161
  walletAccount,
165
162
  })
166
163
 
167
- const txData = await sendTx({ walletAccount, ...approveTx, ...extras })
164
+ const { unsignedTx } = await asset.baseAsset.api.createTx({
165
+ walletAccount,
166
+ ...approveTx,
167
+ ...extras,
168
+ })
169
+ const txData = await asset.baseAsset.api.sendTx({
170
+ asset: asset.baseAsset,
171
+ walletAccount,
172
+ unsignedTx,
173
+ })
168
174
 
169
175
  if (!txData || !txData.txId) {
170
176
  throw new Error(`Failed to approve ${asset.displayTicker} - ${spenderAddress}`)
@@ -185,25 +191,20 @@ export const createApprove =
185
191
  walletAccount,
186
192
  })
187
193
 
188
- const { txId, rawTx } = await assetClientInterface.signTransaction({
189
- assetName: asset.baseAsset.name,
190
- unsignedTx,
191
- walletAccount,
192
- })
194
+ const { txId } = await asset.baseAsset.api.sendTx({ asset, walletAccount, unsignedTx })
193
195
 
194
- await asset.baseAsset.api.broadcastTx(rawTx.toString('hex'))
195
196
  return { txId }
196
197
  }
197
198
 
198
199
  const createSendApprovalAndWatchConfirmation =
199
- ({ sendTx, watchTxConfirmation }) =>
200
+ ({ watchTxConfirmation }) =>
200
201
  async (data) => {
201
202
  // To change the approve amount you first have to reduce the addresses`
202
203
  // allowance to zero by calling `approve(_spender, 0)` if it is not
203
204
  // already 0 to mitigate the race condition described here:
204
205
  // see: https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
205
206
  // see: https://etherscan.io/address/0xdac17f958d2ee523a2206206994597c13d831ec7#code
206
- const approve = createApprove({ sendTx })
207
+ const approve = createApprove()
207
208
  const { txId } = await approve(data)
208
209
  await watchTxConfirmation(
209
210
  { asset: data.asset.baseAsset.name, walletAccount: data.walletAccount },
@@ -215,11 +216,10 @@ const createSendApprovalAndWatchConfirmation =
215
216
  }
216
217
 
217
218
  export const createApproveSpendingTokens =
218
- ({ sendTx, watchTxConfirmation }) =>
219
+ ({ watchTxConfirmation }) =>
219
220
  async (data) => {
220
221
  const txIds = []
221
222
  const sendApprovalAndWatchConfirmation = createSendApprovalAndWatchConfirmation({
222
- sendTx,
223
223
  watchTxConfirmation,
224
224
  })
225
225
  if (ZERO_ALLOWANCE_ASSETS.includes(data.asset.name) && !data.approveAmount.isZero) {
@@ -235,7 +235,7 @@ export const createApproveSpendingTokens =
235
235
  data.gasLimit = Math.max(data.gasLimit || 0, APPROVAL_GAS_LIMIT)
236
236
  }
237
237
 
238
- const tokenApprovalTxId = await sendApprovalAndWatchConfirmation({ ...data, sendTx })
238
+ const tokenApprovalTxId = await sendApprovalAndWatchConfirmation(data)
239
239
  txIds.push(tokenApprovalTxId)
240
240
 
241
241
  return txIds
@@ -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'
@@ -12,7 +12,9 @@ import { ClarityMonitorV2 } from './tx-log/clarity-monitor-v2.js'
12
12
  import { ClarityTruncatedHistoryMonitor } from './tx-log/clarity-truncated-history-monitor.js'
13
13
  import { EthereumMonitor } from './tx-log/ethereum-monitor.js'
14
14
  import { EthereumNoHistoryMonitor } from './tx-log/ethereum-no-history-monitor.js'
15
+ import { getOptimisticTxLogEffects } from './tx-log/get-optimistic-txlog-effects.js'
15
16
  import { BLOCK_TAG_LATEST, BLOCK_TAG_PENDING, resolveNonce } from './tx-send/nonce-utils.js'
17
+ import { signTx } from './tx-sign/index.js'
16
18
 
17
19
  // Determines the appropriate `monitorType`, `serverUrl` and `monitorInterval`
18
20
  // to use for a given config.
@@ -121,7 +123,126 @@ const broadcastPrivateBundleFactory =
121
123
  return null
122
124
  }
123
125
 
124
- export const createTransactionPrivacyFactory = ({ assetName, privacyRpcUrl }) => {
126
+ const assertSendPrivateTxProps = ({ asset, unsignedTx, walletAccount }) => {
127
+ assert(asset, 'expected asset')
128
+ assert(unsignedTx, 'expected unsignedTx')
129
+ assert(walletAccount, 'expected walletAccount')
130
+ }
131
+
132
+ export const createTransactionPrivacyResult = ({
133
+ assetClientInterface,
134
+ broadcastPrivateBundle,
135
+ broadcastPrivateTx,
136
+ privacyServer,
137
+ }) => {
138
+ assert(assetClientInterface, 'expected assetClientInterface')
139
+ assert(typeof broadcastPrivateBundle === 'function')
140
+ assert(typeof broadcastPrivateTx === 'function')
141
+ assert(privacyServer, 'expected privacyServer')
142
+
143
+ const sendPrivateTx = async ({ asset, unsignedTx, walletAccount }) => {
144
+ assertSendPrivateTxProps({ asset, unsignedTx, walletAccount })
145
+
146
+ const { rawTx, txId } = await signTx({
147
+ asset,
148
+ assetClientInterface,
149
+ unsignedTx,
150
+ walletAccount,
151
+ })
152
+
153
+ await broadcastPrivateTx(rawTx.toString('hex'))
154
+
155
+ const fromAddress = unsignedTx.txMeta?.fromAddress
156
+ assert(typeof fromAddress === 'string', 'expected fromAddress')
157
+
158
+ const { nonce, optimisticTxLogEffects } = await getOptimisticTxLogEffects({
159
+ asset,
160
+ assetClientInterface,
161
+ fromAddress,
162
+ txId,
163
+ unsignedTx,
164
+ walletAccount,
165
+ })
166
+
167
+ for (const optimisticTxLogEffect of optimisticTxLogEffects) {
168
+ await assetClientInterface.updateTxLogAndNotify(optimisticTxLogEffect)
169
+ }
170
+
171
+ return { nonce, txId }
172
+ }
173
+
174
+ const sendPrivateBundle = async ({ unsignedTxBundle }) => {
175
+ assert(
176
+ Array.isArray(unsignedTxBundle) && unsignedTxBundle.length > 0,
177
+ 'expected non-empty unsignedTxBundle'
178
+ )
179
+
180
+ unsignedTxBundle.forEach(assertSendPrivateTxProps)
181
+
182
+ const signTxResults = await Promise.all(
183
+ unsignedTxBundle.map(({ asset, unsignedTx, walletAccount }) =>
184
+ signTx({ assetClientInterface, asset, unsignedTx, walletAccount })
185
+ )
186
+ )
187
+
188
+ const maybeBundleResult = await broadcastPrivateBundle({
189
+ txs: signTxResults.map(({ rawTx }) => rawTx.toString('hex')),
190
+ })
191
+
192
+ const bundleHash = maybeBundleResult?.bundleHash || undefined
193
+
194
+ const getOptimisticTxLogEffectsResults = []
195
+
196
+ for (const [i, { txId }] of signTxResults.entries()) {
197
+ const { asset, unsignedTx, walletAccount } = unsignedTxBundle[i]
198
+
199
+ const fromAddress = unsignedTx.txMeta?.fromAddress
200
+ assert(typeof fromAddress === 'string', 'expected fromAddress')
201
+
202
+ void getOptimisticTxLogEffectsResults.push(
203
+ await getOptimisticTxLogEffects({
204
+ asset,
205
+ assetClientInterface,
206
+ fromAddress,
207
+ txId,
208
+ unsignedTx,
209
+ walletAccount,
210
+ // TODO: use consistent naming
211
+ bundleId: bundleHash,
212
+ })
213
+ )
214
+ }
215
+
216
+ const optimisticTxLogEffects = getOptimisticTxLogEffectsResults.flatMap(
217
+ ({ optimisticTxLogEffects }) => optimisticTxLogEffects
218
+ )
219
+
220
+ for (const optimisticTxLogEffect of optimisticTxLogEffects) {
221
+ await assetClientInterface.updateTxLogAndNotify(optimisticTxLogEffect)
222
+ }
223
+
224
+ return {
225
+ bundleHash,
226
+ txIds: signTxResults.map(({ txId }) => txId),
227
+ }
228
+ }
229
+
230
+ return {
231
+ broadcastPrivateBundle,
232
+ broadcastPrivateTx,
233
+ privacyServer,
234
+ sendPrivateTx,
235
+ sendPrivateBundle,
236
+ }
237
+ }
238
+
239
+ export const createTransactionPrivacyFactory = ({
240
+ assetClientInterface,
241
+ assetName,
242
+ privacyRpcUrl,
243
+ }) => {
244
+ assert(assetClientInterface, 'expected assetClientInterface')
245
+
125
246
  if (!privacyRpcUrl) return Object.create(null)
126
247
 
127
248
  const privacyServer = createEvmServer({
@@ -130,11 +251,16 @@ export const createTransactionPrivacyFactory = ({ assetName, privacyRpcUrl }) =>
130
251
  monitorType: 'no-history',
131
252
  })
132
253
 
133
- return {
134
- broadcastPrivateBundle: broadcastPrivateBundleFactory({ privacyServer }),
135
- broadcastPrivateTx: (...args) => privacyServer.sendRawTransaction(...args),
254
+ const broadcastPrivateBundle = broadcastPrivateBundleFactory({ privacyServer })
255
+
256
+ const broadcastPrivateTx = (...args) => privacyServer.sendRawTransaction(...args)
257
+
258
+ return createTransactionPrivacyResult({
259
+ assetClientInterface,
260
+ broadcastPrivateBundle,
261
+ broadcastPrivateTx,
136
262
  privacyServer,
137
- }
263
+ })
138
264
  }
139
265
 
140
266
  export const createHistoryMonitorFactory = ({
@@ -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]
@@ -195,11 +200,17 @@ export const createAssetFactory = ({
195
200
  server,
196
201
  })
197
202
 
198
- const { broadcastPrivateBundle, broadcastPrivateTx, privacyServer } =
199
- createTransactionPrivacyFactory({
200
- assetName: asset.name,
201
- privacyRpcUrl,
202
- })
203
+ const {
204
+ broadcastPrivateBundle,
205
+ broadcastPrivateTx,
206
+ privacyServer,
207
+ sendPrivateTx,
208
+ sendPrivateBundle,
209
+ } = createTransactionPrivacyFactory({
210
+ assetClientInterface,
211
+ assetName: asset.name,
212
+ privacyRpcUrl,
213
+ })
203
214
 
204
215
  const features = {
205
216
  accountState: true,
@@ -283,6 +294,16 @@ export const createAssetFactory = ({
283
294
 
284
295
  const securityChecks = createSecurityChecks({ eip7702Supported })
285
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
+
286
307
  const moveFunds = moveFundsFactory({
287
308
  baseAssetName: asset.name,
288
309
  assetClientInterface,
@@ -318,6 +339,7 @@ export const createAssetFactory = ({
318
339
  assetName: asset.name,
319
340
  }),
320
341
  getSupportedPurposes: () => [44],
342
+ ...(sendValidations.length > 0 && { getSendValidations: () => sendValidations }),
321
343
  getTokens,
322
344
  hasFeature: (feature) => !!features[feature], // @deprecated use api.features instead
323
345
  moveFunds,
@@ -330,6 +352,7 @@ export const createAssetFactory = ({
330
352
  signHardware: signHardwareFactory({ baseAssetName: asset.name }),
331
353
  signMessage: ({ message, privateKey, signer }) =>
332
354
  signer ? signMessageWithSigner({ message, signer }) : signMessage({ privateKey, message }),
355
+ ...(checkTx && { checkTx }),
333
356
  ...(supportsStaking &&
334
357
  stakingDependencies[asset.name] && {
335
358
  staking: stakingApiFactory({
@@ -354,16 +377,18 @@ export const createAssetFactory = ({
354
377
  chainId,
355
378
  monitorType,
356
379
  estimateL1DataFee,
357
- broadcastPrivateBundle,
358
- broadcastPrivateTx,
359
380
  forceGasLimitEstimation,
360
381
  eip7702Supported,
361
382
  getEIP7702Delegation: (addr) => getEIP7702Delegation({ address: addr, server }),
362
383
  getNonce,
363
- privacyServer,
364
384
  server,
365
385
  ...(erc20FuelBuffer && { erc20FuelBuffer }),
366
386
  ...(fuelThreshold && { fuelThreshold: asset.currency.defaultUnit(fuelThreshold) }),
387
+ broadcastPrivateBundle,
388
+ broadcastPrivateTx,
389
+ privacyServer,
390
+ sendPrivateTx,
391
+ sendPrivateBundle,
367
392
  }
368
393
  return overrideCallback({
369
394
  asset: fullAsset,
@@ -157,7 +157,7 @@ export async function fetchGasLimit({
157
157
  const gasLimitMultiplier = await resolveGasLimitMultiplier({
158
158
  asset,
159
159
  feeData,
160
- fromAddress: providedFromAddress,
160
+ fromAddress,
161
161
  toAddress: txToAddress,
162
162
  })
163
163
 
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
+ }
@@ -66,7 +66,6 @@ import NumberUnit from '@exodus/currency'
66
66
  import assert from 'minimalistic-assert'
67
67
 
68
68
  import { estimateGasLimit, scaleGasLimitEstimate } from '../../gas-estimation.js'
69
- import { getOptimisticTxLogEffects } from '../../tx-log/get-optimistic-txlog-effects.js'
70
69
  import { createWatchTx as defaultCreateWatch } from '../../watch-tx.js'
71
70
  import { stakingProviderClientFactory } from '../staking-provider-client.js'
72
71
  import { amountToCurrency, DISABLE_BALANCE_CHECKS, resolveFeeData } from '../utils/index.js'
@@ -281,8 +280,6 @@ export function createEthereumStakingService({
281
280
  asset,
282
281
  fromAddress: delegatorAddress,
283
282
  walletAccount,
284
- tag: 'latest',
285
- forceFromNode: true,
286
283
  })
287
284
 
288
285
  return {
@@ -369,14 +366,12 @@ export function createEthereumStakingService({
369
366
  plan: null,
370
367
  gasLimit: null,
371
368
  unsignedTx: null,
372
- signedTx: null,
373
369
  txId: null,
374
370
  },
375
371
  undelegate: {
376
372
  plan: null,
377
373
  gasLimit: null,
378
374
  unsignedTx: null,
379
- signedTx: null,
380
375
  txId: null,
381
376
  },
382
377
  }
@@ -465,34 +460,16 @@ export function createEthereumStakingService({
465
460
  revertOnSimulationError,
466
461
  })
467
462
 
468
- // 4. Sign transactions
469
- const signedTxEntries = await Promise.all(
470
- Object.entries(txSteps)
471
- .filter(([, txStep]) => txStep.unsignedTx)
472
- .map(async ([key, txStep]) => {
473
- const signedTx = await assetClientInterface.signTransaction({
474
- assetName: asset.baseAsset.name,
475
- unsignedTx: txStep.unsignedTx,
476
- walletAccount,
477
- })
478
- const txId = `0x${signedTx.txId.toString('hex')}`
479
- return [key, { signedTx, txId }]
480
- })
481
- )
482
-
483
- for (const [key, { signedTx, txId }] of signedTxEntries) {
484
- txSteps[key].signedTx = signedTx
485
- txSteps[key].txId = txId
486
- }
463
+ const unsignedTxBundle = createdTxEntries.map(([_, unsignedTx]) => ({
464
+ asset,
465
+ unsignedTx,
466
+ walletAccount,
467
+ }))
487
468
 
469
+ // 4. Sign transactions
470
+ //
488
471
  // 5. Broadcast transactions via bundle and get bundle hash
489
- const bundleResponse = await asset.broadcastPrivateBundle({
490
- txs: Object.values(txSteps)
491
- .filter((txStep) => txStep.signedTx)
492
- .map(({ signedTx }) => signedTx.rawTx),
493
- })
494
- const bundleHash = bundleResponse?.bundleHash
495
-
472
+ //
496
473
  // 6. Optimistic tx-log effects: reflect tx presence and fee consumption only.
497
474
  // The ETH returned to the user via the contract's internal transfer is not
498
475
  // captured here — it will be picked up once the tx is confirmed on-chain.
@@ -518,26 +495,14 @@ export function createEthereumStakingService({
518
495
  // That is incompatible with the current tx-log side-effect hook, which only
519
496
  // receives tx-local data (method id, calldata, nonce, gas, etc.) and not
520
497
  // staking snapshots or precomputed ETH-return amounts.
521
- const optimisticEffects = await Promise.all(
522
- Object.values(txSteps)
523
- .filter((txStep) => txStep.txId)
524
- .map((txStep) =>
525
- getOptimisticTxLogEffects({
526
- asset: asset.baseAsset,
527
- assetClientInterface,
528
- fromAddress: delegatorAddress,
529
- txId: txStep.txId,
530
- unsignedTx: txStep.unsignedTx,
531
- walletAccount,
532
- bundleId: bundleHash ?? undefined,
533
- })
534
- )
535
- )
498
+ const {
499
+ bundleHash,
500
+ txIds: [...txIds],
501
+ } = await asset.baseAsset.sendPrivateBundle({ unsignedTxBundle })
536
502
 
537
- for (const { optimisticTxLogEffects } of optimisticEffects) {
538
- for (const effect of optimisticTxLogEffects) {
539
- await assetClientInterface.updateTxLogAndNotify(effect)
540
- }
503
+ for (const txStep of Object.values(txSteps)) {
504
+ if (!txStep.unsignedTx) continue
505
+ assert((txStep.txId = txIds.shift()), 'expected txId')
541
506
  }
542
507
 
543
508
  // Testnet assets do not support delegations tracking
@@ -770,7 +735,7 @@ export function createEthereumStakingService({
770
735
  waitForConfirmation = false,
771
736
  feeData,
772
737
  }) {
773
- const sendTxArgs = {
738
+ const { unsignedTx } = await asset.baseAsset.api.createTx({
774
739
  asset,
775
740
  walletAccount,
776
741
  address: to,
@@ -781,9 +746,9 @@ export function createEthereumStakingService({
781
746
  // HACK: Override the `tipGasPrice` to use a custom `maxPriorityFeePerGas`.
782
747
  tipGasPrice,
783
748
  feeData,
784
- }
749
+ })
785
750
 
786
- const { txId } = await asset.baseAsset.api.sendTx(sendTxArgs)
751
+ const { txId } = await asset.baseAsset.api.sendTx({ asset, walletAccount, unsignedTx })
787
752
 
788
753
  const baseAsset = asset.baseAsset
789
754
  if (waitForConfirmation) {
@@ -1,8 +1,9 @@
1
- import { normalizeTxId, parseUnsignedTx, updateNonce } from '@exodus/ethereum-lib'
1
+ import { parseUnsignedTx, updateNonce } from '@exodus/ethereum-lib'
2
2
  import { safeString } from '@exodus/safe-string'
3
3
  import assert from 'minimalistic-assert'
4
4
 
5
5
  import { getOptimisticTxLogEffects } from '../tx-log/index.js'
6
+ import { signTx } from '../tx-sign/index.js'
6
7
  import { ARBITRARY_ADDRESS } from '../tx-type/index.js'
7
8
  import { handleBroadcastError } from './broadcast-error-handler.js'
8
9
 
@@ -10,16 +11,6 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
10
11
  assert(assetClientInterface, 'assetClientInterface is required')
11
12
  assert(createTx, 'createTx is required')
12
13
 
13
- async function signTx({ asset, unsignedTx, walletAccount }) {
14
- const { rawTx, txId } = await assetClientInterface.signTransaction({
15
- assetName: asset.baseAsset.name,
16
- unsignedTx,
17
- walletAccount,
18
- })
19
-
20
- return { rawTx, txId: normalizeTxId(txId) }
21
- }
22
-
23
14
  return async ({ asset, walletAccount, unsignedTx: providedUnsignedTx, ...legacyParams }) => {
24
15
  const baseAsset = asset.baseAsset
25
16
 
@@ -41,6 +32,7 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
41
32
  // Are there any signin-level errors that should be caught here?
42
33
  let { txId, rawTx } = await signTx({
43
34
  asset,
35
+ assetClientInterface,
44
36
  unsignedTx,
45
37
  walletAccount,
46
38
  })
@@ -92,7 +84,12 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
92
84
  unsignedTx.txData.nonce = newNonce
93
85
  }
94
86
 
95
- ;({ txId, rawTx } = await signTx({ asset, unsignedTx, walletAccount }))
87
+ ;({ txId, rawTx } = await signTx({
88
+ asset,
89
+ assetClientInterface,
90
+ unsignedTx,
91
+ walletAccount,
92
+ }))
96
93
 
97
94
  try {
98
95
  await baseAsset.api.broadcastTx(rawTx.toString('hex'))
@@ -0,0 +1,17 @@
1
+ import { normalizeTxId } from '@exodus/ethereum-lib'
2
+ import assert from 'minimalistic-assert'
3
+
4
+ export const signTx = async ({ asset, assetClientInterface, unsignedTx, walletAccount }) => {
5
+ assert(asset, 'expected asset')
6
+ assert(assetClientInterface, 'expected assetClientInterface')
7
+ assert(unsignedTx, 'expected unsignedTx')
8
+ assert(walletAccount, 'expected walletAccount')
9
+
10
+ const { rawTx, txId } = await assetClientInterface.signTransaction({
11
+ assetName: asset.baseAsset.name,
12
+ unsignedTx,
13
+ walletAccount,
14
+ })
15
+
16
+ return { rawTx, txId: normalizeTxId(txId) }
17
+ }