fix: show balances and fees below 0.000001 ETH as nonzero on the send screens (closes #343)
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, and the fee no longer matched the approval screen's. The ETH balance is now stored exactly, and the send screen's Current balance and the send-confirm screen's balance, fee, reserve and insufficient-balance messages go through truncateAmountNeverZero(), as the approval screen does. Token balances keep their six-decimal value: it is also what leaves dust off the balance list, and keeps the string within the 18 decimals the balance check reads. Model: opus-5-5
This commit is contained in:
+12
-12
@@ -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
|
||||
// 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_TEXT = "1.0";
|
||||
const FUNDED_ETH_TEXT = "1.0000";
|
||||
const COMFORTABLE_AMOUNT = "0.1";
|
||||
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
|
||||
// reserve, so the same swap flips this assertion too — through a different
|
||||
// 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 OVER_TOKEN_AMOUNT = "9.0";
|
||||
const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI;
|
||||
@@ -1439,16 +1440,15 @@ function toHexWei(wei) {
|
||||
return "0x" + wei.toString(16);
|
||||
}
|
||||
|
||||
// A fee in wei as the confirmation screen writes it. Deliberately a second
|
||||
// implementation of formatFeeEth() from src/popup/views/confirmTx.js rather
|
||||
// than an import of it: that module pulls in the whole popup and cannot be
|
||||
// required outside a browser, and asserting against an independent rendering
|
||||
// is stronger than asserting a function equals itself.
|
||||
// A fee in wei as the confirmation screen writes it: truncated to four decimal
|
||||
// places (README.md, Display Consistency). Deliberately a second
|
||||
// implementation rather than an import of src/shared/amountDisplay.js:
|
||||
// asserting against an independent rendering is stronger than asserting a
|
||||
// function equals itself. The fixture's fees are above 0.0001 ETH, so the
|
||||
// nonzero floor never applies here.
|
||||
function feeEth(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";
|
||||
const [whole, frac = ""] = formatEther(wei).split(".");
|
||||
return whole + "." + (frac + "0000").slice(0, 4) + " ETH";
|
||||
}
|
||||
|
||||
// What the confirmation screen is showing right now, read out of the DOM in
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
// 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 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.
|
||||
describe("the same fee reads the same on the confirmation and approval screens", () => {
|
||||
test.each([
|
||||
["below the floor", 1n],
|
||||
["with more than four decimals", 58823529411n],
|
||||
])("%s", async (_label, feePerGas) => {
|
||||
mockNode.feeData = { maxFeePerGas: feePerGas, gasPrice: feePerGas };
|
||||
await refreshWith(10n ** 18n);
|
||||
await confirmEthSend("0.1");
|
||||
const onConfirm = text("confirm-fee-amount");
|
||||
await approveTxWithFeePerGas(feePerGas);
|
||||
expect(text("approve-tx-fee")).toBe(onConfirm);
|
||||
expect(onConfirm).toMatch(/[1-9]/);
|
||||
});
|
||||
});
|
||||
@@ -389,7 +389,7 @@ describe("a scale the explorer's own rows disagree about", () => {
|
||||
expect(txInfo.tokenBalance).toBe("5.0");
|
||||
confirmTx.show(txInfo);
|
||||
await settle();
|
||||
expect(text("confirm-balance")).toBe("5.0 NOVEL");
|
||||
expect(text("confirm-balance")).toBe("5.0000 NOVEL");
|
||||
expect(errors()).toBe("");
|
||||
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");
|
||||
expect(unknown.balance).not.toBe(zero.balance);
|
||||
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
|
||||
@@ -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).toContain("This token's balance is unknown");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user