@exodus/ethereum-api 8.76.3 → 8.76.4
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 +2 -2
- package/src/create-asset.js +1 -1
- package/src/gas-estimation.js +10 -1
- package/src/move-funds.js +56 -23
- package/src/tx-create.js +143 -29
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.4](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.76.3...@exodus/ethereum-api@8.76.4) (2026-06-02)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
* fix(ethereum-api): eliminate send-all dust on HYPE and other forceGasLimitEstimation networks (#7747)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
6
16
|
## [8.76.3](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.76.2...@exodus/ethereum-api@8.76.3) (2026-06-01)
|
|
7
17
|
|
|
8
18
|
**Note:** Version bump only for package @exodus/ethereum-api
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/ethereum-api",
|
|
3
|
-
"version": "8.76.
|
|
3
|
+
"version": "8.76.4",
|
|
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",
|
|
@@ -70,5 +70,5 @@
|
|
|
70
70
|
"type": "git",
|
|
71
71
|
"url": "git+https://github.com/ExodusMovement/assets.git"
|
|
72
72
|
},
|
|
73
|
-
"gitHead": "
|
|
73
|
+
"gitHead": "f0c007138096bf627e21fc9e757637394fe2a5b0"
|
|
74
74
|
}
|
package/src/create-asset.js
CHANGED
package/src/gas-estimation.js
CHANGED
|
@@ -119,6 +119,7 @@ export async function fetchGasLimit({
|
|
|
119
119
|
bip70,
|
|
120
120
|
txType = TX_TYPE_TRANSFER,
|
|
121
121
|
throwOnError = true,
|
|
122
|
+
isSendAll = false,
|
|
122
123
|
}) {
|
|
123
124
|
if (bip70?.bitpay?.data && bip70?.bitpay?.gasPrice) {
|
|
124
125
|
// from on chain stats https://dune.xyz/queries/189123
|
|
@@ -178,7 +179,15 @@ export async function fetchGasLimit({
|
|
|
178
179
|
})
|
|
179
180
|
|
|
180
181
|
const scaledGasLimitEstimate = scaleGasLimitEstimate({ estimatedGasLimit, gasLimitMultiplier })
|
|
181
|
-
if (!isToken)
|
|
182
|
+
if (!isToken) {
|
|
183
|
+
// For native send-all on EOA targets, return the raw `eth_estimateGas`
|
|
184
|
+
// result (no 2x safety pad). Combined with the EIP-1559 tip override
|
|
185
|
+
// applied in tx-create.js (also gated on `isSendAll && !isContract`),
|
|
186
|
+
// this is the no-dust path: gasLimit ≈ gasUsed, so nothing comes back
|
|
187
|
+
// as refund. For contracts we keep the safety pad because contract
|
|
188
|
+
// gas can be nondeterministic against estimates.
|
|
189
|
+
return isSendAll && !isContractTxToAddress ? estimatedGasLimit : scaledGasLimitEstimate
|
|
190
|
+
}
|
|
182
191
|
|
|
183
192
|
// NOTE: If we've enabled `fixGasLimit`s for a token,
|
|
184
193
|
// we need to make sure that transaction we're
|
package/src/move-funds.js
CHANGED
|
@@ -4,6 +4,7 @@ import assert from 'minimalistic-assert'
|
|
|
4
4
|
|
|
5
5
|
import { getNonce, getTokenBalanceFromNode } from './eth-like-util.js'
|
|
6
6
|
import { fetchGasLimit } from './gas-estimation.js'
|
|
7
|
+
import { AmountIncludesFeeUnderflowError } from './tx-create.js'
|
|
7
8
|
|
|
8
9
|
export const moveFundsFactory = ({ baseAssetName, assetClientInterface, createTx, server }) => {
|
|
9
10
|
assert(baseAssetName, 'baseAssetName is required')
|
|
@@ -73,37 +74,69 @@ export const moveFundsFactory = ({ baseAssetName, assetClientInterface, createTx
|
|
|
73
74
|
fromAddress,
|
|
74
75
|
toAddress,
|
|
75
76
|
amount,
|
|
77
|
+
// `isSendAll` and `amountIncludesFee` are no-ops on the token branch
|
|
78
|
+
// (both gated on `!isToken` / `!isEthereumLikeToken(asset)` downstream),
|
|
79
|
+
// so don't pass them. Keeps the API surface honest about which flags
|
|
80
|
+
// actually take effect for each branch.
|
|
81
|
+
...(isToken ? null : { isSendAll: true }),
|
|
76
82
|
})
|
|
77
83
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
let createTxResult
|
|
85
|
+
try {
|
|
86
|
+
createTxResult = await createTx({
|
|
87
|
+
asset,
|
|
88
|
+
walletAccount,
|
|
89
|
+
fromAddress,
|
|
90
|
+
// Native sweep: pass the full balance and let createTx subtract the
|
|
91
|
+
// accurate fee. Token sweep: pass the full token balance unchanged
|
|
92
|
+
// (the native gas fee is paid separately from native balance).
|
|
93
|
+
address: toAddress,
|
|
94
|
+
amount,
|
|
95
|
+
nonce,
|
|
96
|
+
gasLimit,
|
|
97
|
+
gasPrice: feeData.gasPrice,
|
|
98
|
+
// TODO: drop this. Move funds has no fee picker, so it's always
|
|
99
|
+
// `feeData.gasPrice` (same as `gasPrice` above) and a no-op anyway.
|
|
100
|
+
// If we ever need a custom fee, thread it through prepareSendFundsTx.
|
|
101
|
+
customFee: feeData.gasPrice,
|
|
102
|
+
// Both flags are no-ops for tokens (see fetchGasLimit call above).
|
|
103
|
+
...(isToken ? null : { isSendAll: true, amountIncludesFee: true }),
|
|
104
|
+
})
|
|
105
|
+
} catch (err) {
|
|
106
|
+
// createTx throws `AmountIncludesFeeUnderflowError` for the native
|
|
107
|
+
// sweep when the accurate fee (including OP-stack L1 data fee) exceeds
|
|
108
|
+
// the balance we passed in. Translate to the typed MoveFundsError so
|
|
109
|
+
// consumers (mobile/desktop MoveFunds screens) render the right copy.
|
|
110
|
+
if (!isToken && err instanceof AmountIncludesFeeUnderflowError) {
|
|
87
111
|
throw new MoveFundsError('balance-negative', { fromAddress })
|
|
88
112
|
}
|
|
89
113
|
|
|
90
|
-
|
|
114
|
+
throw err
|
|
91
115
|
}
|
|
92
116
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
gasPrice: feeData.gasPrice,
|
|
102
|
-
customFee: feeData.gasPrice,
|
|
103
|
-
isSendAll: true,
|
|
104
|
-
})
|
|
117
|
+
// Token sweep equivalent of the native underflow gate: createTx doesn't
|
|
118
|
+
// validate ETH balance vs gas for token transfers, so check it here using
|
|
119
|
+
// the authoritative fee returned from `calculateFee`. This catches the
|
|
120
|
+
// OP-stack edge case where balance covers `gasPrice * gasLimit` but not
|
|
121
|
+
// the additional L1 data fee.
|
|
122
|
+
if (isToken && ethBalance.sub(createTxResult.fee).isNegative) {
|
|
123
|
+
throw new MoveFundsError('token-fee-insufficient', { fromAddress })
|
|
124
|
+
}
|
|
105
125
|
|
|
106
|
-
|
|
126
|
+
// Trust createTx for both amount and fee. For native sweep `amount` is
|
|
127
|
+
// the post-subtraction value (input balance minus accurate fee). For
|
|
128
|
+
// token sweep `amount` is the input token balance unchanged, since we
|
|
129
|
+
// don't pass `amountIncludesFee`. In both cases `fee` includes the
|
|
130
|
+
// OP-stack L1 data fee from `calculateFee`, which matters more for
|
|
131
|
+
// tokens than for native because ERC-20 calldata is larger.
|
|
132
|
+
return {
|
|
133
|
+
fromAddress,
|
|
134
|
+
toAddress,
|
|
135
|
+
amount: createTxResult.amount,
|
|
136
|
+
fee: createTxResult.fee,
|
|
137
|
+
privateKey,
|
|
138
|
+
unsignedTx: createTxResult.unsignedTx,
|
|
139
|
+
}
|
|
107
140
|
}
|
|
108
141
|
|
|
109
142
|
const sendFunds = async ({ privateKey, unsignedTx }) => {
|
package/src/tx-create.js
CHANGED
|
@@ -20,6 +20,62 @@ import {
|
|
|
20
20
|
TX_TYPE_TRANSFER,
|
|
21
21
|
} from './tx-type/index.js'
|
|
22
22
|
|
|
23
|
+
// Thrown by `resolveAmountIncludesFeeTxAttributes` when the fee subtraction
|
|
24
|
+
// implied by `amountIncludesFee` would drive the resulting tx value below
|
|
25
|
+
// zero (i.e. the caller's balance can't cover the computed fee). Exported so
|
|
26
|
+
// callers like MoveFunds can `instanceof`-check and translate it into their
|
|
27
|
+
// own user-facing error type without string-matching the message.
|
|
28
|
+
export class AmountIncludesFeeUnderflowError extends Error {
|
|
29
|
+
constructor() {
|
|
30
|
+
super('transaction gas cost exceeds fee-inclusive amount')
|
|
31
|
+
this.name = 'AmountIncludesFeeUnderflowError'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function calculateFee({
|
|
36
|
+
asset,
|
|
37
|
+
chainId,
|
|
38
|
+
eip1559Enabled,
|
|
39
|
+
gasLimit,
|
|
40
|
+
gasPrice,
|
|
41
|
+
tipGasPrice,
|
|
42
|
+
nonce,
|
|
43
|
+
txInput,
|
|
44
|
+
txToAddress,
|
|
45
|
+
txValue,
|
|
46
|
+
}) {
|
|
47
|
+
const ethjsTx = createEthereumJsTx({
|
|
48
|
+
txData: {
|
|
49
|
+
nonce,
|
|
50
|
+
gasPrice: currency2buffer(gasPrice),
|
|
51
|
+
tipGasPrice: tipGasPrice ? currency2buffer(tipGasPrice) : undefined,
|
|
52
|
+
gasLimit,
|
|
53
|
+
to: txToAddress,
|
|
54
|
+
value: currency2buffer(txValue),
|
|
55
|
+
data: txInput,
|
|
56
|
+
chainId,
|
|
57
|
+
},
|
|
58
|
+
txMeta: {
|
|
59
|
+
eip1559Enabled,
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
const transactionBuffer = ethjsTx.serialize()
|
|
63
|
+
|
|
64
|
+
const baseFee = gasPrice.mul(gasLimit)
|
|
65
|
+
const optimismL1DataFee = asset.baseAsset.estimateL1DataFee
|
|
66
|
+
? await asset.baseAsset.estimateL1DataFee({
|
|
67
|
+
unsignedTx: { txData: { transactionBuffer, chainId } },
|
|
68
|
+
})
|
|
69
|
+
: undefined
|
|
70
|
+
|
|
71
|
+
const l1DataFee = optimismL1DataFee
|
|
72
|
+
? asset.baseAsset.currency.baseUnit(optimismL1DataFee)
|
|
73
|
+
: asset.baseAsset.currency.ZERO
|
|
74
|
+
|
|
75
|
+
const fee = baseFee.add(l1DataFee)
|
|
76
|
+
return { fee, transactionBuffer }
|
|
77
|
+
}
|
|
78
|
+
|
|
23
79
|
async function createUnsignedTxWithFees({
|
|
24
80
|
asset,
|
|
25
81
|
chainId,
|
|
@@ -57,35 +113,19 @@ async function createUnsignedTxWithFees({
|
|
|
57
113
|
isContractTxToAddress,
|
|
58
114
|
})
|
|
59
115
|
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
txMeta: {
|
|
72
|
-
eip1559Enabled,
|
|
73
|
-
},
|
|
116
|
+
const { fee, transactionBuffer } = await calculateFee({
|
|
117
|
+
asset,
|
|
118
|
+
chainId,
|
|
119
|
+
eip1559Enabled,
|
|
120
|
+
gasLimit,
|
|
121
|
+
gasPrice,
|
|
122
|
+
tipGasPrice,
|
|
123
|
+
nonce,
|
|
124
|
+
txInput,
|
|
125
|
+
txToAddress,
|
|
126
|
+
txValue,
|
|
74
127
|
})
|
|
75
|
-
const transactionBuffer = ethjsTx.serialize()
|
|
76
128
|
|
|
77
|
-
const baseFee = gasPrice.mul(gasLimit)
|
|
78
|
-
const optimismL1DataFee = asset.baseAsset.estimateL1DataFee
|
|
79
|
-
? await asset.baseAsset.estimateL1DataFee({
|
|
80
|
-
unsignedTx: { txData: { transactionBuffer, chainId } },
|
|
81
|
-
})
|
|
82
|
-
: undefined
|
|
83
|
-
|
|
84
|
-
const l1DataFee = optimismL1DataFee
|
|
85
|
-
? asset.baseAsset.currency.baseUnit(optimismL1DataFee)
|
|
86
|
-
: asset.baseAsset.currency.ZERO
|
|
87
|
-
|
|
88
|
-
const fee = baseFee.add(l1DataFee)
|
|
89
129
|
const extraFeeData = getExtraFeeData({ asset, amount, txValue })
|
|
90
130
|
const unsignedTx = {
|
|
91
131
|
txData: { transactionBuffer, chainId },
|
|
@@ -102,6 +142,7 @@ async function createUnsignedTxWithFees({
|
|
|
102
142
|
return {
|
|
103
143
|
unsignedTx,
|
|
104
144
|
fee,
|
|
145
|
+
amount,
|
|
105
146
|
extraFeeData,
|
|
106
147
|
// exhcange compatibility until the use usignedTx, remove me!
|
|
107
148
|
gasPrice,
|
|
@@ -110,6 +151,60 @@ async function createUnsignedTxWithFees({
|
|
|
110
151
|
}
|
|
111
152
|
}
|
|
112
153
|
|
|
154
|
+
// TODO: Move this into tx attribute resolution once gasLimit is resolved there.
|
|
155
|
+
// `amountIncludesFee` changes the tx value, so the fee subtraction should happen
|
|
156
|
+
// in the same flow that finalizes amount, txValue, txInput, txToAddress, and
|
|
157
|
+
// gasLimit.
|
|
158
|
+
async function resolveAmountIncludesFeeTxAttributes({
|
|
159
|
+
amountIncludesFee,
|
|
160
|
+
asset,
|
|
161
|
+
chainId,
|
|
162
|
+
eip1559Enabled,
|
|
163
|
+
gasLimit,
|
|
164
|
+
gasPrice,
|
|
165
|
+
tipGasPrice,
|
|
166
|
+
txAttributes,
|
|
167
|
+
}) {
|
|
168
|
+
const { amount, nonce, txInput, txToAddress, txValue } = txAttributes
|
|
169
|
+
|
|
170
|
+
// `amountIncludesFee` is a hint: subtract any fee that is paid in the same
|
|
171
|
+
// currency as `amount`. On EVM today, gas is paid in the native asset, so
|
|
172
|
+
// for ERC-20 sends and zero-value calldata (i.e approve()) flows there is nothing in the
|
|
173
|
+
// amount's currency to subtract. Pass through silently in those cases, so
|
|
174
|
+
// consumers can set this flag based on intent without needing to know
|
|
175
|
+
// whether the asset is a token or whether the call carries zero native
|
|
176
|
+
// value.
|
|
177
|
+
if (!amountIncludesFee || isEthereumLikeToken(asset) || amount.isZero) {
|
|
178
|
+
return { txAttributes }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const { fee } = await calculateFee({
|
|
182
|
+
asset,
|
|
183
|
+
chainId,
|
|
184
|
+
eip1559Enabled,
|
|
185
|
+
gasLimit,
|
|
186
|
+
gasPrice,
|
|
187
|
+
tipGasPrice,
|
|
188
|
+
nonce,
|
|
189
|
+
txInput,
|
|
190
|
+
txToAddress,
|
|
191
|
+
txValue,
|
|
192
|
+
})
|
|
193
|
+
const reducedAmount = amount.sub(fee)
|
|
194
|
+
|
|
195
|
+
if (!reducedAmount.gte(asset.currency.ZERO)) {
|
|
196
|
+
throw new AmountIncludesFeeUnderflowError()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
txAttributes: {
|
|
201
|
+
...txAttributes,
|
|
202
|
+
amount: reducedAmount,
|
|
203
|
+
txValue: reducedAmount,
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
113
208
|
const resolveTxFactoryGasPrices = async ({
|
|
114
209
|
assetClientInterface,
|
|
115
210
|
baseAsset,
|
|
@@ -384,7 +479,12 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
|
|
|
384
479
|
gasPrice: providedGasPrice,
|
|
385
480
|
bip70,
|
|
386
481
|
customFee: providedCustomFee,
|
|
482
|
+
// Intent flag: the user is trying to empty the wallet. Used for send-all-only
|
|
483
|
+
// dust behavior such as the EIP-1559 tip override.
|
|
387
484
|
isSendAll,
|
|
485
|
+
// Amount semantics flag: the provided amount includes the fee budget, so the
|
|
486
|
+
// final tx value should be reduced by the computed fee.
|
|
487
|
+
amountIncludesFee = false,
|
|
388
488
|
bumpTxId,
|
|
389
489
|
}) => {
|
|
390
490
|
assert(asset, 'asset is required')
|
|
@@ -520,6 +620,7 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
|
|
|
520
620
|
bip70,
|
|
521
621
|
amount: resolvedTxAttributes.amount,
|
|
522
622
|
txType,
|
|
623
|
+
isSendAll,
|
|
523
624
|
}))
|
|
524
625
|
|
|
525
626
|
// HACK: We cannot ensure the no dust invariant for `isSendAll`
|
|
@@ -532,8 +633,21 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
|
|
|
532
633
|
const isSendAllBaseAsset =
|
|
533
634
|
isSendAll && !isEthereumLikeToken(asset) && !resolvedTxAttributes.isContractTxToAddress
|
|
534
635
|
|
|
636
|
+
const finalTipGasPrice = isSendAllBaseAsset && eip1559Enabled ? gasPrice : tipGasPrice
|
|
637
|
+
|
|
638
|
+
const { txAttributes: finalTxAttributes } = await resolveAmountIncludesFeeTxAttributes({
|
|
639
|
+
amountIncludesFee,
|
|
640
|
+
asset,
|
|
641
|
+
chainId,
|
|
642
|
+
eip1559Enabled,
|
|
643
|
+
gasLimit,
|
|
644
|
+
gasPrice,
|
|
645
|
+
tipGasPrice: finalTipGasPrice,
|
|
646
|
+
txAttributes: resolvedTxAttributes,
|
|
647
|
+
})
|
|
648
|
+
|
|
535
649
|
return createUnsignedTxWithFees({
|
|
536
|
-
...
|
|
650
|
+
...finalTxAttributes,
|
|
537
651
|
asset,
|
|
538
652
|
chainId,
|
|
539
653
|
gasLimit,
|
|
@@ -545,7 +659,7 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
|
|
|
545
659
|
// fixed gas cost transaction, no dust balance should remain,
|
|
546
660
|
// since any deviation in the underlying `baseFeePerGas` will
|
|
547
661
|
// result only affect the tip for the miner - no dust remains.
|
|
548
|
-
tipGasPrice:
|
|
662
|
+
tipGasPrice: finalTipGasPrice,
|
|
549
663
|
eip1559Enabled,
|
|
550
664
|
})
|
|
551
665
|
}
|