fix: count the network fee in the confirm-screen balance check (closes #154)
All checks were successful
check / check (push) Successful in 29s
All checks were successful
check / check (push) Successful in 29s
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.
This commit is contained in:
4
TODO.md
4
TODO.md
@@ -44,6 +44,10 @@ undefined identifiers, which is how
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-11: Network fee counted in the confirmation-screen balance check for
|
||||||
|
both ETH and ERC-20 sends, with the arithmetic in a pure, unit-tested
|
||||||
|
`src/shared/txValidation.js`
|
||||||
|
([#154](https://git.eeqj.de/sneak/AutistMask/issues/154)).
|
||||||
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
|
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
|
||||||
on `next`, with Status and Next Step refreshed
|
on `next`, with Status and Next Step refreshed
|
||||||
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).
|
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).
|
||||||
|
|||||||
@@ -647,6 +647,31 @@
|
|||||||
class="mb-2 border border-border border-dashed p-2"
|
class="mb-2 border border-border border-dashed p-2"
|
||||||
style="visibility: hidden; min-height: 1.25rem"
|
style="visibility: hidden; min-height: 1.25rem"
|
||||||
></div>
|
></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">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -32,11 +32,22 @@ const {
|
|||||||
getFullWarnings,
|
getFullWarnings,
|
||||||
} = require("../../shared/addressWarnings");
|
} = require("../../shared/addressWarnings");
|
||||||
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
|
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
|
||||||
|
const {
|
||||||
|
CODES,
|
||||||
|
FEE_PENDING,
|
||||||
|
FEE_KNOWN,
|
||||||
|
FEE_UNAVAILABLE,
|
||||||
|
validateTransfer,
|
||||||
|
} = require("../../shared/txValidation");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
const makeBlockie = require("ethereum-blockies-base64");
|
const makeBlockie = require("ethereum-blockies-base64");
|
||||||
const txStatus = require("./txStatus");
|
const txStatus = require("./txStatus");
|
||||||
|
|
||||||
let pendingTx = null;
|
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() {
|
function restore() {
|
||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
@@ -67,6 +78,8 @@ function valueWithUsd(text, usdAmount) {
|
|||||||
|
|
||||||
function show(txInfo) {
|
function show(txInfo) {
|
||||||
pendingTx = txInfo;
|
pendingTx = txInfo;
|
||||||
|
feeStatus = FEE_PENDING;
|
||||||
|
feeWei = null;
|
||||||
|
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
||||||
@@ -153,50 +166,14 @@ function show(txInfo) {
|
|||||||
warningsEl.style.visibility = "hidden";
|
warningsEl.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for errors
|
// The two fee messages are mutually exclusive per transaction type, and
|
||||||
const errors = [];
|
// the type is known here, before the first paint. Drop the one that can
|
||||||
if (isErc20) {
|
// never apply and reserve the space of the one that can, so the async
|
||||||
const tokenBal = parseFloat(txInfo.tokenBalance || "0");
|
// estimate landing later never moves anything.
|
||||||
if (parseFloat(txInfo.amount) > tokenBal) {
|
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
||||||
errors.push(
|
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
||||||
"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.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorsEl = $("confirm-errors");
|
renderValidation(txInfo);
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset password field and error
|
// Reset password field and error
|
||||||
$("confirm-tx-password").value = "";
|
$("confirm-tx-password").value = "";
|
||||||
@@ -224,6 +201,89 @@ function show(txInfo) {
|
|||||||
checkRecipientHistory(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";
|
||||||
|
}
|
||||||
|
|
||||||
async function estimateGas(txInfo) {
|
async function estimateGas(txInfo) {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(state.rpcUrl);
|
const provider = getProvider(state.rpcUrl);
|
||||||
@@ -247,6 +307,10 @@ async function estimateGas(txInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gasCostWei = gasLimit * gasPrice;
|
const gasCostWei = gasLimit * gasPrice;
|
||||||
|
// 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 gasCostEth = formatEther(gasCostWei);
|
const gasCostEth = formatEther(gasCostWei);
|
||||||
// Format to 6 significant decimal places
|
// Format to 6 significant decimal places
|
||||||
const parts = gasCostEth.split(".");
|
const parts = gasCostEth.split(".");
|
||||||
@@ -258,9 +322,16 @@ async function estimateGas(txInfo) {
|
|||||||
const ethPrice = getPrice("ETH");
|
const ethPrice = getPrice("ETH");
|
||||||
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null;
|
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null;
|
||||||
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd);
|
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd);
|
||||||
|
feeStatus = FEE_KNOWN;
|
||||||
|
feeWei = gasCostWei;
|
||||||
|
renderValidation(txInfo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("gas estimation failed:", e.message);
|
log.errorf("gas estimation failed:", e.message);
|
||||||
|
if (pendingTx !== txInfo) return;
|
||||||
$("confirm-fee-amount").textContent = "Unable to estimate";
|
$("confirm-fee-amount").textContent = "Unable to estimate";
|
||||||
|
feeStatus = FEE_UNAVAILABLE;
|
||||||
|
feeWei = null;
|
||||||
|
renderValidation(txInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
src/shared/txValidation.js
Normal file
114
src/shared/txValidation.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// 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 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",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
|
// feeWei — bigint estimated network fee in wei, when FEE_KNOWN
|
||||||
|
//
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
if (amountFp === null) {
|
||||||
|
codes.push(CODES.AMOUNT_INVALID);
|
||||||
|
return { canSend: false, codes };
|
||||||
|
}
|
||||||
|
|
||||||
|
const feeFp =
|
||||||
|
feeStatus === FEE_KNOWN && typeof feeWei === "bigint" ? 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 (feeStatus === FEE_PENDING) codes.push(CODES.FEE_PENDING);
|
||||||
|
if (feeStatus === FEE_UNAVAILABLE) codes.push(CODES.FEE_UNAVAILABLE);
|
||||||
|
|
||||||
|
return { canSend: codes.length === 0, codes };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CODES,
|
||||||
|
FEE_PENDING,
|
||||||
|
FEE_KNOWN,
|
||||||
|
FEE_UNAVAILABLE,
|
||||||
|
SCALE_DECIMALS,
|
||||||
|
toFixedPoint,
|
||||||
|
validateTransfer,
|
||||||
|
};
|
||||||
185
tests/txValidation.test.js
Normal file
185
tests/txValidation.test.js
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
const { parseEther } = require("ethers");
|
||||||
|
const {
|
||||||
|
CODES,
|
||||||
|
FEE_PENDING,
|
||||||
|
FEE_KNOWN,
|
||||||
|
FEE_UNAVAILABLE,
|
||||||
|
toFixedPoint,
|
||||||
|
validateTransfer,
|
||||||
|
} = require("../src/shared/txValidation");
|
||||||
|
|
||||||
|
// A plausible mainnet fee: 21000 gas at 20 gwei.
|
||||||
|
const FEE = 21000n * 20000000000n; // 0.00042 ETH
|
||||||
|
|
||||||
|
describe("toFixedPoint", () => {
|
||||||
|
test("scales human decimals to 18 places", () => {
|
||||||
|
expect(toFixedPoint("1.5")).toBe(parseEther("1.5"));
|
||||||
|
expect(toFixedPoint("0")).toBe(0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects values it cannot represent exactly", () => {
|
||||||
|
expect(toFixedPoint("not a number")).toBe(null);
|
||||||
|
expect(toFixedPoint("")).toBe(null);
|
||||||
|
expect(toFixedPoint(null)).toBe(null);
|
||||||
|
// More precision than 18 decimals can hold.
|
||||||
|
expect(toFixedPoint("0.0000000000000000001")).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateTransfer, native ETH", () => {
|
||||||
|
const eth = (over) => ({
|
||||||
|
isErc20: false,
|
||||||
|
amount: "0.5",
|
||||||
|
ethBalance: "1.0",
|
||||||
|
feeStatus: FEE_KNOWN,
|
||||||
|
feeWei: FEE,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows a send comfortably within balance", () => {
|
||||||
|
const r = validateTransfer(eth());
|
||||||
|
expect(r).toEqual({ canSend: true, codes: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks a send whose amount plus fee exceeds the balance", () => {
|
||||||
|
// The whole balance: passes an amount-only check, fails once the fee
|
||||||
|
// is counted. This is the bug this module exists to prevent.
|
||||||
|
const r = validateTransfer(eth({ amount: "1.0", ethBalance: "1.0" }));
|
||||||
|
expect(r.canSend).toBe(false);
|
||||||
|
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks a send left short by less than one fee", () => {
|
||||||
|
const balance = "1.0";
|
||||||
|
// One wei less headroom than the fee needs.
|
||||||
|
const amount = "0.99958000000000001"; // 1.0 - 0.00042 + 1e-17
|
||||||
|
const r = validateTransfer(eth({ amount, ethBalance: balance }));
|
||||||
|
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows a send that leaves exactly the fee behind", () => {
|
||||||
|
const r = validateTransfer(
|
||||||
|
eth({ amount: "0.99958", ethBalance: "1.0" }),
|
||||||
|
);
|
||||||
|
expect(r).toEqual({ canSend: true, codes: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports plain insufficient balance when the amount alone is too big", () => {
|
||||||
|
const r = validateTransfer(eth({ amount: "2.0", ethBalance: "1.0" }));
|
||||||
|
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks while the fee estimate is still pending", () => {
|
||||||
|
const r = validateTransfer(
|
||||||
|
eth({ feeStatus: FEE_PENDING, feeWei: null }),
|
||||||
|
);
|
||||||
|
expect(r.canSend).toBe(false);
|
||||||
|
expect(r.codes).toEqual([CODES.FEE_PENDING]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks when the fee estimate failed, without assuming zero", () => {
|
||||||
|
const r = validateTransfer(
|
||||||
|
eth({
|
||||||
|
amount: "1.0",
|
||||||
|
ethBalance: "1.0",
|
||||||
|
feeStatus: FEE_UNAVAILABLE,
|
||||||
|
feeWei: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.canSend).toBe(false);
|
||||||
|
expect(r.codes).toEqual([CODES.FEE_UNAVAILABLE]);
|
||||||
|
// A zero fee would have let this exact transfer through.
|
||||||
|
expect(
|
||||||
|
validateTransfer(
|
||||||
|
eth({ amount: "1.0", ethBalance: "1.0", feeWei: 0n }),
|
||||||
|
).canSend,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still reports an over-balance amount before the estimate lands", () => {
|
||||||
|
const r = validateTransfer(
|
||||||
|
eth({
|
||||||
|
amount: "2.0",
|
||||||
|
ethBalance: "1.0",
|
||||||
|
feeStatus: FEE_PENDING,
|
||||||
|
feeWei: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH, CODES.FEE_PENDING]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an amount it cannot do exact arithmetic on", () => {
|
||||||
|
const r = validateTransfer(eth({ amount: "abc" }));
|
||||||
|
expect(r.canSend).toBe(false);
|
||||||
|
expect(r.codes).toEqual([CODES.AMOUNT_INVALID]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats a missing balance as zero, not as unlimited", () => {
|
||||||
|
const r = validateTransfer(eth({ ethBalance: undefined }));
|
||||||
|
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateTransfer, ERC-20", () => {
|
||||||
|
const erc20 = (over) => ({
|
||||||
|
isErc20: true,
|
||||||
|
amount: "100.0",
|
||||||
|
tokenBalance: "250.0",
|
||||||
|
ethBalance: "1.0",
|
||||||
|
feeStatus: FEE_KNOWN,
|
||||||
|
feeWei: FEE,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows a transfer with tokens to spend and ETH for the fee", () => {
|
||||||
|
expect(validateTransfer(erc20())).toEqual({ canSend: true, codes: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checks the token amount against the token balance", () => {
|
||||||
|
const r = validateTransfer(erc20({ amount: "250.000001" }));
|
||||||
|
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not charge the fee against the token balance", () => {
|
||||||
|
// The full token balance is sendable: the fee is paid in ETH.
|
||||||
|
expect(validateTransfer(erc20({ amount: "250.0" })).canSend).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks when the ETH balance does not cover the fee", () => {
|
||||||
|
const r = validateTransfer(erc20({ ethBalance: "0.0001" }));
|
||||||
|
expect(r.canSend).toBe(false);
|
||||||
|
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_FOR_FEE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows a fee exactly equal to the ETH balance", () => {
|
||||||
|
const r = validateTransfer(erc20({ ethBalance: "0.00042" }));
|
||||||
|
expect(r).toEqual({ canSend: true, codes: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports both shortfalls when tokens and ETH are both short", () => {
|
||||||
|
const r = validateTransfer(
|
||||||
|
erc20({ amount: "300.0", ethBalance: "0.0" }),
|
||||||
|
);
|
||||||
|
expect(r.codes).toEqual([
|
||||||
|
CODES.INSUFFICIENT_TOKEN,
|
||||||
|
CODES.INSUFFICIENT_ETH_FOR_FEE,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks while the fee estimate is pending or failed", () => {
|
||||||
|
expect(
|
||||||
|
validateTransfer(erc20({ feeStatus: FEE_PENDING, feeWei: null }))
|
||||||
|
.codes,
|
||||||
|
).toEqual([CODES.FEE_PENDING]);
|
||||||
|
expect(
|
||||||
|
validateTransfer(
|
||||||
|
erc20({ feeStatus: FEE_UNAVAILABLE, feeWei: null }),
|
||||||
|
).codes,
|
||||||
|
).toEqual([CODES.FEE_UNAVAILABLE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats a missing token balance as zero", () => {
|
||||||
|
const r = validateTransfer(erc20({ tokenBalance: undefined }));
|
||||||
|
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user