fix: the wallet's own send shows its amount to four decimal places (closes #522)
ConfirmTx and the wait, success and error screens printed the text from Send's amount field as typed or as Max filled it in: `0.5 ETH`, or `1.234147890123456789 ETH` after Max, where TxApproval shows a site's send as `0.5000 ETH`. ConfirmTx now shows the amount through truncateAmountNeverZero(), as TxApproval does, and hands the status screens a copy carrying that text. The transaction is still signed for the exact amount, which stays on the pending transaction, and the USD value is priced from it. A saved confirmation screen whose amount is not text is no longer redrawn, since the amount is now split. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
// The amount of the wallet's own send on the confirmation, wait, success and
|
||||
// error screens (https://git.eeqj.de/sneak/AutistMask/issues/522).
|
||||
//
|
||||
// These screens printed the text from Send's amount field as it was typed or
|
||||
// filled in: `0.5 ETH`, or `1.234147890123456789 ETH` after Max, where the
|
||||
// approval screen shows a site's send as `0.5000 ETH`. They now show it to four
|
||||
// decimal places with the nonzero floor, as the approval screen does, and the
|
||||
// transaction is still signed for the exact amount.
|
||||
//
|
||||
// Driven through the real Send screen, confirmation screen, Sign & Send and
|
||||
// wait screen, with only the node, the explorer and the DOM stubbed.
|
||||
|
||||
"use strict";
|
||||
|
||||
// What the stub node answers, and the signed transactions it was handed. The
|
||||
// token's own decimals() answers 6, as the explorer does.
|
||||
const mockNode = {
|
||||
balanceWei: 0n,
|
||||
tokenUnits: 0n,
|
||||
tokenDecimals: 6n,
|
||||
broadcast: [],
|
||||
broadcastError: null,
|
||||
receipt: null,
|
||||
};
|
||||
|
||||
// The token rows the stub explorer reports for the address.
|
||||
const mockExplorer = { items: [] };
|
||||
|
||||
jest.mock("ethers", () => {
|
||||
const actual = jest.requireActual("ethers");
|
||||
// The selector of decimals(); every other contract call is balanceOf().
|
||||
const DECIMALS = "0x313ce567";
|
||||
class StubProvider {
|
||||
async getBalance() {
|
||||
return mockNode.balanceWei;
|
||||
}
|
||||
async call(tx) {
|
||||
return actual.AbiCoder.defaultAbiCoder().encode(
|
||||
["uint256"],
|
||||
[
|
||||
tx.data.startsWith(DECIMALS)
|
||||
? mockNode.tokenDecimals
|
||||
: mockNode.tokenUnits,
|
||||
],
|
||||
);
|
||||
}
|
||||
async lookupAddress() {
|
||||
return null;
|
||||
}
|
||||
// 20 gwei reserved per gas.
|
||||
async getFeeData() {
|
||||
return {
|
||||
maxFeePerGas: 20000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
gasPrice: 10000000000n,
|
||||
};
|
||||
}
|
||||
async estimateGas() {
|
||||
return 21000n;
|
||||
}
|
||||
async getCode() {
|
||||
return "0x";
|
||||
}
|
||||
async getTransactionCount() {
|
||||
return 1;
|
||||
}
|
||||
async getNetwork() {
|
||||
return { chainId: 1n };
|
||||
}
|
||||
async broadcastTransaction(signed) {
|
||||
if (mockNode.broadcastError) throw mockNode.broadcastError;
|
||||
mockNode.broadcast.push(signed);
|
||||
return { hash: "0x" + "ab".repeat(32) };
|
||||
}
|
||||
async getTransactionReceipt() {
|
||||
return mockNode.receipt;
|
||||
}
|
||||
}
|
||||
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.
|
||||
debugFetch: jest.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockExplorer.items,
|
||||
})),
|
||||
urlOrigin: () => "",
|
||||
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");
|
||||
});
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
|
||||
|
||||
// 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,
|
||||
children: [],
|
||||
addEventListener(name, fn) {
|
||||
handlers.set(name, fn);
|
||||
},
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
remove() {},
|
||||
focus() {},
|
||||
// The success screen hides whole sections through .parentElement.
|
||||
get parentElement() {
|
||||
return global.document.getElementById(id + "-parent");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
Interface,
|
||||
Transaction,
|
||||
Wallet,
|
||||
getAddress,
|
||||
parseEther,
|
||||
} = require("ethers");
|
||||
const { refreshBalances } = require("../src/shared/balances");
|
||||
const { encryptWithPassword } = require("../src/shared/vault");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { clearPrices } = require("../src/shared/prices");
|
||||
const send = require("../src/popup/views/send");
|
||||
const confirmTx = require("../src/popup/views/confirmTx");
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
|
||||
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
||||
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
const PASSWORD = "correct horse battery staple";
|
||||
|
||||
const GWEI = 1000000000n;
|
||||
// 1.234567890123456789 ETH.
|
||||
const BALANCE_WEI = 1234567890123456789n;
|
||||
|
||||
// A token the bundled list does not know, with enough holders to be listed,
|
||||
// at 6 decimal places.
|
||||
const TOKEN = "0x" + "d".repeat(40);
|
||||
|
||||
const transferIface = new Interface([
|
||||
"function transfer(address to, uint256 amount)",
|
||||
]);
|
||||
|
||||
let encryptedSecret;
|
||||
|
||||
// The confirmation screen Review leads to, once shown.
|
||||
let confirmed = null;
|
||||
|
||||
function el(id) {
|
||||
return global.document.getElementById(id);
|
||||
}
|
||||
|
||||
function text(id) {
|
||||
return el(id).textContent;
|
||||
}
|
||||
|
||||
// show() reads the balance and then estimates the fee without awaiting
|
||||
// either; this lets both settle.
|
||||
async function settle() {
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
// The address holds BALANCE_WEI and, when `tokenUnits` is given, that many
|
||||
// base units of TOKEN, fetched and stored where the popup stores them. Send
|
||||
// is then open for `token` ("ETH" or TOKEN) with the recipient entered.
|
||||
async function openSend(token, tokenUnits = null) {
|
||||
mockNode.balanceWei = BALANCE_WEI;
|
||||
mockNode.tokenUnits = tokenUnits ?? 0n;
|
||||
mockExplorer.items =
|
||||
tokenUnits === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: String(tokenUnits),
|
||||
token: {
|
||||
type: "ERC-20",
|
||||
address_hash: TOKEN,
|
||||
symbol: "TOK",
|
||||
name: "Token",
|
||||
decimals: "6",
|
||||
holders_count: "50000",
|
||||
},
|
||||
},
|
||||
];
|
||||
state.wallets = [
|
||||
{
|
||||
type: "key",
|
||||
name: "Wallet 1",
|
||||
encryptedSecret,
|
||||
addresses: [{ address: HOLDER }],
|
||||
},
|
||||
];
|
||||
state.selectedWallet = 0;
|
||||
state.selectedAddress = 0;
|
||||
await refreshBalances(
|
||||
state.wallets,
|
||||
"https://rpc.example.invalid",
|
||||
"https://blockscout.example/api/v2",
|
||||
state.trackedTokens,
|
||||
"mainnet",
|
||||
);
|
||||
send.init({
|
||||
showConfirmTx: (info) => (confirmed = info),
|
||||
pageClosed: new AbortController().signal,
|
||||
});
|
||||
confirmTx.init({});
|
||||
send.resetSendValidation();
|
||||
state.currentView = "send";
|
||||
state.selectedToken = token;
|
||||
el("send-to").value = RECIPIENT;
|
||||
el("send-amount").value = "";
|
||||
}
|
||||
|
||||
// Press Review and show the confirmation screen with its fee estimate settled.
|
||||
async function review() {
|
||||
await el("btn-send-review").handlers.get("click")();
|
||||
confirmTx.show(confirmed);
|
||||
await settle();
|
||||
}
|
||||
|
||||
async function signAndSend() {
|
||||
el("confirm-tx-password").value = PASSWORD;
|
||||
await el("btn-confirm-send").handlers.get("click")();
|
||||
}
|
||||
|
||||
// The one transaction the stub node was handed.
|
||||
function broadcastTx() {
|
||||
expect(mockNode.broadcast).toHaveLength(1);
|
||||
return Transaction.from(mockNode.broadcast[0]);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
elements.clear();
|
||||
clearPrices();
|
||||
confirmed = null;
|
||||
state.selectedToken = null;
|
||||
state.trackedTokens = [];
|
||||
state.fraudContracts = [];
|
||||
state.currentView = null;
|
||||
mockNode.broadcast = [];
|
||||
mockNode.broadcastError = null;
|
||||
mockNode.receipt = null;
|
||||
txStatus.init({
|
||||
doRefreshAndRender() {},
|
||||
pageClosed: new AbortController().signal,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
txStatus.endWait();
|
||||
});
|
||||
|
||||
describe("the wallet's own send shows its amount to four decimal places", () => {
|
||||
test("for a short amount", async () => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = "0.5";
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.5000 ETH");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
|
||||
expect(broadcastTx().value).toBe(parseEther("0.5"));
|
||||
});
|
||||
|
||||
test("for a Max amount with many decimals", async () => {
|
||||
await openSend("ETH");
|
||||
await el("btn-send-max").handlers.get("click")();
|
||||
// The balance minus 21000 gas at 20 gwei.
|
||||
expect(el("send-amount").value).toBe("1.234147890123456789");
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("1.2341 ETH");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("1.2341 ETH");
|
||||
expect(broadcastTx().value).toBe(BALANCE_WEI - 21000n * 20n * GWEI);
|
||||
});
|
||||
|
||||
// Cut to four decimals it would read 0.0000, so it is shown to its first
|
||||
// significant digit instead, and signed in full.
|
||||
test("for a tiny nonzero amount", async () => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = "0.000012345";
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.00001 ETH");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("0.00001 ETH");
|
||||
expect(broadcastTx().value).toBe(parseEther("0.000012345"));
|
||||
});
|
||||
|
||||
test("for a token amount", async () => {
|
||||
// 100 TOK.
|
||||
await openSend(TOKEN, 100000000n);
|
||||
el("send-amount").value = "12.345678";
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("12.3456 TOK");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("12.3456 TOK");
|
||||
const tx = broadcastTx();
|
||||
expect(tx.to).toBe(getAddress(TOKEN));
|
||||
const [to, amount] = transferIface.decodeFunctionData(
|
||||
"transfer",
|
||||
tx.data,
|
||||
);
|
||||
expect(to).toBe(RECIPIENT);
|
||||
expect(amount).toBe(12345678n);
|
||||
});
|
||||
|
||||
test("on the success screen", async () => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = "0.5";
|
||||
await review();
|
||||
await signAndSend();
|
||||
// A popup opened later resumes the wait, and the receipt is there.
|
||||
mockNode.receipt = { blockNumber: 21000000 };
|
||||
txStatus.restoreWait();
|
||||
await settle();
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
expect(text("success-tx-summary")).toBe("0.5000 ETH");
|
||||
});
|
||||
|
||||
test("on the error screen", async () => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = "0.5";
|
||||
await review();
|
||||
mockNode.broadcastError = new Error("the node refused it");
|
||||
await signAndSend();
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(text("error-tx-summary")).toBe("0.5000 ETH");
|
||||
});
|
||||
});
|
||||
@@ -333,7 +333,7 @@ function expectNativeTransferOn(networkId) {
|
||||
"Native " + symbol + " transfer",
|
||||
);
|
||||
expect(el("confirm-amount").textContent).toBe(
|
||||
"1 " + symbol + usdOn(networkId, "$2,000.00"),
|
||||
"1.0000 " + symbol + usdOn(networkId, "$2,000.00"),
|
||||
);
|
||||
expect(el("confirm-balance").textContent).toBe(
|
||||
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
|
||||
@@ -649,7 +649,7 @@ describe.each([
|
||||
`href="${explorerUrl}/address/${TOKEN}"`,
|
||||
);
|
||||
expect(el("confirm-amount").textContent).toBe(
|
||||
"10 USDC" + usdOn(reviewedOn, "$10.00"),
|
||||
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
|
||||
);
|
||||
expect(el("confirm-balance").textContent).toBe(
|
||||
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
|
||||
|
||||
+2
-1
@@ -2915,9 +2915,10 @@ async function goToTokenConfirm(env) {
|
||||
amount: TOKEN_AMOUNT,
|
||||
});
|
||||
await waitForEstimate(env.page);
|
||||
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
|
||||
const shown = await shownAmount(env.page);
|
||||
assert(
|
||||
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
|
||||
shown === "0.2500 " + STUB_TOKEN.symbol,
|
||||
"the confirmation screen is not showing the amount that was entered: " +
|
||||
JSON.stringify(shown),
|
||||
);
|
||||
|
||||
@@ -245,7 +245,7 @@ describe.each([
|
||||
test("the value", async () => {
|
||||
await confirmSend("0.1");
|
||||
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
|
||||
expect(text("confirm-amount")).toBe("0.1 " + symbol);
|
||||
expect(text("confirm-amount")).toBe("0.1000 " + symbol);
|
||||
await approveTx(NETWORKS[networkId].chainId);
|
||||
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
|
||||
});
|
||||
@@ -429,10 +429,10 @@ describe.each([
|
||||
await global.document
|
||||
.getElementById("btn-confirm-send")
|
||||
.handlers.get("click")();
|
||||
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
|
||||
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
|
||||
state.networkId = activeNetworkId;
|
||||
txStatus.restoreWait();
|
||||
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
|
||||
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
|
||||
});
|
||||
|
||||
test("the transaction detail screen's type line and fee", async () => {
|
||||
|
||||
@@ -477,6 +477,19 @@ const CONTRACT = [
|
||||
views: ["confirm-tx"],
|
||||
restored: false,
|
||||
},
|
||||
// The amount line splits the amount to cut it to four decimals.
|
||||
{
|
||||
value: {
|
||||
pendingTx: {
|
||||
token: "ETH",
|
||||
from: ADDRESS,
|
||||
to: ADDRESS,
|
||||
amount: 42,
|
||||
},
|
||||
},
|
||||
views: ["confirm-tx"],
|
||||
restored: false,
|
||||
},
|
||||
// A chain id naming no network has no explorer to link to.
|
||||
{
|
||||
value: {
|
||||
|
||||
+12
-6
@@ -306,7 +306,8 @@ describe("Max on an ETH send", () => {
|
||||
openSend();
|
||||
await pressMax();
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
|
||||
expect(confirmed.amount).toBe(maxAfter(20n * GWEI));
|
||||
expect(text("confirm-amount")).toBe("1.2341 ETH");
|
||||
expect(el("confirm-errors").innerHTML).toBe("");
|
||||
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
|
||||
expect(canSend()).toBe(true);
|
||||
@@ -322,14 +323,16 @@ describe("Max on an ETH send", () => {
|
||||
// Kept, the Send screen's amount would be refused for want of funds.
|
||||
mockNode.feeData = fees(30n * GWEI);
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
|
||||
expect(confirmed.amount).toBe(maxAfter(30n * GWEI));
|
||||
expect(text("confirm-amount")).toBe("1.2339 ETH");
|
||||
expect(canSend()).toBe(true);
|
||||
|
||||
// And falls when the screen is shown again, as on reopening the popup.
|
||||
mockNode.feeData = fees(10n * GWEI);
|
||||
confirmTx.restore();
|
||||
await settle();
|
||||
expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
|
||||
expect(confirmed.amount).toBe(maxAfter(10n * GWEI));
|
||||
expect(text("confirm-amount")).toBe("1.2343 ETH");
|
||||
expect(canSend()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -526,7 +529,8 @@ describe("Max on an ETH send", () => {
|
||||
el("send-amount").handlers.get("input")();
|
||||
mockNode.feeData = fees(30n * GWEI);
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.5 ETH");
|
||||
expect(confirmed.amount).toBe("0.5");
|
||||
expect(text("confirm-amount")).toBe("0.5000 ETH");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -540,7 +544,8 @@ describe("Max on a token send", () => {
|
||||
await pressMax();
|
||||
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
|
||||
expect(confirmed.amount).toBe("1234.567890123456789012");
|
||||
expect(text("confirm-amount")).toBe("1234.5678 TOK");
|
||||
expect(canSend()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -553,7 +558,8 @@ describe("Max on a token send", () => {
|
||||
await pressMax();
|
||||
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
|
||||
expect(confirmed.amount).toBe("1234.567890123456789012");
|
||||
expect(text("confirm-amount")).toBe("1234.5678 TOK");
|
||||
expect(canSend()).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user