@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 +20 -0
- package/package.json +6 -3
- package/src/allowance/index.js +19 -19
- package/src/check-tx/create-check-tx.js +116 -0
- package/src/check-tx/index.js +1 -0
- package/src/create-asset-utils.js +131 -5
- package/src/create-asset.js +33 -8
- package/src/gas-estimation.js +1 -1
- package/src/index.js +1 -0
- package/src/send-validations.js +59 -0
- package/src/staking/ethereum/service.js +18 -53
- package/src/tx-send/tx-send.js +9 -12
- package/src/tx-sign/index.js +17 -0
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.
|
|
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
|
}
|
package/src/allowance/index.js
CHANGED
|
@@ -120,11 +120,9 @@ export const buildLegacyApproveTx = async ({
|
|
|
120
120
|
const baseAsset = asset.baseAsset
|
|
121
121
|
|
|
122
122
|
return {
|
|
123
|
-
asset: baseAsset
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
({
|
|
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(
|
|
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
|
-
({
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 = ({
|
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]
|
|
@@ -195,11 +200,17 @@ export const createAssetFactory = ({
|
|
|
195
200
|
server,
|
|
196
201
|
})
|
|
197
202
|
|
|
198
|
-
const {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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,
|
package/src/gas-estimation.js
CHANGED
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
|
+
}
|
|
@@ -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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
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
|
|
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(
|
|
751
|
+
const { txId } = await asset.baseAsset.api.sendTx({ asset, walletAccount, unsignedTx })
|
|
787
752
|
|
|
788
753
|
const baseAsset = asset.baseAsset
|
|
789
754
|
if (waitForConfirmation) {
|
package/src/tx-send/tx-send.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
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({
|
|
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
|
+
}
|