Compare commits

...
1 Commits
Author SHA1 Message Date
clawbot ebbc2028e0 fix: show balances and fees below 0.000001 ETH as nonzero on the send screens (closes #343)
check / check (push) Successful in 3m1s
e2e / e2e-chrome (push) Successful in 4m28s
e2e / e2e-firefox (push) Successful in 35s
The stored ETH balance and the send-confirm screen's fee were each cut to six
decimal places, so a value below 0.000001 read as 0.0. The ETH balance is now
stored exactly, and the send and send-confirm screens' balances, reserve and
insufficient-balance messages go through truncateAmountNeverZero(). Both the
send-confirm and approval screens render the fee through formatFee(), which
prices the exact fee in USD, so the same fee reads the same on both. Token
balances keep their six-decimal value: it also leaves dust off the balance
list, and keeps the string within the 18 decimals the balance check reads.

Model: opus-5-5
2026-10-04 02:49:19 +00:00
10 changed files with 392 additions and 70 deletions
+14 -6
View File
@@ -892,12 +892,20 @@ shows is a V4 exact-in `amountIn` of zero.
The rule and its exception live in `src/shared/amountDisplay.js` as The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and `truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
confirmation screens display goes through the floored one — the ERC-20 amount, confirmation screens display goes through the floored one — the ERC-20 amount,
the ETH value and max fee (`src/popup/views/approval.js`), and the swap's the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
`Amount` and `Min. received` lines (`src/shared/uniswap.js`). The history and and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
balance lists (`src/shared/transactions.js`) use the unfloored one: the `Current balance` (`src/popup/views/send.js`), and the balance and network fee
transaction detail view is the authoritative record and already shows exact on the confirmation screen for the wallet's own send
precision. The 4-decimal rule is unchanged everywhere else, including for (`src/popup/views/confirmTx.js`). Both screens render a network fee through
amounts at or above the floor on the approval screens. `formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD
rather than its truncated figure, so the same fee reads the same on both, USD
value included. The ETH balance is stored exactly, so a balance below the floor
reaches these screens as it is. A token balance is stored to six decimal places,
and a holding below 0.000001 is not listed at all. The history and balance lists
(`src/shared/transactions.js`) use the unfloored one: the transaction detail
view is the authoritative record and already shows exact precision. The
4-decimal rule is unchanged everywhere else, including for amounts at or above
the floor on the approval screens.
The floor applies only where the token's scale is known. Where it is not, the The floor applies only where the token's scale is known. Where it is not, the
approval screen states base units instead of a quantity — see Unknown token approval screen states base units instead of a quantity — see Unknown token
+14
View File
@@ -45,6 +45,20 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-10-04: The Send and confirmation screens no longer show an ETH balance or
a network fee below 0.000001 as `0.0`
([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored ETH
balance (`src/shared/balances.js`) and the confirmation screen's fee were each
cut to six decimal places by a rule of their own. The ETH balance is now
stored exactly, and the Send screen's `Current balance`, and the confirmation
screen's balance, fee, reserve and insufficient-balance messages, go through
`truncateAmountNeverZero()` in `src/shared/amountDisplay.js`, the helper the
approval screen already used. The confirmation and approval screens both
render the fee through `formatFee()` in `src/popup/views/helpers.js`, which
prices the exact fee in USD, so the same fee reads the same on both, USD value
included. Token balances are still stored to six decimal places: that cut is
also what leaves a holding below 0.000001 off the balance list, and keeps the
stored string within the 18 decimals the balance check reads.
- 2026-10-03: The typed-data signing screen warns for a token permission, and - 2026-10-03: The typed-data signing screen warns for a token permission, and
names the primary type ethers signs names the primary type ethers signs
([#400](https://git.eeqj.de/sneak/AutistMask/issues/400)). A Permit or Permit2 ([#400](https://git.eeqj.de/sneak/AutistMask/issues/400)). A Permit or Permit2
+6 -8
View File
@@ -8,6 +8,7 @@ const {
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
onViewLeave, onViewLeave,
formatFee,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks"); const { networkByChainId } = require("../../shared/networks");
@@ -207,7 +208,7 @@ function showPhishingWarning(elementId, isPhishing) {
// and the nonce. The background compares every one of them against the signed // and the nonce. The background compares every one of them against the signed
// artifact, so every one of them has to be on the screen — a number that is // artifact, so every one of them has to be on the screen — a number that is
// verified but never displayed is verified against nothing the user agreed to. // verified but never displayed is verified against nothing the user agreed to.
function showTxFee(approvedTx, ethPrice) { function showTxFee(approvedTx) {
const network = networkByChainId(approvedTx.chainId); const network = networkByChainId(approvedTx.chainId);
$("approve-tx-network").textContent = network $("approve-tx-network").textContent = network
? network.name ? network.name
@@ -215,12 +216,9 @@ function showTxFee(approvedTx, ethPrice) {
const gasLimit = BigInt(approvedTx.gasLimit); const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice); const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas)); // Through formatFee(), as the confirmation screen's fee is, so the same
const usdStr = formatUsd( // fee reads the same on both.
ethPrice ? parseFloat(maxFeeEth) * ethPrice : null, $("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas);
);
$("approve-tx-fee").textContent =
maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
let detail = let detail =
gasLimit.toString() + gasLimit.toString() +
@@ -332,7 +330,7 @@ function showTxApproval(details) {
$("approve-tx-value").textContent = $("approve-tx-value").textContent =
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : ""); ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx, ethPrice); showTxFee(approvedTx);
// Decode calldata (reuse decoded from above) // Decode calldata (reuse decoded from above)
const decodedEl = $("approve-tx-decoded"); const decodedEl = $("approve-tx-decoded");
+21 -25
View File
@@ -15,6 +15,7 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
onViewLeave, onViewLeave,
formatFee,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
@@ -31,6 +32,9 @@ const {
transferAmountUnits, transferAmountUnits,
} = require("../../shared/transferAmount"); } = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify"); const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The balance lines, the fee reserve and the insufficient-balance messages go
// through it, as the approval screen's amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { const {
CODES, CODES,
FEE_PENDING, FEE_PENDING,
@@ -150,11 +154,17 @@ function show(txInfo) {
$("confirm-balance").textContent = $("confirm-balance").textContent =
bal == null bal == null
? "unknown (" + symbol + ")" ? "unknown (" + symbol + ")"
: valueWithUsd(bal + " " + symbol, balUsd); : valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
} else { } else {
const bal = txInfo.balance || "0"; const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null; const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd); $("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " ETH",
balUsd,
);
} }
// Check for warnings (synchronous local checks) // Check for warnings (synchronous local checks)
@@ -249,7 +259,7 @@ function renderValidation(txInfo) {
: "Insufficient " + : "Insufficient " +
symbol + symbol +
" balance. You have " + " balance. You have " +
txInfo.tokenBalance + truncateAmountNeverZero(txInfo.tokenBalance) +
" " + " " +
symbol + symbol +
" but are trying to send " + " but are trying to send " +
@@ -262,7 +272,7 @@ function renderValidation(txInfo) {
if (codes.includes(CODES.INSUFFICIENT_ETH)) { if (codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push( messages.push(
"Insufficient balance. You have " + "Insufficient balance. You have " +
txInfo.balance + truncateAmountNeverZero(txInfo.balance || "0") +
" ETH but are trying to send " + " ETH but are trying to send " +
txInfo.amount + txInfo.amount +
" ETH.", " ETH.",
@@ -305,14 +315,6 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden"; $(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) { async function estimateGas(txInfo) {
try { try {
const provider = getProvider(state.rpcUrl, state.networkId); const provider = getProvider(state.rpcUrl, state.networkId);
@@ -359,26 +361,20 @@ async function estimateGas(txInfo) {
// flight; a stale fee must not reach the screen or the balance check. // flight; a stale fee must not reach the screen or the balance check.
if (pendingTx !== txInfo) return; if (pendingTx !== txInfo) return;
const ethPrice = getPrice("ETH"); // The fee lines go through formatFee(), as the approval screen's
const usd = (wei) => // does, so the same fee reads the same on both.
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
if (estimateWei !== null && estimateWei < gasCostWei) { if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = valueWithUsd( $("confirm-fee-amount").textContent = "~" + formatFee(estimateWei);
"~" + formatFeeEth(estimateWei),
usd(estimateWei),
);
$("confirm-fee-reserve").textContent = $("confirm-fee-reserve").textContent =
"up to " + formatFeeEth(gasCostWei) + " reserved"; "up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" ETH reserved";
setVisible("confirm-fee-reserve", true); setVisible("confirm-fee-reserve", true);
} else { } else {
// No spread to report: either there is no estimate, or the node // No spread to report: either there is no estimate, or the node
// quotes a gas price at or above maxFeePerGas, so the expected // quotes a gas price at or above maxFeePerGas, so the expected
// cost is not below the reserve. Show the reserve alone. // cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = valueWithUsd( $("confirm-fee-amount").textContent = formatFee(gasCostWei);
formatFeeEth(gasCostWei),
usd(gasCostWei),
);
setVisible("confirm-fee-reserve", false); setVisible("confirm-fee-reserve", false);
} }
feeStatus = FEE_KNOWN; feeStatus = FEE_KNOWN;
+16
View File
@@ -12,6 +12,8 @@
// escapeHtml lives in src/shared/html.js, where the escape and the // escapeHtml lives in src/shared/html.js, where the escape and the
// reasoning behind it are; it is re-exported below so views keep importing // reasoning behind it are; it is re-exported below so views keep importing
// it from here. // it from here.
const { formatEther } = require("ethers");
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { DEBUG } = require("../../shared/constants"); const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
@@ -243,6 +245,19 @@ function unknownableAmount(balance) {
return Number.isFinite(n) ? n : null; return Number.isFinite(n) ? n : null;
} }
// A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero(), then its USD value when the
// ETH price is known. The USD value is of the exact fee, not of the truncated
// figure, so it never understates what the fee costs.
function formatFee(wei) {
const eth = formatEther(wei);
const ethPrice = getPrice("ETH");
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return (
truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "")
);
}
// One row of the balance list: symbol, quantity, fiat value. // One row of the balance list: symbol, quantity, fiat value.
// //
// `symbol` is the ERC-20's own symbol() as the block explorer reported it, // `symbol` is the ERC-20's own symbol() as the block explorer reported it,
@@ -610,6 +625,7 @@ module.exports = {
balanceLinesForAddress, balanceLinesForAddress,
addressHoldsFunds, addressHoldsFunds,
unknownableAmount, unknownableAmount,
formatFee,
addressColor, addressColor,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,
+8 -2
View File
@@ -16,6 +16,7 @@ const { resolveTokenDecimals } = require("../../shared/approvalAmount");
const { resolveSymbol } = require("../../shared/tokenList"); const { resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders"); const { isLowHolderCount } = require("../../shared/holders");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof"); const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { getAddress } = require("ethers"); const { getAddress } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
@@ -150,7 +151,9 @@ function updateSendBalance() {
const token = state.selectedToken || $("send-token").value; const token = state.selectedToken || $("send-token").value;
if (token === "ETH") { if (token === "ETH") {
$("send-balance").textContent = $("send-balance").textContent =
"Current balance: " + (addr.balance || "0") + " ETH"; "Current balance: " +
truncateAmountNeverZero(addr.balance || "0") +
" ETH";
} else { } else {
const tb = (addr.tokenBalances || []).find( const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(), (t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -167,7 +170,10 @@ function updateSendBalance() {
$("send-balance").textContent = $("send-balance").textContent =
bal == null bal == null
? "Current balance: unknown (" + symbol + ")" ? "Current balance: unknown (" + symbol + ")"
: "Current balance: " + bal + " " + symbol; : "Current balance: " +
truncateAmountNeverZero(bal) +
" " +
symbol;
} }
} }
+10 -9
View File
@@ -52,14 +52,12 @@ function requireNetworkId(networkId) {
return net; return net;
} }
function formatBalance(wei) { // A token balance cut to six decimal places. Two things rely on the cut: a
const eth = formatEther(wei); // holding below 0.000001 comes out as "0.0" and is left off the balance list as
const parts = eth.split("."); // dust, and the stored string never carries more decimals than the balance
if (parts.length === 1) return eth + ".0"; // check on the confirmation screen can read (18, in txValidation.js), whatever
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0"; // scale the token declares. Screens truncate it again for display, through
return parts[0] + "." + dec; // src/shared/amountDisplay.js.
}
function formatTokenBalance(raw, decimals) { function formatTokenBalance(raw, decimals) {
const val = formatUnits(raw, decimals); const val = formatUnits(raw, decimals);
const parts = val.split("."); const parts = val.split(".");
@@ -221,7 +219,10 @@ async function refreshBalances(
provider provider
.getBalance(addr.address) .getBalance(addr.address)
.then((bal) => { .then((bal) => {
addr.balance = formatBalance(bal); // Exact, never cut: the screens truncate for display
// themselves, and a cut here stores a small nonzero
// balance as zero.
addr.balance = formatEther(bal);
log.debugf("ETH balance", addr.address, addr.balance); log.debugf("ETH balance", addr.address, addr.balance);
}) })
.catch((e) => { .catch((e) => {
+16 -17
View File
@@ -1414,9 +1414,10 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
// on opposite sides of the reserve while sitting on the same side of the // on opposite sides of the reserve while sitting on the same side of the
// estimate. // estimate.
// The balance the funded fixture serves, and the amounts sent against it. // The balance the funded fixture serves, as the Send and confirmation screens
// show it, and the amounts sent against it.
const FUNDED_ETH_WEI = 10n ** 18n; const FUNDED_ETH_WEI = 10n ** 18n;
const FUNDED_ETH_TEXT = "1.0"; const FUNDED_ETH_TEXT = "1.0000";
const COMFORTABLE_AMOUNT = "0.1"; const COMFORTABLE_AMOUNT = "0.1";
const OVER_BALANCE_AMOUNT = "2.0"; const OVER_BALANCE_AMOUNT = "2.0";
@@ -1430,7 +1431,7 @@ const GAP_AMOUNT = formatEther(FUNDED_ETH_WEI - FEE_ESTIMATE_WEI);
// fee test: it covers the expected cost to the wei and falls short of the // fee test: it covers the expected cost to the wei and falls short of the
// reserve, so the same swap flips this assertion too — through a different // reserve, so the same swap flips this assertion too — through a different
// balance and a different message than the ETH path uses. // balance and a different message than the ETH path uses.
const TOKEN_BALANCE_TEXT = "1.5"; const TOKEN_BALANCE_TEXT = "1.5000";
const TOKEN_AMOUNT = "0.25"; const TOKEN_AMOUNT = "0.25";
const OVER_TOKEN_AMOUNT = "9.0"; const OVER_TOKEN_AMOUNT = "9.0";
const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI; const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI;
@@ -1439,16 +1440,15 @@ function toHexWei(wei) {
return "0x" + wei.toString(16); return "0x" + wei.toString(16);
} }
// A fee in wei as the confirmation screen writes it. Deliberately a second // A fee in wei as the confirmation screen writes it: truncated to four decimal
// implementation of formatFeeEth() from src/popup/views/confirmTx.js rather // places (README.md, Display Consistency). Deliberately a second
// than an import of it: that module pulls in the whole popup and cannot be // implementation rather than an import of src/shared/amountDisplay.js:
// required outside a browser, and asserting against an independent rendering // asserting against an independent rendering is stronger than asserting a
// is stronger than asserting a function equals itself. // function equals itself. The fixture's fees are above 0.0001 ETH, so the
// nonzero floor never applies here.
function feeEth(wei) { function feeEth(wei) {
const parts = formatEther(wei).split("."); const [whole, frac = ""] = formatEther(wei).split(".");
const dec = return whole + "." + (frac + "0000").slice(0, 4) + " ETH";
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
return parts[0] + "." + dec + " ETH";
} }
// What the confirmation screen is showing right now, read out of the DOM in // What the confirmation screen is showing right now, read out of the DOM in
@@ -1505,11 +1505,10 @@ async function backToAddress(page) {
// Drive the popup to the confirmation screen for one send. // Drive the popup to the confirmation screen for one send.
// //
// It waits for the send screen to be showing `balance` before filling // It waits for the send screen to be showing `balance`, the fixture's balance
// anything in. That figure is the exact number the spend gate compares // as that screen displays it, before filling anything in. Waiting for it —
// against, so waiting for it — rather than for a refresh to have probably // rather than for a refresh to have probably landed — is what keeps every
// landed — is what keeps every assertion below deterministic after a // assertion below deterministic after a fixture change.
// fixture change.
async function goToConfirm(page, { token, balance, amount }) { async function goToConfirm(page, { token, balance, amount }) {
await backToAddress(page); await backToAddress(page);
await page.click("#btn-send"); await page.click("#btn-send");
+284
View File
@@ -0,0 +1,284 @@
// The balance and fee lines of the Send and confirmation screens, and the fee
// line they must share with the approval screen.
//
// An ETH balance or a fee below 0.000001 rendered as `0.0` on these screens
// (https://git.eeqj.de/sneak/AutistMask/issues/343): the stored balance and the
// fee were each cut to six decimal places, a rule of their own, while the
// approval screen showed the same fee through src/shared/amountDisplay.js with
// the nonzero floor. Both now go through that one helper.
//
// Driven through the real refreshBalances(), Send screen, confirmation screen
// and approval screen, with only the node and the DOM stubbed: a balance
// written onto state by hand would skip the place the cut happened.
"use strict";
// What the stub node answers. Each test sets what it needs.
const mockNode = {
balanceWei: 0n,
feeData: { maxFeePerGas: 1n, gasPrice: 1n },
};
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
async getBalance() {
return mockNode.balanceWei;
}
async lookupAddress() {
return null;
}
async getFeeData() {
return mockNode.feeData;
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
}
return {
...actual,
JsonRpcProvider: StubProvider,
Network: { from: () => ({}) },
};
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The explorer's token list, which refreshBalances() also fetches: empty.
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => [],
})),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The confirmation screen's Etherscan label lookup is the only fetch() these
// screens make; it fails, as it does offline.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// The approval the background hands the approval screen. Set per test.
let approvalDetails = null;
const { makeStorageStub } = require("./support/storageStub");
global.chrome = {
storage: makeStorageStub(),
runtime: {
connect: () => ({
postMessage() {},
disconnect() {},
onDisconnect: { addListener() {} },
}),
sendMessage(message, callback) {
callback(
message.type === "AUTISTMASK_GET_APPROVAL"
? approvalDetails
: undefined,
);
},
},
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state");
const { prices, clearPrices } = require("../src/shared/prices");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const approval = require("../src/popup/views/approval");
const HOLDER = "0x" + "a".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// 0.0000005 ETH.
const HALF_MICRO_ETH = 500000000000n;
function text(id) {
return global.document.getElementById(id).textContent;
}
// The ETH balance the node reports, fetched and stored exactly where the popup
// stores it.
async function refreshWith(balanceWei) {
mockNode.balanceWei = balanceWei;
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0;
state.selectedAddress = 0;
await refreshBalances(
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
[],
"mainnet",
);
}
// Press Review on the Send screen for an ETH send, and show the confirmation
// screen it leads to with its fee estimate settled.
async function confirmEthSend(amount) {
let txInfo = null;
send.init({ showConfirmTx: (info) => (txInfo = info) });
state.selectedToken = "ETH";
global.document.getElementById("send-to").value = RECIPIENT;
global.document.getElementById("send-amount").value = amount;
await global.document
.getElementById("btn-send-review")
.handlers.get("click")();
confirmTx.show(txInfo);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// The approval screen for a dApp transaction of 21000 gas, the gas the stub
// node estimates for the send above.
async function approveTxWithFeePerGas(maxFeePerGas) {
approvalDetails = {
type: "tx",
hostname: "dapp.example",
approvedFrom: HOLDER,
approvedTx: {
to: RECIPIENT,
value: "0",
data: "0x",
chainId: "0x1",
gasLimit: "21000",
maxFeePerGas: String(maxFeePerGas),
nonce: 0,
},
};
await approval.show("1");
}
beforeEach(() => {
elements.clear();
state.selectedToken = null;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
mockNode.feeData = { maxFeePerGas: 1n, gasPrice: 1n };
});
describe("an ETH balance below 0.000001 never renders as zero", () => {
test("on the Send screen", async () => {
await refreshWith(HALF_MICRO_ETH);
state.selectedToken = "ETH";
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 0.0000005 ETH");
});
test("on the confirmation screen", async () => {
await refreshWith(HALF_MICRO_ETH);
await confirmEthSend("0.0000001");
expect(text("confirm-balance")).toBe("0.0000005 ETH");
});
test("while a balance above the floor keeps four decimals", async () => {
await refreshWith(1234567890000000000n);
await confirmEthSend("0.1");
expect(text("confirm-balance")).toBe("1.2345 ETH");
});
});
describe("a fee below 0.000001 ETH never renders as zero", () => {
test("when the estimate and the reserve are the same", async () => {
await refreshWith(10n ** 18n);
await confirmEthSend("0.1");
// 21000 gas at 1 wei is 0.000000000000021 ETH, shown to its first
// significant digit.
expect(text("confirm-fee-amount")).toBe("0.00000000000002 ETH");
});
test("when they differ, on both lines", async () => {
mockNode.feeData = { maxFeePerGas: 2n, gasPrice: 1n };
await refreshWith(10n ** 18n);
await confirmEthSend("0.1");
expect(text("confirm-fee-amount")).toBe("~0.00000000000002 ETH");
expect(text("confirm-fee-reserve")).toBe(
"up to 0.00000000000004 ETH reserved",
);
});
});
// The confirmation screen shows the reserve alone when the node quotes no
// cheaper estimate, and that reserve is the same gas limit times maximum fee
// per gas that the approval screen calls the max fee. An ETH price is set, as
// it is on mainnet, so the USD value has to match too.
describe("the same fee reads the same on the confirmation and approval screens", () => {
beforeEach(() => {
prices.ETH = 3000;
});
afterEach(() => {
clearPrices();
});
test.each([
// 21000 gas at 1 wei.
["below the floor", 1n, "0.00000000000002 ETH (< $0.01)"],
// 0.001235294117631 ETH, which is $3.71. Pricing the truncated
// 0.0012 instead would read $3.60.
["with more than four decimals", 58823529411n, "0.0012 ETH ($3.71)"],
])("%s", async (_label, feePerGas, expected) => {
mockNode.feeData = { maxFeePerGas: feePerGas, gasPrice: feePerGas };
await refreshWith(10n ** 18n);
await confirmEthSend("0.1");
expect(text("confirm-fee-amount")).toBe(expected);
await approveTxWithFeePerGas(feePerGas);
expect(text("approve-tx-fee")).toBe(expected);
});
});
+3 -3
View File
@@ -389,7 +389,7 @@ describe("a scale the explorer's own rows disagree about", () => {
expect(txInfo.tokenBalance).toBe("5.0"); expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo); confirmTx.show(txInfo);
await settle(); await settle();
expect(text("confirm-balance")).toBe("5.0 NOVEL"); expect(text("confirm-balance")).toBe("5.0000 NOVEL");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(sendDisabled()).toBe(false); expect(sendDisabled()).toBe(false);
}); });
@@ -431,7 +431,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
const zero = await render("0.0"); const zero = await render("0.0");
expect(unknown.balance).not.toBe(zero.balance); expect(unknown.balance).not.toBe(zero.balance);
expect(unknown.balance).toBe("unknown (NOVEL)"); expect(unknown.balance).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0 NOVEL"); expect(zero.balance).toBe("0.0000 NOVEL");
}); });
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to // Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
@@ -443,7 +443,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
expect(unknown.errors).not.toBe(zero.errors); expect(unknown.errors).not.toBe(zero.errors);
expect(unknown.errors).toContain("This token&#39;s balance is unknown"); expect(unknown.errors).toContain("This token&#39;s balance is unknown");
expect(unknown.errors).not.toContain("You have"); expect(unknown.errors).not.toContain("You have");
expect(zero.errors).toContain("You have 0.0 NOVEL"); expect(zero.errors).toContain("You have 0.0000 NOVEL");
expect(zero.errors).not.toContain("balance is unknown"); expect(zero.errors).not.toContain("balance is unknown");
}); });
}); });