2 Commits
Author SHA1 Message Date
sneak fb7c75101f fix: the balance lists and Home cut amounts to four decimals (closes #757)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The balance lists and Home's ETH line rounded with toFixed(4), so 0.99996
ETH read 1.0000 there and 0.9999 on Send. They now cut the stored decimal
string with truncateAmount(), as the history rows do. balanceLine() takes
the stored balance string instead of a parsed number, and escapes the
quantity cell. The screens that state what is authorized keep the floor
of truncateAmountNeverZero().

Model: opus-5-5
2026-11-10 21:54:45 +00:00
clawbot 6d158bf1b1 refactor: move the calldata decoder to src/shared/calldataDecoder.js (closes #759)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
decodeCalldata() and tokenAmountText() move out of the TxApproval view into
src/shared/calldataDecoder.js. TxApproval, TransactionDetail and the decoder
tests import them from there.

tokenSourcesByChainId() moves from the popup view helpers to
src/shared/state.js, beside currentNetwork(), so the decoder requires nothing
under src/popup/. It reads only the state singleton and the network table.

Model: opus-5-5
2026-11-10 22:33:26 +01:00
21 changed files with 289 additions and 248 deletions

No files matched your search

+15 -10
View File
@@ -264,6 +264,8 @@ The shared modules, each in one line:
messaging, windows and the lock.
- `buildInfo.js`: the version, license, author, commit and date compiled into
About.
- `calldataDecoder.js`: the ERC-20 `approve()` and `transfer()` decoder, which
hands other calldata to `uniswap.js`. Popup only.
- `chainSwitch.js`: the popup's network switch.
- `chainSwitchFields.js`: the fields a network switch changes.
- `constants.js`: `DEBUG`, the default endpoints, the ERC-20 ABI and the burn
@@ -1187,17 +1189,20 @@ These helpers write one kind of value on every screen that shows it:
times;
- `renderAddressHtml()` in the same file: most addresses.
Every screen shows token and ETH amounts the same way, through
`truncateAmountNeverZero()` in `src/shared/amountDisplay.js`:
Most screens show token and ETH amounts with four decimal places. History rows
cut amounts to four decimals and never round up (`truncateAmount()` in
`src/shared/amountDisplay.js`). The balance lists and Home's ETH balance cut
them the same way. The screens that state what is being authorized cut as the
history does, and extend an amount below 0.0001 to its first nonzero digit
(`truncateAmountNeverZero()`). So a nonzero amount never reads as zero there.
Those screens are:
- an amount is cut to four decimal places, never rounded, so 0.99996 reads
`0.9999`;
- an amount below 0.0001 is shown to its first nonzero digit, so 0.00004 reads
`0.00004`.
- TxApproval;
- SignApproval's Permit warning;
- ConfirmTx, and Send's current balance;
- WaitTx, SuccessTx and ErrorTx.
So an amount never reads as more than it is, and a nonzero amount never reads as
zero. TransactionDetail is the one exception: it shows the exact amount and its
base units.
TransactionDetail shows the exact amount and its base units.
An amount to send is valid when it is a number above zero, written as digits
with at most one decimal point. Nothing but zeros may follow the token's last
@@ -1376,7 +1381,7 @@ approval screens show a red warning.
### Transaction decoding
TxApproval and TransactionDetail try to decode calldata into a summary
(`decodeCalldata()` in `src/popup/views/approval.js`). SuccessTx repeats
(`decodeCalldata()` in `src/shared/calldataDecoder.js`). SuccessTx repeats
TxApproval's summary. Decoding is a display aid. When it fails, the raw calldata
is shown in full. No contract gets any treatment beyond a legible confirmation
screen.
+12 -144
View File
@@ -12,9 +12,13 @@ const {
formatFee,
tokenLabel,
blockscoutOffTokenNoteHtml,
tokenSourcesByChainId,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const {
state,
saveState,
currentNetwork,
tokenSourcesByChainId,
} = require("../../shared/state");
const {
networkById,
networkByChainId,
@@ -26,21 +30,24 @@ const {
getAddress,
getBigInt,
getBytes,
Interface,
MaxUint256,
toUtf8String,
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
const {
resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount,
} = require("../../shared/approvalAmount");
const {
decodeCalldata,
tokenAmountText,
} = require("../../shared/calldataDecoder");
// Four decimals, with the nonzero floor these screens hold: every amount this
// view renders — the ERC-20 line, the ETH value, the max fee — and every one
// it carries forward to the wait/success/error screens goes through it.
// it carries forward to the wait/success/error screens goes through it. The
// ERC-20 line gets it from tokenAmountText().
const {
truncateAmountNeverZero: formatTxValue,
} = require("../../shared/amountDisplay");
@@ -54,11 +61,8 @@ const {
typedDataChainRefusal,
} = require("../../shared/approvalVerify");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const { runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI);
// The link opens the explorer of the network with `chainId`, or of the active
// network when none is given. A chain id that names no network has no
// explorer, so the address is drawn without a link.
@@ -70,141 +74,6 @@ function approvalAddressHtml(address, chainId) {
return renderAddressHtml(address, { title, chainId });
}
// The amount line for a decoded ERC-20 call. With a known scale it is the
// token quantity; with `decimals` null it is the base-unit integer with the
// unknown scale stated, because formatting it with an assumed scale is what
// showed a 5,000-token transfer as `0.0000`. `raw` is what the status screens
// carry, `display` is what the approval screen shows.
function tokenAmountText(rawAmount, decimals, symbol) {
if (decimals === null) {
const unknown = unknownDecimalsAmount(rawAmount);
return { raw: unknown, display: unknown };
}
const formatted = formatTxValue(formatUnits(rawAmount, decimals));
return {
raw: formatted,
display: formatted + (symbol ? " " + symbol : ""),
};
}
// Try to decode calldata sent from `fromAddress` to `toAddress` on the network
// with chain id `chainId`, using known ABIs.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress, chainId, fromAddress) {
if (!data || data === "0x" || data.length < 10) return null;
// Where a token's name and scale are looked for, for every decoder below:
// what the wallet knows of the tokens on the network with `chainId`, which
// need not be the active one. The ERC-20 amount line and the swap's Amount
// and Min. received lines resolve the scale the same way, and refuse to
// format the same way when it is nowhere.
const decimalsSources = tokenSourcesByChainId(chainId);
// Try ERC-20 (approve / transfer)
try {
const parsed = erc20Iface.parseTransaction({ data });
if (parsed) {
const tokenSymbol = resolveTokenSymbol(toAddress, decimalsSources);
// null when no source knows this token's scale. It is not
// defaulted to 18: an amount formatted with a guessed scale is
// the wrong number, and for a token with fewer decimals than the
// guess it is the wrong number in the direction that reads as
// zero. See tokenAmountText().
const tokenDecimals = resolveTokenDecimals(
toAddress,
decimalsSources,
);
const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
if (parsed.name === "approve") {
const spender = parsed.args[0];
const rawAmount = parsed.args[1];
const maxUint = BigInt(
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = rawAmount === maxUint;
// An unbounded allowance needs no scale to describe, so it is
// still named rather than refused.
const amount = isUnlimited
? { raw: "Unlimited", display: "Unlimited" }
: tokenAmountText(rawAmount, tokenDecimals, tokenSymbol);
return {
name: "Token Approval",
description: tokenSymbol
? "Approve spending of your " + tokenSymbol
: "Approve spending of an ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{
label: "Spender",
value: spender,
address: spender,
},
{
label: "Amount",
value: amount.display,
rawValue: amount.raw,
},
],
};
}
if (parsed.name === "transfer") {
const to = parsed.args[0];
const rawAmount = parsed.args[1];
const amount = tokenAmountText(
rawAmount,
tokenDecimals,
tokenSymbol,
);
return {
name: "Token Transfer",
description: tokenSymbol
? "Transfer " + tokenSymbol
: "Transfer ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{ label: "Recipient", value: to, address: to },
{
label: "Amount",
value: amount.display,
rawValue: amount.raw,
},
],
};
}
}
} catch {
// Not ERC-20 — fall through
}
// Try Uniswap Universal Router
const routerResult = uniswap.decode(
data,
toAddress,
chainId,
decimalsSources,
fromAddress,
);
if (routerResult) return routerResult;
return null;
}
function showPhishingWarning(elementId, isPhishing) {
const el = $(elementId);
if (!el) return;
@@ -1298,7 +1167,6 @@ function init(_ctx) {
module.exports = {
init,
show,
decodeCalldata,
formatTypedDataHtml,
typedDataRefusal,
};
+2 -2
View File
@@ -79,8 +79,8 @@ function recoveryPathText(wallet) {
// "Holds" means ETH or any ERC-20 the wallet knows about — an address with no
// ETH and a five-figure stablecoin position must not get the blank line on
// the one screen whose job is to warn. The sentence names no figure of its
// own: the rendered lines round to four decimals, so a sentence built from a
// rounded number would report "0.0000 ETH" for an address holding real money.
// own: the rendered lines cut to four decimals, so a sentence built from their
// figure would report "0.0000 ETH" for an address holding real money.
// The lines below it carry the amounts, in the same format as Home and
// AddressDetail, followed by the USD total when there is one to give — no
// total line at all on testnet, before the first price fetch or while no
+12 -32
View File
@@ -18,6 +18,7 @@
const { formatEther } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const {
truncateAmount,
truncateAmountNeverZero,
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
@@ -31,7 +32,12 @@ const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state");
const {
state,
saveState,
currentNetwork,
tokenSourcesByChainId,
} = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
@@ -305,32 +311,6 @@ function nativeCurrency() {
return currentNetwork().nativeCurrency;
}
// What the wallet knows of the tokens on the network with this chain id, or
// on the active network when no chain id is given, as the `sources` that
// resolveTokenSymbol() and resolveTokenDecimals() take. A contract address
// names a token on one network only, and every token balance held is the
// active network's, so another network gets the tokens tracked on it
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances.
//
// The wallets' balances hold what Blockscout reported for each token. So the
// wallets are returned only while "Use Blockscout" is on.
function tokenSourcesByChainId(chainId) {
const chainIdOrActive = chainId || currentNetwork().chainId;
const net = networkByChainId(chainIdOrActive);
if (net && net.id === state.networkId) {
return {
chainId: chainIdOrActive,
trackedTokens: state.trackedTokens,
wallets: state.useBlockscout ? state.wallets : [],
};
}
return {
chainId: chainIdOrActive,
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [],
};
}
// The line shown under a token while "Use Blockscout" is off. The token gets
// the line only when the bundled list and the tracked tokens lack its name or
// decimals. Otherwise this returns "". The token is a contract address on the
@@ -402,8 +382,7 @@ function formatFee(wei, symbol, chainId) {
// whole rule exists to prevent, so the row says so instead.
function balanceLine(symbol, balance, price, tokenId) {
const amount = unknownableAmount(balance);
const qty =
amount === null ? "quantity unknown" : truncateAmountNeverZero(balance);
const qty = amount === null ? "quantity unknown" : truncateAmount(balance);
const usd =
price && amount !== null
? escapeHtml(formatUsd(amount * price)) || "&nbsp;"
@@ -491,8 +470,10 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
// Whether an address holds anything at all: ETH or any ERC-20 the wallet
// knows about, except a token holding below 0.000001, which the balance list
// under the remove-address warning leaves out too. Callers that warn about
// holdings must ask this, not the rendered lines.
// under the remove-address warning leaves out too. Deliberately exact — the
// rendered lines cut to four decimals, so a dust balance displays as 0.0000
// while still being real money at a real address. Callers that warn about
// holdings must ask this, not the rendered figure.
function addressHoldsFunds(addr) {
if (!addr) return false;
// A null or absent ETH balance reads as unknown, so the address may hold
@@ -867,7 +848,6 @@ module.exports = {
keyMaterialName,
unknownableAmount,
nativeCurrency,
tokenSourcesByChainId,
blockscoutOffTokenNoteHtml,
tokenLabel,
formatFee,
+2 -3
View File
@@ -31,7 +31,7 @@ const {
resetSendValidation,
} = require("./send");
const { deriveAddressFromXpub } = require("../../shared/wallet");
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { truncateAmount } = require("../../shared/amountDisplay");
const { canRemoveAddress } = require("../../shared/walletDelete");
const {
walletDefect,
@@ -82,8 +82,7 @@ function renderTotalValue() {
if (ethBal === null) {
el.textContent = "unknown (" + nativeCurrency() + ")";
} else {
const ethStr =
truncateAmountNeverZero(addr.balance) + " " + nativeCurrency();
const ethStr = truncateAmount(addr.balance) + " " + nativeCurrency();
const ethUsd = ethPrice
? " (" + formatUsd(ethBal * ethPrice) + ")"
: "";
+6 -2
View File
@@ -10,9 +10,13 @@ const {
renderAddressHtml,
attachCopyHandlers,
goBack,
tokenSourcesByChainId,
} = require("./helpers");
const { state, currentAddress, currentNetwork } = require("../../shared/state");
const {
state,
currentAddress,
currentNetwork,
tokenSourcesByChainId,
} = require("../../shared/state");
let ctx;
const { getProvider } = require("../../shared/balances");
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
+1 -1
View File
@@ -27,7 +27,7 @@ const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log");
const { storedSettingIsOn } = require("../../shared/storedSettings");
const { decodeCalldata } = require("./approval");
const { decodeCalldata } = require("../../shared/calldataDecoder");
/**
* Determine a human-readable transaction type string from tx fields.
+19 -10
View File
@@ -1,6 +1,15 @@
// The amount rule from README.md's Amounts section. Every screen that shows a
// token or ETH amount, except TransactionDetail, shows it through
// truncateAmountNeverZero(), so one value reads the same on every screen.
// The 4-decimal amount rule from README.md's Amounts section, and
// the one exception to it, in one place. Three call sites had grown their own
// copy of the truncation — the history and balance lists
// (`src/shared/transactions.js`), the approval screen's ERC-20 amount line
// (`src/shared/calldataDecoder.js`) and its Uniswap swap detail lines
// (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
// two showing a different number for the same value.
//
// The two truncation functions below are the two policies, not two
// implementations of one: summary lists truncate, and the screens that state
// what is being authorized truncate with a floor. Keeping them adjacent is the
// point, so a change to the rule cannot reach one screen and miss another.
// Truncate to exactly four decimal places. Truncation, never rounding: an
// amount must never be displayed as larger than it is, so 0.99999 stays
@@ -11,13 +20,12 @@ function truncateAmount(val) {
return parts[0] + "." + (parts[1] + "0000").slice(0, 4);
}
// Four decimal places, truncated, and a nonzero amount never renders as zero.
// Truncating alone would show an amount below 0.0001 as 0.0000, and a real
// holding, transfer or allowance would then read as nothing.
//
// `val` is the exact decimal string: a stored balance, or what formatEther()
// or formatUnits() returns. Never a JavaScript number, whose own rounding
// would happen before the truncation.
// The same rule, plus the invariant the approval and confirmation screens
// hold: a nonzero amount never renders as zero. Truncating to four decimals
// does exactly that to an amount below 0.0001 — one base unit of an 18-decimal
// token, 500 of an 8-decimal one — and a real transfer or allowance then reads
// as "nothing is being moved" on the screen whose whole job is to say what is
// being authorized.
//
// When the truncated string carries no significant digit and the value does,
// the amount is extended to its first significant digit instead. It stays in
@@ -46,6 +54,7 @@ function isBelowOneMillionth(balance) {
}
module.exports = {
truncateAmount,
truncateAmountNeverZero,
isBelowOneMillionth,
};
+155
View File
@@ -0,0 +1,155 @@
// Decode a transaction's calldata into the summary that TxApproval and
// TransactionDetail show: ERC-20 `approve` and `transfer` calls here, and
// Uniswap Universal Router calls through uniswap.js. The summary is data; the
// views turn its addresses into HTML.
const { formatUnits, Interface } = require("ethers");
const { ERC20_ABI } = require("./constants");
const { tokenSourcesByChainId } = require("./state");
const {
resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount,
} = require("./approvalAmount");
// Four decimals, with the nonzero floor the approval screens hold.
const { truncateAmountNeverZero } = require("./amountDisplay");
const uniswap = require("./uniswap");
const erc20Iface = new Interface(ERC20_ABI);
// The amount line for a decoded ERC-20 call. With a known scale it is the
// token quantity; with `decimals` null it is the base-unit integer with the
// unknown scale stated, because formatting it with an assumed scale is what
// showed a 5,000-token transfer as `0.0000`. `raw` is what the status screens
// carry, `display` is what the approval screen shows.
function tokenAmountText(rawAmount, decimals, symbol) {
if (decimals === null) {
const unknown = unknownDecimalsAmount(rawAmount);
return { raw: unknown, display: unknown };
}
const formatted = truncateAmountNeverZero(formatUnits(rawAmount, decimals));
return {
raw: formatted,
display: formatted + (symbol ? " " + symbol : ""),
};
}
// Try to decode calldata sent from `fromAddress` to `toAddress` on the network
// with chain id `chainId`, using known ABIs.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress, chainId, fromAddress) {
if (!data || data === "0x" || data.length < 10) return null;
// Where a token's name and scale are looked for, for every decoder below:
// what the wallet knows of the tokens on the network with `chainId`, which
// need not be the active one. The ERC-20 amount line and the swap's Amount
// and Min. received lines resolve the scale the same way, and refuse to
// format the same way when it is nowhere.
const decimalsSources = tokenSourcesByChainId(chainId);
// Try ERC-20 (approve / transfer)
try {
const parsed = erc20Iface.parseTransaction({ data });
if (parsed) {
const tokenSymbol = resolveTokenSymbol(toAddress, decimalsSources);
// null when no source knows this token's scale. It is not
// defaulted to 18: an amount formatted with a guessed scale is
// the wrong number, and for a token with fewer decimals than the
// guess it is the wrong number in the direction that reads as
// zero. See tokenAmountText().
const tokenDecimals = resolveTokenDecimals(
toAddress,
decimalsSources,
);
const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
if (parsed.name === "approve") {
const spender = parsed.args[0];
const rawAmount = parsed.args[1];
const maxUint = BigInt(
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = rawAmount === maxUint;
// An unbounded allowance needs no scale to describe, so it is
// still named rather than refused.
const amount = isUnlimited
? { raw: "Unlimited", display: "Unlimited" }
: tokenAmountText(rawAmount, tokenDecimals, tokenSymbol);
return {
name: "Token Approval",
description: tokenSymbol
? "Approve spending of your " + tokenSymbol
: "Approve spending of an ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{
label: "Spender",
value: spender,
address: spender,
},
{
label: "Amount",
value: amount.display,
rawValue: amount.raw,
},
],
};
}
if (parsed.name === "transfer") {
const to = parsed.args[0];
const rawAmount = parsed.args[1];
const amount = tokenAmountText(
rawAmount,
tokenDecimals,
tokenSymbol,
);
return {
name: "Token Transfer",
description: tokenSymbol
? "Transfer " + tokenSymbol
: "Transfer ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{ label: "Recipient", value: to, address: to },
{
label: "Amount",
value: amount.display,
rawValue: amount.raw,
},
],
};
}
}
} catch {
// Not ERC-20 — fall through
}
// Try Uniswap Universal Router
const routerResult = uniswap.decode(
data,
toAddress,
chainId,
decimalsSources,
fromAddress,
);
if (routerResult) return routerResult;
return null;
}
module.exports = { decodeCalldata, tokenAmountText };
+28 -1
View File
@@ -22,7 +22,7 @@
// same thing in the editor is fast feedback in front of the build, not the
// guarantee.
const { networkById } = require("./networks");
const { networkById, networkByChainId } = require("./networks");
const { copyChainSwitchFields } = require("./chainSwitchFields");
const {
DEFAULT_STATE,
@@ -125,6 +125,32 @@ function currentNetwork() {
return networkById(state.networkId);
}
// What the wallet knows of the tokens on the network with this chain id, or
// on the active network when no chain id is given, as the `sources` that
// resolveTokenSymbol() and resolveTokenDecimals() take. A contract address
// names a token on one network only, and every token balance held is the
// active network's, so another network gets the tokens tracked on it
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances.
//
// The wallets' balances hold what Blockscout reported for each token. So the
// wallets are returned only while "Use Blockscout" is on.
function tokenSourcesByChainId(chainId) {
const chainIdOrActive = chainId || currentNetwork().chainId;
const net = networkByChainId(chainIdOrActive);
if (net && net.id === state.networkId) {
return {
chainId: chainIdOrActive,
trackedTokens: state.trackedTokens,
wallets: state.useBlockscout ? state.wallets : [],
};
}
return {
chainId: chainIdOrActive,
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [],
};
}
// `baseline` holds the persisted fields as this page held them when its last
// loadState() finished, or when its last successful saveState() began.
// takeStoredNetwork(), takeStoredTrackedTokens() and
@@ -820,5 +846,6 @@ module.exports = {
loadState,
currentAddress,
currentNetwork,
tokenSourcesByChainId,
StateNotLoadedError,
};
+6 -5
View File
@@ -16,7 +16,10 @@ const { nativeCurrencyByChainId } = require("./networks");
// a scale is either reported or it is unknown, and "unknown" must mean the
// same thing here as it does on the screens that refuse to format one.
const { toDecimals } = require("./transferAmount");
const { truncateAmountNeverZero } = require("./amountDisplay");
// The plain 4-decimal rule. The history and balance lists deliberately keep
// truncation without the approval screens' nonzero floor: the transaction
// detail view is the authoritative record and already shows exact precision.
const { truncateAmount: formatTxValue } = require("./amountDisplay");
const { routerRelease } = require("./uniswap");
const { storedSettingIsOn } = require("./storedSettings");
@@ -37,7 +40,7 @@ function parseTx(tx, addrLower, chainId) {
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
let symbol = nativeCurrencyByChainId(chainId);
let value = truncateAmountNeverZero(formatEther(rawWei));
let value = formatTxValue(formatEther(rawWei));
let exactValue = formatEther(rawWei);
let rawAmount = rawWei;
let rawUnit = "wei";
@@ -119,9 +122,7 @@ function parseTokenTransfer(tt, addrLower, chainId) {
// The exact figure is not lost — it is the base-unit line below, which is
// the one number that needs no scale to be true.
const formatted =
decimals === null
? ""
: truncateAmountNeverZero(formatUnits(rawVal, decimals));
decimals === null ? "" : formatTxValue(formatUnits(rawVal, decimals));
const exact = decimals === null ? "" : formatUnits(rawVal, decimals);
return {
hash: tt.transaction_hash,
+1 -1
View File
@@ -23,7 +23,7 @@ const {
resolveTokenDecimals,
unknownDecimalsAmount,
} = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const { decodeCalldata } = require("../src/shared/calldataDecoder");
const iface = new Interface(ERC20_ABI);
+2 -9
View File
@@ -13,7 +13,7 @@
// asserted on `rawValue` alongside the displayed line.
//
// Both amount paths of that screen are covered: the ERC-20 line decoded by
// `src/popup/views/approval.js`, and the swap's `Amount` and `Min. received`
// `src/shared/calldataDecoder.js`, and the swap's `Amount` and `Min. received`
// lines decoded by `src/shared/uniswap.js`.
globalThis.chrome = {
@@ -23,9 +23,8 @@ globalThis.chrome = {
const { AbiCoder, Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state");
const { decodeCalldata } = require("../src/popup/views/approval");
const { decodeCalldata } = require("../src/shared/calldataDecoder");
const uniswap = require("../src/shared/uniswap");
const { truncateAmountNeverZero } = require("../src/shared/amountDisplay");
const iface = new Interface(ERC20_ABI);
@@ -180,10 +179,4 @@ describe("the four-decimal rule is otherwise unchanged", () => {
test("a genuine zero still renders as zero", () => {
expect(amount(transferData(0n), DAI).rawValue).toBe("0.0000");
});
test("one base unit of an 18-decimal token reads as itself", () => {
expect(truncateAmountNeverZero("0.000000000000000001")).toBe(
"0.000000000000000001",
);
});
});
+6 -7
View File
@@ -1,6 +1,5 @@
// The balance lists and Home's ETH line show a balance as Send does (README.md,
// Amounts): cut to four decimal places, never rounded up, and a nonzero amount
// never shown as zero
// The balance lists and Home's ETH line show a balance as the history rows do
// (README.md, Amounts): cut to four decimal places, never rounded up
// (https://git.eeqj.de/sneak/AutistMask/issues/757).
//
// Driven through the real popup over a stored profile on Ethereum Mainnet.
@@ -54,9 +53,9 @@ describe("balanceLine", () => {
);
});
test("shows 0.00004 as 0.00004, not 0.0000", () => {
test("cuts 0.00004 to 0.0000", () => {
expect(quantity(balanceLine("USDC", "0.00004", null, USDC))).toBe(
"0.00004",
"0.0000",
);
});
});
@@ -70,9 +69,9 @@ describe("Home", () => {
expect(quantity(homeRow(page, "ETH"))).toBe("0.9999");
});
test("the balance list reads 0.00004 USDC as 0.00004", async () => {
test("the balance list reads 0.00004 USDC as 0.0000", async () => {
const page = await bootHolding("0.99996", "0.00004");
expect(quantity(homeRow(page, USDC.toLowerCase()))).toBe("0.00004");
expect(quantity(homeRow(page, USDC.toLowerCase()))).toBe("0.0000");
});
});
+6 -5
View File
@@ -102,7 +102,7 @@ describe("whether an address holds anything", () => {
expect(addressHoldsFunds(TOKEN_ONLY)).toBe(true);
});
// 0.00001 ETH is below four decimals. It is still money.
// 0.00001 ETH renders as "0.0000" at four decimals. It is still money.
test("an ETH balance below the displayed precision counts", () => {
expect(addressHoldsFunds(DUST)).toBe(true);
});
@@ -141,12 +141,13 @@ describe("the balance warning on the removal confirmation", () => {
expect(html).toContain("2500.0000");
});
// The rendered line shows the balance to its first nonzero digit, as Home
// and AddressDetail do (README.md, Amounts).
test("an ETH balance below 0.0001 is warned about, and reads as itself", () => {
// The rendered line says 0.0000 for this address — that is the display
// format, shared with Home and AddressDetail — and the warning is shown
// all the same, because the balance is not zero.
test("an ETH balance that renders as 0.0000 is warned about", () => {
const html = balanceWarningHtml(DUST);
expect(html).toContain("This address holds a balance.");
expect(html).toContain("<span>0.00001</span>");
expect(html).toContain("<span>0.0000</span>");
});
test("an ETH balance no refresh has read is warned about, and reads as unknown", () => {
+9 -9
View File
@@ -829,12 +829,11 @@ test("a common-token quick-pick fills in the contract address (#150)", async (en
await visible(env.page, "#view-address");
});
// The native amount as the transaction list writes it (four decimals) and
// as the detail screen writes it (full precision). Both are rendered here
// from the fixture rather than read off the screen, so the assertions
// compare against the wei the stub served.
const NATIVE_ROW_TEXT =
parseFloat(formatEther(STUB_NATIVE_VALUE_WEI)).toFixed(4) + " ETH";
// The native amount as the transaction list writes it (cut to four decimals,
// quantity() below) and as the detail screen writes it (full precision). Both
// are rendered here from the fixture rather than read off the screen, so the
// assertions compare against the wei the stub served.
const NATIVE_ROW_TEXT = quantity(STUB_NATIVE_VALUE_WEI) + " ETH";
const NATIVE_DETAIL_TEXT = formatEther(STUB_NATIVE_VALUE_WEI) + " ETH";
test("the native ETH transaction detail still renders (#151)", async (env) => {
@@ -2724,10 +2723,11 @@ async function goToConfirm(page, send) {
await assertAddressesFit(page, "the confirmation screen");
}
// A balance as the main view renders it: balanceLinesForAddress() writes
// every quantity with four decimal places.
// An amount in wei as the main view's balance lines and the transaction list
// write it: cut to four decimal places, never rounded, as feeEth() does.
function quantity(wei) {
return parseFloat(formatEther(wei)).toFixed(4);
const [whole, frac = ""] = formatEther(wei).split(".");
return whole + "." + (frac + "0000").slice(0, 4);
}
// Wait on the main view until a changed balance fixture has been picked up.
+1 -1
View File
@@ -47,7 +47,7 @@ const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const { decodeCalldata } = require("../src/shared/calldataDecoder");
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
const HOLDER = "0x" + "a".repeat(40);
+1 -1
View File
@@ -22,7 +22,7 @@ const { Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state");
const { resolveTokenSymbol } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const { decodeCalldata } = require("../src/shared/calldataDecoder");
const iface = new Interface(ERC20_ABI);
+3 -3
View File
@@ -1361,8 +1361,8 @@ describe("fetchRecentTransactions merge and dedup", () => {
expect(txs[0].holders).toBe(3500000);
});
// README.md, Amounts: a nonzero amount never reads as zero.
test("a token transfer below 0.0001 shows its first nonzero digit", async () => {
// README.md, Amounts: a history row cuts to four decimals, with no floor.
test("a token transfer below 0.0001 reads 0.0000", async () => {
respondWith(
[],
[
@@ -1383,7 +1383,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
expect(txs[0].value).toBe("0.00004");
expect(txs[0].value).toBe("0.0000");
});
test("two transfers of the same token in one transaction collapse to one entry", async () => {
+1 -1
View File
@@ -47,7 +47,7 @@ globalThis.chrome = {
const { state } = require("../src/shared/state");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const approval = require("../src/popup/views/approval");
const { decodeCalldata } = approval;
const { decodeCalldata } = require("../src/shared/calldataDecoder");
const transactionDetail = require("../src/popup/views/transactionDetail");
const MAINNET = "0x1";
+1 -1
View File
@@ -18,7 +18,7 @@ globalThis.chrome = {
const { AbiCoder, Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const { decodeCalldata } = require("../src/shared/calldataDecoder");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";