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`, `.5 ETH`, or `1.234147890123456789 ETH` after Max, where TxApproval shows a site's send as `0.5000 ETH`. ConfirmTx now writes the amount as TxApproval does: parsed at the scale it is encoded with, written back as plain decimal text, and cut by truncateAmountNeverZero(). It hands the status screens a copy carrying that text. A token whose scale is unknown, which cannot be sent, is cut as typed. The transaction is still signed for the exact amount, kept 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. Model: opus-5-5
This commit is contained in:
@@ -1082,19 +1082,21 @@ The rule and its exception live in `src/shared/amountDisplay.js` as
|
|||||||
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`), the swap's `Amount`
|
the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
|
||||||
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
|
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
|
||||||
`Current balance` (`src/popup/views/send.js`), and the balance and network fee
|
`Current balance` (`src/popup/views/send.js`), and the amount, balance and
|
||||||
on the confirmation screen for the wallet's own send
|
network fee on the confirmation screen for the wallet's own send
|
||||||
(`src/popup/views/confirmTx.js`). Both screens render a network fee through
|
(`src/popup/views/confirmTx.js`), which hands the wait, success and error
|
||||||
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD
|
screens the amount as it showed it. The transaction is still signed for the
|
||||||
rather than its truncated figure, so the same fee reads the same on both, USD
|
exact amount, and its USD value is priced from it. Both screens render a network
|
||||||
value included. Balances are stored exactly (`src/shared/balances.js`), whatever
|
fee through `formatFee()` in `src/popup/views/helpers.js`, which prices the
|
||||||
decimals a token declares, so a balance below the floor reaches these screens as
|
exact fee in USD rather than its truncated figure, so the same fee reads the
|
||||||
it is. The history list (`src/shared/transactions.js`) uses the unfloored one:
|
same on both, USD value included. Balances are stored exactly
|
||||||
the transaction detail view is the authoritative record and already shows exact
|
(`src/shared/balances.js`), whatever decimals a token declares, so a balance
|
||||||
precision. The balance lists use neither: they round to four places with
|
below the floor reaches these screens as it is. The history list
|
||||||
`toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). The 4-decimal
|
(`src/shared/transactions.js`) uses the unfloored one: the transaction detail
|
||||||
rule is unchanged everywhere else, including for amounts at or above the floor
|
view is the authoritative record and already shows exact precision. The balance
|
||||||
on the approval screens.
|
lists use neither: they round to four places with `toFixed(4)` (`balanceLine()`
|
||||||
|
in `src/popup/views/helpers.js`). 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
|
||||||
@@ -1763,7 +1765,11 @@ view would leave a wallet one click from deletion.
|
|||||||
- Token contract: full address + etherscan link (ERC-20 only)
|
- Token contract: full address + etherscan link (ERC-20 only)
|
||||||
- From: blockie + color dot + full address + etherscan link + wallet title
|
- From: blockie + color dot + full address + etherscan link + wallet title
|
||||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||||
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
|
- Amount: value + symbol (USD in parentheses), the value to four decimal
|
||||||
|
places with the nonzero floor, as on TxApproval (see Display Consistency),
|
||||||
|
however Send's amount field wrote it (`.5` and `0.5` both read `0.5000`),
|
||||||
|
except for a token whose scale is unknown, which cannot be sent: its
|
||||||
|
amount is cut as typed (`.5` reads `.5000`). An ETH amount Send's "Max"
|
||||||
filled in is worked out again from this screen's own fee estimate when it
|
filled in is worked out again from this screen's own fee estimate when it
|
||||||
arrives, as the balance minus the reserve, and the transaction is signed
|
arrives, as the balance minus the reserve, and the transaction is signed
|
||||||
with that estimate's fee fields, so a fee fetched again at signing cannot
|
with that estimate's fee fields, so a fee fetched again at signing cannot
|
||||||
|
|||||||
@@ -127,16 +127,18 @@ function isRenderableDecoded(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The pending transaction confirmTx.show() renders: `token` reaches
|
// The pending transaction confirmTx.show() renders: `token` reaches
|
||||||
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
|
// renderAddressHtml() when it is not "ETH", `from`/`to` reach
|
||||||
// addressTitle(), makeBlockie() and getLocalWarnings(). `chainId`, which
|
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
|
||||||
// show() fills in when it is absent, picks the network whose explorer the
|
// truncateAmountNeverZero(), which splits it. `chainId`, which show() fills in
|
||||||
// address links open, so it has to name one.
|
// when it is absent, picks the network whose explorer the address links open,
|
||||||
|
// so it has to name one.
|
||||||
function isRenderablePendingTx(value) {
|
function isRenderablePendingTx(value) {
|
||||||
return (
|
return (
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
isText(value.token) &&
|
isText(value.token) &&
|
||||||
isAddressText(value.from) &&
|
isAddressText(value.from) &&
|
||||||
isAddressText(value.to) &&
|
isAddressText(value.to) &&
|
||||||
|
isText(value.amount) &&
|
||||||
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
|
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ const {
|
|||||||
transferAmountUnits,
|
transferAmountUnits,
|
||||||
} = require("../../shared/transferAmount");
|
} = require("../../shared/transferAmount");
|
||||||
const { assertWithinCeilings } = require("../../shared/approvalVerify");
|
const { assertWithinCeilings } = require("../../shared/approvalVerify");
|
||||||
// The balance line and the fee reserve go through it, as the approval screen's
|
// The amount, the balance line and the fee reserve go through it, as the
|
||||||
// amounts do.
|
// approval screen's amounts do.
|
||||||
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
|
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
|
||||||
const {
|
const {
|
||||||
CODES,
|
CODES,
|
||||||
@@ -100,8 +100,32 @@ function valueWithUsd(text, usdAmount) {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Amount line, with its USD value. A max ETH send's line is drawn again
|
// The amount as this screen and the wait, success and error screens show it,
|
||||||
// once its amount is re-derived from the fee estimate.
|
// written as the approval screen writes a site's amount: the amount being
|
||||||
|
// signed, parsed at the scale it is encoded with and written back as plain
|
||||||
|
// decimal text, then cut to four decimals with the nonzero floor. So `.5` and
|
||||||
|
// `00.5` read `0.5000`, as `0.5` does, except for a token whose scale is
|
||||||
|
// unknown, which cannot be sent: its amount is cut as typed, so `.5` reads
|
||||||
|
// `.5000`. txInfo.amount, which is what gets signed, is left as it is.
|
||||||
|
function shownAmount(txInfo) {
|
||||||
|
let exact = txInfo.amount;
|
||||||
|
try {
|
||||||
|
if (txInfo.token === "ETH") {
|
||||||
|
exact = formatEther(parseEther(txInfo.amount));
|
||||||
|
} else {
|
||||||
|
const decimals = displayedDecimals(txInfo.tokenDecimals);
|
||||||
|
exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A token whose scale is unknown, which is never signed, or an amount
|
||||||
|
// the amount check refuses: cut as Send's amount field held it.
|
||||||
|
}
|
||||||
|
return truncateAmountNeverZero(exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Amount line, with its USD value, which is priced from the exact amount.
|
||||||
|
// A max ETH send's line is drawn again once its amount is re-derived from the
|
||||||
|
// fee estimate.
|
||||||
function renderAmount(txInfo) {
|
function renderAmount(txInfo) {
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
const rawSymbol = isErc20
|
const rawSymbol = isErc20
|
||||||
@@ -110,7 +134,7 @@ function renderAmount(txInfo) {
|
|||||||
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
|
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
|
||||||
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
||||||
$("confirm-amount").textContent = valueWithUsd(
|
$("confirm-amount").textContent = valueWithUsd(
|
||||||
txInfo.amount + " " + displaySymbol(rawSymbol),
|
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
|
||||||
amountUsd,
|
amountUsd,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -698,6 +722,10 @@ function init(_ctx) {
|
|||||||
$("btn-confirm-send").disabled = true;
|
$("btn-confirm-send").disabled = true;
|
||||||
$("btn-confirm-send").classList.add("text-muted");
|
$("btn-confirm-send").classList.add("text-muted");
|
||||||
|
|
||||||
|
// The wait, success and error screens show the amount as this screen
|
||||||
|
// does, so they are handed a copy carrying that text. pendingTx keeps
|
||||||
|
// the exact amount, which is the one signed.
|
||||||
|
const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) };
|
||||||
let tx;
|
let tx;
|
||||||
try {
|
try {
|
||||||
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
|
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
|
||||||
@@ -714,7 +742,7 @@ function init(_ctx) {
|
|||||||
// Note: JS strings are immutable; this nulls the reference but
|
// Note: JS strings are immutable; this nulls the reference but
|
||||||
// the original string may persist in memory until GC.
|
// the original string may persist in memory until GC.
|
||||||
decryptedSecret = null;
|
decryptedSecret = null;
|
||||||
txStatus.showWait(pendingTx, tx.hash);
|
txStatus.showWait(shownTx, tx.hash);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
decryptedSecret = null;
|
decryptedSecret = null;
|
||||||
// A gas limit or fee over its bound is refused before anything is
|
// A gas limit or fee over its bound is refused before anything is
|
||||||
@@ -730,7 +758,7 @@ function init(_ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hash = tx ? tx.hash : null;
|
const hash = tx ? tx.hash : null;
|
||||||
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
|
txStatus.showError(shownTx, hash, e.shortMessage || e.message);
|
||||||
} finally {
|
} finally {
|
||||||
$("btn-confirm-send").disabled = false;
|
$("btn-confirm-send").disabled = false;
|
||||||
$("btn-confirm-send").classList.remove("text-muted");
|
$("btn-confirm-send").classList.remove("text-muted");
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
// 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"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send accepts both. The approval screen writes the same value `0.5000`.
|
||||||
|
test.each([".5", "00.5"])("for an amount typed as %s", async (typed) => {
|
||||||
|
await openSend("ETH");
|
||||||
|
el("send-amount").value = typed;
|
||||||
|
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 token amount typed as .5", async () => {
|
||||||
|
// 100 TOK.
|
||||||
|
await openSend(TOKEN, 100000000n);
|
||||||
|
el("send-amount").value = ".5";
|
||||||
|
await review();
|
||||||
|
expect(text("confirm-amount")).toBe("0.5000 TOK");
|
||||||
|
|
||||||
|
await signAndSend();
|
||||||
|
expect(text("wait-tx-summary")).toBe("0.5000 TOK");
|
||||||
|
const [, amount] = transferIface.decodeFunctionData(
|
||||||
|
"transfer",
|
||||||
|
broadcastTx().data,
|
||||||
|
);
|
||||||
|
expect(amount).toBe(500000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
"Native " + symbol + " transfer",
|
||||||
);
|
);
|
||||||
expect(el("confirm-amount").textContent).toBe(
|
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(
|
expect(el("confirm-balance").textContent).toBe(
|
||||||
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
|
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
|
||||||
@@ -649,7 +649,7 @@ describe.each([
|
|||||||
`href="${explorerUrl}/address/${TOKEN}"`,
|
`href="${explorerUrl}/address/${TOKEN}"`,
|
||||||
);
|
);
|
||||||
expect(el("confirm-amount").textContent).toBe(
|
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(
|
expect(el("confirm-balance").textContent).toBe(
|
||||||
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
|
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
|
||||||
|
|||||||
+2
-1
@@ -2915,9 +2915,10 @@ async function goToTokenConfirm(env) {
|
|||||||
amount: TOKEN_AMOUNT,
|
amount: TOKEN_AMOUNT,
|
||||||
});
|
});
|
||||||
await waitForEstimate(env.page);
|
await waitForEstimate(env.page);
|
||||||
|
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
|
||||||
const shown = await shownAmount(env.page);
|
const shown = await shownAmount(env.page);
|
||||||
assert(
|
assert(
|
||||||
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
|
shown === "0.2500 " + STUB_TOKEN.symbol,
|
||||||
"the confirmation screen is not showing the amount that was entered: " +
|
"the confirmation screen is not showing the amount that was entered: " +
|
||||||
JSON.stringify(shown),
|
JSON.stringify(shown),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ describe.each([
|
|||||||
test("the value", async () => {
|
test("the value", async () => {
|
||||||
await confirmSend("0.1");
|
await confirmSend("0.1");
|
||||||
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
|
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);
|
await approveTx(NETWORKS[networkId].chainId);
|
||||||
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
|
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
|
||||||
});
|
});
|
||||||
@@ -429,10 +429,10 @@ describe.each([
|
|||||||
await global.document
|
await global.document
|
||||||
.getElementById("btn-confirm-send")
|
.getElementById("btn-confirm-send")
|
||||||
.handlers.get("click")();
|
.handlers.get("click")();
|
||||||
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
|
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
|
||||||
state.networkId = activeNetworkId;
|
state.networkId = activeNetworkId;
|
||||||
txStatus.restoreWait();
|
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 () => {
|
test("the transaction detail screen's type line and fee", async () => {
|
||||||
|
|||||||
@@ -477,6 +477,19 @@ const CONTRACT = [
|
|||||||
views: ["confirm-tx"],
|
views: ["confirm-tx"],
|
||||||
restored: false,
|
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.
|
// A chain id naming no network has no explorer to link to.
|
||||||
{
|
{
|
||||||
value: {
|
value: {
|
||||||
|
|||||||
+12
-6
@@ -306,7 +306,8 @@ describe("Max on an ETH send", () => {
|
|||||||
openSend();
|
openSend();
|
||||||
await pressMax();
|
await pressMax();
|
||||||
await review();
|
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-errors").innerHTML).toBe("");
|
||||||
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
|
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
|
||||||
expect(canSend()).toBe(true);
|
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.
|
// Kept, the Send screen's amount would be refused for want of funds.
|
||||||
mockNode.feeData = fees(30n * GWEI);
|
mockNode.feeData = fees(30n * GWEI);
|
||||||
await review();
|
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);
|
expect(canSend()).toBe(true);
|
||||||
|
|
||||||
// And falls when the screen is shown again, as on reopening the popup.
|
// And falls when the screen is shown again, as on reopening the popup.
|
||||||
mockNode.feeData = fees(10n * GWEI);
|
mockNode.feeData = fees(10n * GWEI);
|
||||||
confirmTx.restore();
|
confirmTx.restore();
|
||||||
await settle();
|
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);
|
expect(canSend()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -526,7 +529,8 @@ describe("Max on an ETH send", () => {
|
|||||||
el("send-amount").handlers.get("input")();
|
el("send-amount").handlers.get("input")();
|
||||||
mockNode.feeData = fees(30n * GWEI);
|
mockNode.feeData = fees(30n * GWEI);
|
||||||
await review();
|
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();
|
await pressMax();
|
||||||
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
||||||
await review();
|
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);
|
expect(canSend()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -553,7 +558,8 @@ describe("Max on a token send", () => {
|
|||||||
await pressMax();
|
await pressMax();
|
||||||
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
||||||
await review();
|
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);
|
expect(canSend()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user