fix: count the network fee in the confirm-screen balance check (closes #154)
Some checks failed
check / check (push) Has been cancelled
Some checks failed
check / check (push) Has been cancelled
The Send button was enabled whenever the amount alone fit the balance, so a max-value ETH send passed the confirmation screen and failed at broadcast, after the user had committed to it. The arithmetic moves into src/shared/txValidation.js as a pure function over 18-decimal fixed point: native ETH now requires amount + fee <= balance, and an ERC-20 transfer requires the ETH balance to cover the fee on top of the token check, reported as its own error. Validation re-runs when the async estimate resolves; Send stays disabled while the estimate is pending and when it fails, so an unknown fee is never treated as zero. The fee messages are static elements that already reserve their space, so nothing moves when the estimate lands. The fee reserved is the one the node will actually require. The send pins no fee fields, so ethers broadcasts a type-2 transaction and the node validates it against value + gasLimit * maxFeePerGas; gating on gasPrice would under-reserve by roughly gasLimit * baseFee and let through exactly the broadcast failure this change exists to prevent. feeReserveWei() derives that reserve, falling back to gasPrice only where no type-2 pricing exists. The reserve is read when the screen opens and the broadcast derives its own, so a base fee that roughly doubles while the user is at the password field can still outrun it: this turns a deterministic failure on every max-value send into a rare one, not into none. The fee line shows both numbers rather than one - what the transfer is expected to cost, and below it the larger amount reserved until it confirms. Quoting only the reserve overstates the typical mainnet cost by roughly double on every send; quoting only the estimate contradicts the gate. The second line holds its space from the first paint. validateTransfer() fails closed: a feeWei that is not a non-negative bigint under FEE_KNOWN, any unrecognised feeStatus, and a negative amount all block, rather than counting as a fee of zero or as an amount that passes every comparison trivially.
This commit is contained in:
@@ -582,10 +582,18 @@
|
||||
<div id="confirm-balance" class="text-xs"></div>
|
||||
</div>
|
||||
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
|
||||
<div class="text-xs text-muted mb-1">
|
||||
Estimated network fee
|
||||
</div>
|
||||
<div class="text-xs text-muted mb-1">Network fee</div>
|
||||
<div id="confirm-fee-amount" class="text-xs"></div>
|
||||
<!-- Holds its one line of space from the first paint, so
|
||||
the reserve appearing when the estimate lands moves
|
||||
nothing. The placeholder is never seen. -->
|
||||
<div
|
||||
id="confirm-fee-reserve"
|
||||
class="text-xs text-muted"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
reserve pending
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="confirm-warnings"
|
||||
@@ -647,6 +655,31 @@
|
||||
class="mb-2 border border-border border-dashed p-2"
|
||||
style="visibility: hidden; min-height: 1.25rem"
|
||||
></div>
|
||||
<div
|
||||
id="confirm-amount-fee-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
Your balance does not cover this amount plus the network
|
||||
fee. Please go back and send a smaller amount.
|
||||
</div>
|
||||
<div
|
||||
id="confirm-gas-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
You do not have enough ETH to pay the network fee for this
|
||||
transfer. Please add ETH to this address and try again.
|
||||
</div>
|
||||
<div
|
||||
id="confirm-fee-unknown-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
The network fee could not be estimated, so this transaction
|
||||
cannot be checked against your balance. Please go back and
|
||||
try again.
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block mb-1 text-xs">Password</label>
|
||||
<input
|
||||
|
||||
@@ -32,11 +32,24 @@ const {
|
||||
getFullWarnings,
|
||||
} = require("../../shared/addressWarnings");
|
||||
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
|
||||
const {
|
||||
CODES,
|
||||
FEE_PENDING,
|
||||
FEE_KNOWN,
|
||||
FEE_UNAVAILABLE,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
validateTransfer,
|
||||
} = require("../../shared/txValidation");
|
||||
const { log } = require("../../shared/log");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const txStatus = require("./txStatus");
|
||||
|
||||
let pendingTx = null;
|
||||
// Network fee for the transaction currently on screen. Reset by show() and
|
||||
// filled in by estimateGas() when the estimate resolves or fails.
|
||||
let feeStatus = FEE_PENDING;
|
||||
let feeWei = null;
|
||||
|
||||
function restore() {
|
||||
const d = state.viewData;
|
||||
@@ -67,6 +80,8 @@ function valueWithUsd(text, usdAmount) {
|
||||
|
||||
function show(txInfo) {
|
||||
pendingTx = txInfo;
|
||||
feeStatus = FEE_PENDING;
|
||||
feeWei = null;
|
||||
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
||||
@@ -153,50 +168,14 @@ function show(txInfo) {
|
||||
warningsEl.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
const errors = [];
|
||||
if (isErc20) {
|
||||
const tokenBal = parseFloat(txInfo.tokenBalance || "0");
|
||||
if (parseFloat(txInfo.amount) > tokenBal) {
|
||||
errors.push(
|
||||
"Insufficient " +
|
||||
symbol +
|
||||
" balance. You have " +
|
||||
txInfo.tokenBalance +
|
||||
" " +
|
||||
symbol +
|
||||
" but are trying to send " +
|
||||
txInfo.amount +
|
||||
" " +
|
||||
symbol +
|
||||
".",
|
||||
);
|
||||
}
|
||||
} else if (parseFloat(txInfo.amount) > parseFloat(txInfo.balance)) {
|
||||
errors.push(
|
||||
"Insufficient balance. You have " +
|
||||
txInfo.balance +
|
||||
" ETH but are trying to send " +
|
||||
txInfo.amount +
|
||||
" ETH.",
|
||||
);
|
||||
}
|
||||
// The two fee messages are mutually exclusive per transaction type, and
|
||||
// the type is known here, before the first paint. Drop the one that can
|
||||
// never apply and reserve the space of the one that can, so the async
|
||||
// estimate landing later never moves anything.
|
||||
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
||||
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
||||
|
||||
const errorsEl = $("confirm-errors");
|
||||
const sendBtn = $("btn-confirm-send");
|
||||
if (errors.length > 0) {
|
||||
errorsEl.innerHTML = errors
|
||||
.map((e) => `<div class="text-xs">${e}</div>`)
|
||||
.join("");
|
||||
errorsEl.style.visibility = "visible";
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.classList.add("text-muted");
|
||||
} else {
|
||||
errorsEl.innerHTML = "";
|
||||
errorsEl.style.visibility = "hidden";
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.classList.remove("text-muted");
|
||||
}
|
||||
renderValidation(txInfo);
|
||||
|
||||
// Reset password field and error
|
||||
$("confirm-tx-password").value = "";
|
||||
@@ -205,6 +184,7 @@ function show(txInfo) {
|
||||
// Gas estimate — show placeholder then fetch async
|
||||
$("confirm-fee").style.visibility = "visible";
|
||||
$("confirm-fee-amount").textContent = "Estimating...";
|
||||
setVisible("confirm-fee-reserve", false);
|
||||
state.viewData = { pendingTx: txInfo };
|
||||
showView("confirm-tx");
|
||||
attachCopyHandlers("view-confirm-tx");
|
||||
@@ -224,11 +204,101 @@ function show(txInfo) {
|
||||
checkRecipientHistory(txInfo);
|
||||
}
|
||||
|
||||
// Render the balance check for the transaction on screen. Called once during
|
||||
// show() and again when the fee estimate resolves or fails. Every element it
|
||||
// touches already occupies its space, so re-running it never moves anything.
|
||||
function renderValidation(txInfo) {
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
||||
|
||||
const { canSend, codes } = validateTransfer({
|
||||
isErc20,
|
||||
amount: txInfo.amount,
|
||||
ethBalance: txInfo.balance,
|
||||
tokenBalance: txInfo.tokenBalance,
|
||||
feeStatus,
|
||||
feeWei,
|
||||
});
|
||||
|
||||
// Messages carrying the user's own numbers are built here; the fixed
|
||||
// sentences live in the reserved elements in index.html.
|
||||
const messages = [];
|
||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||
messages.push("Please enter a valid amount to send.");
|
||||
}
|
||||
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
|
||||
messages.push(
|
||||
"Insufficient " +
|
||||
symbol +
|
||||
" balance. You have " +
|
||||
txInfo.tokenBalance +
|
||||
" " +
|
||||
symbol +
|
||||
" but are trying to send " +
|
||||
txInfo.amount +
|
||||
" " +
|
||||
symbol +
|
||||
".",
|
||||
);
|
||||
}
|
||||
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
||||
messages.push(
|
||||
"Insufficient balance. You have " +
|
||||
txInfo.balance +
|
||||
" ETH but are trying to send " +
|
||||
txInfo.amount +
|
||||
" ETH.",
|
||||
);
|
||||
}
|
||||
|
||||
const errorsEl = $("confirm-errors");
|
||||
if (messages.length > 0) {
|
||||
errorsEl.innerHTML = messages
|
||||
.map((m) => `<div class="text-xs">${escapeHtml(m)}</div>`)
|
||||
.join("");
|
||||
errorsEl.style.visibility = "visible";
|
||||
} else {
|
||||
errorsEl.innerHTML = "";
|
||||
errorsEl.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
setVisible(
|
||||
"confirm-amount-fee-error",
|
||||
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
|
||||
);
|
||||
setVisible(
|
||||
"confirm-gas-error",
|
||||
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
|
||||
);
|
||||
setVisible(
|
||||
"confirm-fee-unknown-error",
|
||||
codes.includes(CODES.FEE_UNAVAILABLE),
|
||||
);
|
||||
|
||||
// While the estimate is in flight there is no error to show — the fee
|
||||
// line already reads "Estimating..." — but sending stays blocked so a
|
||||
// transaction the fee would break cannot be signed in the meantime.
|
||||
const sendBtn = $("btn-confirm-send");
|
||||
sendBtn.disabled = !canSend;
|
||||
sendBtn.classList.toggle("text-muted", !canSend);
|
||||
}
|
||||
|
||||
function setVisible(id, visible) {
|
||||
$(id).style.visibility = visible ? "visible" : "hidden";
|
||||
}
|
||||
|
||||
// A fee in wei as an ETH string, truncated to 6 decimal places.
|
||||
function formatFeeEth(wei) {
|
||||
const parts = formatEther(wei).split(".");
|
||||
const dec =
|
||||
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
|
||||
return parts[0] + "." + dec + " ETH";
|
||||
}
|
||||
|
||||
async function estimateGas(txInfo) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const feeData = await provider.getFeeData();
|
||||
const gasPrice = feeData.gasPrice;
|
||||
let gasLimit;
|
||||
|
||||
if (txInfo.token === "ETH") {
|
||||
@@ -246,21 +316,54 @@ async function estimateGas(txInfo) {
|
||||
});
|
||||
}
|
||||
|
||||
const gasCostWei = gasLimit * gasPrice;
|
||||
const gasCostEth = formatEther(gasCostWei);
|
||||
// Format to 6 significant decimal places
|
||||
const parts = gasCostEth.split(".");
|
||||
const dec =
|
||||
parts.length > 1
|
||||
? parts[1].slice(0, 6).replace(/0+$/, "") || "0"
|
||||
: "0";
|
||||
const feeStr = parts[0] + "." + dec + " ETH";
|
||||
// What the node will require to be reserved, which is what the gate
|
||||
// must be: the send pins no fee fields, so it is broadcast as a
|
||||
// type-2 transaction priced at maxFeePerGas.
|
||||
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
||||
if (gasCostWei === null) {
|
||||
throw new Error("no usable gas price from the provider");
|
||||
}
|
||||
// What the transaction is expected to cost, which is a different and
|
||||
// usually much smaller number. Both are shown: quoting only the
|
||||
// reserve overstates the typical cost by roughly double on mainnet,
|
||||
// and quoting only the estimate contradicts the balance check.
|
||||
const estimateWei = feeEstimateWei(gasLimit, feeData);
|
||||
// The user may have left this transaction while the estimate was in
|
||||
// flight; a stale fee must not reach the screen or the balance check.
|
||||
if (pendingTx !== txInfo) return;
|
||||
|
||||
const ethPrice = getPrice("ETH");
|
||||
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null;
|
||||
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd);
|
||||
const usd = (wei) =>
|
||||
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
|
||||
|
||||
if (estimateWei !== null && estimateWei < gasCostWei) {
|
||||
$("confirm-fee-amount").textContent = valueWithUsd(
|
||||
"~" + formatFeeEth(estimateWei),
|
||||
usd(estimateWei),
|
||||
);
|
||||
$("confirm-fee-reserve").textContent =
|
||||
"up to " + formatFeeEth(gasCostWei) + " reserved";
|
||||
setVisible("confirm-fee-reserve", true);
|
||||
} else {
|
||||
// No spread to report: a network with no type-2 pricing charges
|
||||
// exactly what is reserved.
|
||||
$("confirm-fee-amount").textContent = valueWithUsd(
|
||||
formatFeeEth(gasCostWei),
|
||||
usd(gasCostWei),
|
||||
);
|
||||
setVisible("confirm-fee-reserve", false);
|
||||
}
|
||||
feeStatus = FEE_KNOWN;
|
||||
feeWei = gasCostWei;
|
||||
renderValidation(txInfo);
|
||||
} catch (e) {
|
||||
log.errorf("gas estimation failed:", e.message);
|
||||
if (pendingTx !== txInfo) return;
|
||||
$("confirm-fee-amount").textContent = "Unable to estimate";
|
||||
setVisible("confirm-fee-reserve", false);
|
||||
feeStatus = FEE_UNAVAILABLE;
|
||||
feeWei = null;
|
||||
renderValidation(txInfo);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
171
src/shared/txValidation.js
Normal file
171
src/shared/txValidation.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// Balance arithmetic for the transaction confirmation screen.
|
||||
//
|
||||
// Pure: no DOM, no network, no state. Everything is exact integer math on
|
||||
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
|
||||
// instead of through the confirmation view. The caller maps the returned
|
||||
// codes to the reserved message elements on the screen.
|
||||
//
|
||||
// Human decimal strings ("1.25") are scaled to 18 decimals for comparison.
|
||||
// That scale is independent of a token's own decimals: both the amount and
|
||||
// the token balance arrive as human decimal strings, so comparing them at a
|
||||
// common scale is exact.
|
||||
|
||||
const { parseUnits } = require("ethers");
|
||||
|
||||
const SCALE_DECIMALS = 18;
|
||||
|
||||
// Whether the asynchronous fee estimate has arrived yet.
|
||||
const FEE_PENDING = "pending";
|
||||
const FEE_KNOWN = "known";
|
||||
const FEE_UNAVAILABLE = "unavailable";
|
||||
|
||||
const CODES = {
|
||||
// The amount is not a non-negative number we can do exact arithmetic on.
|
||||
AMOUNT_INVALID: "amount-invalid",
|
||||
// ERC-20: the token amount exceeds the token balance.
|
||||
INSUFFICIENT_TOKEN: "insufficient-token",
|
||||
// ETH: the amount alone already exceeds the ETH balance.
|
||||
INSUFFICIENT_ETH: "insufficient-eth",
|
||||
// ETH: the amount fits, the amount plus the network fee does not.
|
||||
INSUFFICIENT_ETH_WITH_FEE: "insufficient-eth-with-fee",
|
||||
// ERC-20: the token balance covers the transfer, the ETH balance does
|
||||
// not cover the network fee it costs.
|
||||
INSUFFICIENT_ETH_FOR_FEE: "insufficient-eth-for-fee",
|
||||
// The fee estimate has not arrived yet.
|
||||
FEE_PENDING: "fee-pending",
|
||||
// The fee estimate failed. Unknown is never treated as zero.
|
||||
FEE_UNAVAILABLE: "fee-unavailable",
|
||||
};
|
||||
|
||||
// The fee that must be reserved for a transaction, in wei: the amount the
|
||||
// node will require, not the amount the transaction is expected to cost.
|
||||
//
|
||||
// A send that pins no fee fields is populated by ethers as a type-2
|
||||
// (EIP-1559) transaction, and a node validates that against
|
||||
// `value + gasLimit * maxFeePerGas`. ethers derives maxFeePerGas as
|
||||
// `baseFeePerGas * 2 + maxPriorityFeePerGas`, so reserving `gasPrice`
|
||||
// (roughly `baseFee + tip`) under-reserves by about `gasLimit * baseFee` and
|
||||
// lets through a transaction the node then rejects with "insufficient funds
|
||||
// for gas * price + value". gasPrice is the fallback only for a network that
|
||||
// offers no type-2 pricing at all.
|
||||
//
|
||||
// Returns null when no usable price is available, which the caller must treat
|
||||
// as a failed estimate rather than as a free transaction.
|
||||
function feeReserveWei(gasLimit, feeData) {
|
||||
if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null;
|
||||
const price = feeData?.maxFeePerGas ?? feeData?.gasPrice;
|
||||
if (typeof price !== "bigint" || price < 0n) return null;
|
||||
return gasLimit * price;
|
||||
}
|
||||
|
||||
// What the transaction is expected to actually cost, in wei — not what must
|
||||
// be reserved for it. A type-2 transaction is charged `baseFee + tip` per gas
|
||||
// and refunded the rest of the cap, and `eth_gasPrice` reports roughly that,
|
||||
// so gasPrice is the estimate and maxFeePerGas is the reserve. On a network
|
||||
// with no type-2 pricing the two are the same number.
|
||||
//
|
||||
// Display only: nothing gates on this. Returns null on the same unusable
|
||||
// inputs as feeReserveWei().
|
||||
function feeEstimateWei(gasLimit, feeData) {
|
||||
if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null;
|
||||
const price = feeData?.gasPrice ?? feeData?.maxFeePerGas;
|
||||
if (typeof price !== "bigint" || price < 0n) return null;
|
||||
return gasLimit * price;
|
||||
}
|
||||
|
||||
// Scale a human decimal string to 18-decimal fixed point. Returns null when
|
||||
// the value is not a decimal number or carries more precision than the scale
|
||||
// can hold, which the caller must treat as unusable rather than as zero.
|
||||
function toFixedPoint(value) {
|
||||
if (typeof value !== "string" && typeof value !== "number") return null;
|
||||
const text = String(value).trim();
|
||||
if (text === "") return null;
|
||||
try {
|
||||
return parseUnits(text, SCALE_DECIMALS);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate a pending transfer against the balances that must cover it.
|
||||
//
|
||||
// isErc20 — token transfer rather than a native ETH transfer
|
||||
// amount — human decimal string being sent, non-negative. Anything
|
||||
// else, a negative value included, is an unusable amount
|
||||
// rather than an amount that passes every comparison.
|
||||
// ethBalance — human decimal string, the sender's ETH balance
|
||||
// tokenBalance — human decimal string, the sender's token balance
|
||||
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
|
||||
// is treated as FEE_UNAVAILABLE.
|
||||
// feeWei — the fee reserve in wei from feeReserveWei(), as a
|
||||
// non-negative bigint, when FEE_KNOWN. Any other value makes
|
||||
// the fee unavailable rather than zero.
|
||||
//
|
||||
// Returns { canSend, codes }. Every code blocks sending: canSend is true
|
||||
// only when nothing was found.
|
||||
function validateTransfer({
|
||||
isErc20 = false,
|
||||
amount,
|
||||
ethBalance,
|
||||
tokenBalance,
|
||||
feeStatus = FEE_PENDING,
|
||||
feeWei = null,
|
||||
} = {}) {
|
||||
const codes = [];
|
||||
|
||||
const amountFp = toFixedPoint(amount);
|
||||
const ethFp = toFixedPoint(ethBalance) ?? 0n;
|
||||
|
||||
// A negative amount parses to a valid bigint, so every comparison below
|
||||
// is trivially false and the send clears the screen — then dies at encode
|
||||
// time in parseEther(). Unusable, on the same footing as a malformed fee.
|
||||
if (amountFp === null || amountFp < 0n) {
|
||||
codes.push(CODES.AMOUNT_INVALID);
|
||||
return { canSend: false, codes };
|
||||
}
|
||||
|
||||
// Fail closed. Anything that is not a usable fee under a recognised
|
||||
// status — a malformed feeWei, or a status this module does not know —
|
||||
// is an unavailable estimate, never a fee of zero. Every such input errs
|
||||
// in the direction that lets money out, so none of them is trusted.
|
||||
const known =
|
||||
feeStatus === FEE_KNOWN && typeof feeWei === "bigint" && feeWei >= 0n;
|
||||
let status = feeStatus;
|
||||
if (feeStatus === FEE_KNOWN && !known) status = FEE_UNAVAILABLE;
|
||||
if (status !== FEE_KNOWN && status !== FEE_PENDING) {
|
||||
status = FEE_UNAVAILABLE;
|
||||
}
|
||||
|
||||
const feeFp = known ? feeWei : null;
|
||||
|
||||
if (isErc20) {
|
||||
const tokenFp = toFixedPoint(tokenBalance) ?? 0n;
|
||||
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
|
||||
if (feeFp !== null && feeFp > ethFp) {
|
||||
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
|
||||
}
|
||||
} else if (amountFp > ethFp) {
|
||||
codes.push(CODES.INSUFFICIENT_ETH);
|
||||
} else if (feeFp !== null && amountFp + feeFp > ethFp) {
|
||||
codes.push(CODES.INSUFFICIENT_ETH_WITH_FEE);
|
||||
}
|
||||
|
||||
// An unknown fee is never assumed to be zero: sending stays blocked
|
||||
// until the estimate arrives, and stays blocked if it never does.
|
||||
if (status === FEE_PENDING) codes.push(CODES.FEE_PENDING);
|
||||
if (status === FEE_UNAVAILABLE) codes.push(CODES.FEE_UNAVAILABLE);
|
||||
|
||||
return { canSend: codes.length === 0, codes };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CODES,
|
||||
FEE_PENDING,
|
||||
FEE_KNOWN,
|
||||
FEE_UNAVAILABLE,
|
||||
SCALE_DECIMALS,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
toFixedPoint,
|
||||
validateTransfer,
|
||||
};
|
||||
Reference in New Issue
Block a user