Author SHA1 Message Date
sneak 1a21bd254c fix: history rows show the ISO datetime and the age (closes #760)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Home, AddressDetail and AddressToken showed only the relative age, with
the datetime as a tooltip. Each row now shows the datetime and then the
age in parentheses, on a line of its own, through transactionTimeHtml()
in helpers.js, which TransactionDetail also uses. The direction label
moves to its own right-aligned line below.

Model: opus-5-5
2026-11-11 04:53:15 +00:00
clawbot d909567ab9 fix: every balance is cut 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. Send's current balance and
ConfirmTx's balance line kept the nonzero floor, so 0.00004 USDC read
0.0000 in the lists and 0.00004 on Send. All four 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 amounts, fees and allowances a screen
asks the user to approve keep the floor of truncateAmountNeverZero().

Disclosure: for a holding below 0.0001, ConfirmTx shows the amount sent
beside a balance that reads 0.0000.

Model: opus-5-5
2026-11-11 04:46:32 +01:00
clawbot 32b232db60 docs: TODO.md Next Step names open milestone work, not tagging (closes #771)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Tags and releases are the owner's call; no repo document tells him when
to tag.

Model: opus-5-5
2026-11-11 03:50:29 +01:00
clawbot 02f14143eb docs: TODO.md holds open work only (closes #769)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Removes the Completed Steps work log and the v0.1.0 tagging date from
Status. Every open item the log mentioned is a tracker issue. The
Workflow bullet that named the removed section now says to record no
finished work in this file.

Model: opus-5-5
2026-11-11 02:04:13 +01:00
clawbot b87c03be11 fix: build hashes and recovery-phrase entropy come from ethers (closes #758)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
build.js and script/lib/package.js hash with sha256 from ethers, with the
0x prefix stripped so the build receipt and SHA256SUMS keep bare lowercase
hex. generateMnemonic() takes its 16 bytes of entropy from randomBytes in
ethers. The README Crypto policy no longer lists these as exceptions.

Model: opus-5-5
2026-11-11 00:42:18 +01:00
clawbot 6d158bf1b1 refactor: move the calldata decoder to src/shared/calldataDecoder.js (closes #759)
check / check (push) Successful in 10m38s
e2e / e2e-chrome (push) Successful in 16m28s
e2e / e2e-firefox (push) Successful in 4m23s
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
33 changed files with 664 additions and 2326 deletions

No files matched your search

+21 -13
View File
@@ -264,6 +264,8 @@ The shared modules, each in one line:
messaging, windows and the lock. messaging, windows and the lock.
- `buildInfo.js`: the version, license, author, commit and date compiled into - `buildInfo.js`: the version, license, author, commit and date compiled into
About. 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. - `chainSwitch.js`: the popup's network switch.
- `chainSwitchFields.js`: the fields a network switch changes. - `chainSwitchFields.js`: the fields a network switch changes.
- `constants.js`: `DEBUG`, the default endpoints, the ERC-20 ABI and the burn - `constants.js`: `DEBUG`, the default endpoints, the ERC-20 ABI and the burn
@@ -1189,15 +1191,23 @@ These helpers write one kind of value on every screen that shows it:
Most screens show token and ETH amounts with four decimal places. History rows Most screens show token and ETH amounts with four decimal places. History rows
cut amounts to four decimals and never round up (`truncateAmount()` in cut amounts to four decimals and never round up (`truncateAmount()` in
`src/shared/amountDisplay.js`). The balance lists and Home's ETH balance round `src/shared/amountDisplay.js`). These balances are cut the same way:
to four places. 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 - the balance lists;
- Home's ETH balance;
- Send's current balance;
- ConfirmTx's balance line.
So a balance below 0.0001 reads `0.0000` there.
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. (`truncateAmountNeverZero()`). So a nonzero amount never reads as zero there.
Those screens are: Those screens are:
- TxApproval; - TxApproval;
- SignApproval's Permit warning; - SignApproval's Permit warning;
- ConfirmTx, and Send's current balance; - ConfirmTx's amount and network fee;
- WaitTx, SuccessTx and ErrorTx. - WaitTx, SuccessTx and ErrorTx.
TransactionDetail shows the exact amount and its base units. TransactionDetail shows the exact amount and its base units.
@@ -1264,9 +1274,8 @@ A native amount is labelled with the native currency of the transaction's chain:
`src/shared/networks.js`). The balance lists and Send use the active network's. `src/shared/networks.js`). The balance lists and Send use the active network's.
The decoded swap lines call the native currency `ETH` on both networks. The decoded swap lines call the native currency `ETH` on both networks.
TransactionDetail shows the time as an ISO datetime and a relative age. A TransactionDetail and every history row show the time as an ISO datetime and
history row shows the age, with the datetime as its tooltip. With "UTC then a relative age. With "UTC Timestamps" on, the datetime is in UTC.
Timestamps" on, the datetime is in UTC.
Every nonzero holding is stored exactly. A token holding below 0.000001 is left Every nonzero holding is stored exactly. A token holding below 0.000001 is left
out of: out of:
@@ -1379,7 +1388,7 @@ approval screens show a red warning.
### Transaction decoding ### Transaction decoding
TxApproval and TransactionDetail try to decode calldata into a summary 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 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 is shown in full. No contract gets any treatment beyond a legible confirmation
screen. screen.
@@ -1645,11 +1654,10 @@ The development dependencies ship nowhere:
### Crypto policy ### Crypto policy
Hashing, key derivation, signing and encryption go through `ethers` or In the extension, hashing, key derivation, signing and encryption go through
`libsodium-wrappers-sumo`. No primitive is implemented in this repository. A new `ethers` or `libsodium-wrappers-sumo`. A new recovery phrase takes its random
recovery phrase draws its random bytes from the browser's bytes from `ethers`. `build.js` and `script/lib/package.js` also hash files with
`crypto.getRandomValues()`. The build tooling, `build.js` and `script/lib/`, `ethers`. No primitive is implemented in this repository.
hashes files with Node's `crypto`; none of it ships in the extension.
### Content Security Policy ### Content Security Policy
+1 -4
View File
@@ -108,10 +108,7 @@ contradicts either, the originals govern.
- [ ] All tool invocations via `make` targets, never directly - [ ] All tool invocations via `make` targets, never directly
- [ ] `make check` = `make test` + `make lint` + `make fmt-check` - [ ] `make check` = `make test` + `make lint` + `make fmt-check`
- [ ] `main` always passes `make check` - [ ] `main` always passes `make check`
- [ ] Branch per issue, cut from `next`. One PR per issue, based on `next`, - [ ] Feature branches for all changes, merge to main when done
squash-merged into `next` on a passed independent review. `main` is
reached only by the per-milestone PR from `next`; releases are tagged from
`main`. `next` and `main` must stay green; feature branches may be red.
- [ ] No force-push to `main` - [ ] No force-push to `main`
- [ ] Pre-commit hook runs `make check` - [ ] Pre-commit hook runs `make check`
+6 -1938
View File
File diff suppressed because it is too large. Load diff
+3 -5
View File
@@ -1,8 +1,8 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const crypto = require("crypto");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const esbuild = require("esbuild"); const esbuild = require("esbuild");
const { sha256 } = require("ethers");
const { resolveVersion } = require("./script/lib/version"); const { resolveVersion } = require("./script/lib/version");
const { const {
BACKGROUND_ENTRY_PREFIX, BACKGROUND_ENTRY_PREFIX,
@@ -314,10 +314,8 @@ function copyIcons(distDir) {
} }
function sha256File(absPath) { function sha256File(absPath) {
return crypto // ethers prefixes the hex with 0x; the receipt holds bare hex.
.createHash("sha256") return sha256(fs.readFileSync(absPath)).slice(2);
.update(fs.readFileSync(absPath))
.digest("hex");
} }
// Write the receipt for the files this build emitted. Deliberately records no // Write the receipt for the files this build emitted. Deliberately records no
+9 -3
View File
@@ -25,9 +25,9 @@
"use strict"; "use strict";
const crypto = require("crypto");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const ethers = require("ethers");
const { readZip, writeZip } = require("./zip"); const { readZip, writeZip } = require("./zip");
const { resolveVersion } = require("./version"); const { resolveVersion } = require("./version");
@@ -60,8 +60,9 @@ function fail(message) {
throw new Error(message); throw new Error(message);
} }
// Lowercase hex without the 0x prefix ethers adds, as sha256sum prints it.
function sha256(buf) { function sha256(buf) {
return crypto.createHash("sha256").update(buf).digest("hex"); return ethers.sha256(buf).slice(2);
} }
// Every regular file under dir, as archive-root-relative forward-slashed // Every regular file under dir, as archive-root-relative forward-slashed
@@ -291,4 +292,9 @@ if (require.main === module) {
} }
} }
module.exports = { checkSelfContained, htmlReferences, manifestReferences }; module.exports = {
checkSelfContained,
htmlReferences,
manifestReferences,
sha256,
};
+5 -5
View File
@@ -12,8 +12,7 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate, transactionTimeHtml,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
@@ -236,10 +235,11 @@ function renderTransactions(txs) {
const nameStr = escapeHtml(title || ensName || ""); const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : ""; const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`; html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; // The time has a line of its own, so a long time or direction label
// never pushes the other onto a second line.
html += `<div class="text-muted">${transactionTimeHtml(tx.timestamp)}</div>`;
html += `<div class="text-right">${dirLabel}${err}</div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr); html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`; html += `</div>`;
i++; i++;
+11 -10
View File
@@ -18,8 +18,7 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate, transactionTimeHtml,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { bundledToken, resolveSymbol } = require("../../shared/tokenList"); const { bundledToken, resolveSymbol } = require("../../shared/tokenList");
@@ -159,10 +158,10 @@ function renderBalance() {
const addr = const addr =
state.wallets[state.selectedWallet].addresses[state.selectedAddress]; state.wallets[state.selectedWallet].addresses[state.selectedAddress];
const tokenId = state.selectedToken; const tokenId = state.selectedToken;
let amount, price; let balance, price;
if (tokenId === "ETH") { if (tokenId === "ETH") {
// null when no refresh has read the balance. // null when no refresh has read the balance.
amount = unknownableAmount(addr.balance); balance = addr.balance;
price = getPrice("ETH"); price = getPrice("ETH");
} else { } else {
const id = tokenId.toLowerCase(); const id = tokenId.toLowerCase();
@@ -175,12 +174,13 @@ function renderBalance() {
// null when the scale is unknown, or when no refresh has read the // null when the scale is unknown, or when no refresh has read the
// token's balance: no quantity to show, and none to price. // token's balance: no quantity to show, and none to price.
// balanceLine() states that rather than printing 0.0000. // balanceLine() states that rather than printing 0.0000.
if (tb) amount = unknownableAmount(tb.balance); if (tb) balance = tb.balance;
else amount = tracked && tracked.balanceNotFetched ? null : 0; else balance = tracked && tracked.balanceNotFetched ? null : "0";
price = getPrice(tokenId); price = getPrice(tokenId);
} }
// USD total for this token only // USD total for this token only
const amount = unknownableAmount(balance);
const usdVal = price && amount !== null ? amount * price : null; const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal); const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;"; $("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
@@ -188,7 +188,7 @@ function renderBalance() {
// Single token balance line (no tokenId — not clickable here) // Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine( $("address-token-balance").innerHTML = balanceLine(
currentSymbol, currentSymbol,
amount, balance,
price, price,
); );
} }
@@ -328,10 +328,11 @@ function renderTransactions(txs) {
const nameStr = escapeHtml(title || ensName || ""); const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : ""; const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`; html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; // The time has a line of its own, so a long time or direction label
// never pushes the other onto a second line.
html += `<div class="text-muted">${transactionTimeHtml(tx.timestamp)}</div>`;
html += `<div class="text-right">${dirLabel}${err}</div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr); html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`; html += `</div>`;
i++; i++;
+12 -144
View File
@@ -12,9 +12,13 @@ const {
formatFee, formatFee,
tokenLabel, tokenLabel,
blockscoutOffTokenNoteHtml, blockscoutOffTokenNoteHtml,
tokenSourcesByChainId,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const {
state,
saveState,
currentNetwork,
tokenSourcesByChainId,
} = require("../../shared/state");
const { const {
networkById, networkById,
networkByChainId, networkByChainId,
@@ -26,21 +30,24 @@ const {
getAddress, getAddress,
getBigInt, getBigInt,
getBytes, getBytes,
Interface,
MaxUint256, MaxUint256,
toUtf8String, toUtf8String,
} = require("ethers"); } = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices"); const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof"); const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
const { const {
resolveTokenDecimals, resolveTokenDecimals,
resolveTokenSymbol, resolveTokenSymbol,
unknownDecimalsAmount, unknownDecimalsAmount,
} = require("../../shared/approvalAmount"); } = require("../../shared/approvalAmount");
const {
decodeCalldata,
tokenAmountText,
} = require("../../shared/calldataDecoder");
// Four decimals, with the nonzero floor these screens hold: every amount this // 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 // 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 { const {
truncateAmountNeverZero: formatTxValue, truncateAmountNeverZero: formatTxValue,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
@@ -54,11 +61,8 @@ const {
typedDataChainRefusal, typedDataChainRefusal,
} = require("../../shared/approvalVerify"); } = require("../../shared/approvalVerify");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const { runtimeApi, sendMessage } = require("../../shared/browserApi"); 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 // 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 // network when none is given. A chain id that names no network has no
// explorer, so the address is drawn without a link. // explorer, so the address is drawn without a link.
@@ -70,141 +74,6 @@ function approvalAddressHtml(address, chainId) {
return renderAddressHtml(address, { title, 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) { function showPhishingWarning(elementId, isPhishing) {
const el = $(elementId); const el = $(elementId);
if (!el) return; if (!el) return;
@@ -1298,7 +1167,6 @@ function init(_ctx) {
module.exports = { module.exports = {
init, init,
show, show,
decodeCalldata,
formatTypedDataHtml, formatTypedDataHtml,
typedDataRefusal, typedDataRefusal,
}; };
+7 -4
View File
@@ -43,9 +43,12 @@ const {
transferAmountUnits, transferAmountUnits,
} = require("../../shared/transferAmount"); } = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify"); const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The amount, the balance line and the fee reserve go through it, as the // The amount and the fee reserve keep the nonzero floor, as the approval
// approval screen's amounts do. // screen's amounts do. The balance line is cut as the balance lists cut it.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay"); const {
truncateAmount,
truncateAmountNeverZero,
} = require("../../shared/amountDisplay");
const { const {
CODES, CODES,
FEE_PENDING, FEE_PENDING,
@@ -160,7 +163,7 @@ function renderBalance(txInfo) {
const price = getPrice(txInfo.token, txInfo.chainId); const price = getPrice(txInfo.token, txInfo.chainId);
const balUsd = price ? parseFloat(bal) * price : null; const balUsd = price ? parseFloat(bal) * price : null;
$("confirm-balance").textContent = valueWithUsd( $("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol, truncateAmount(bal) + " " + symbol,
balUsd, balUsd,
); );
} }
+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 // "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 // 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 // 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 // own: the rendered lines cut to four decimals, so a sentence built from their
// rounded number would report "0.0000 ETH" for an address holding real money. // 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 // 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 // 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 // total line at all on testnet, before the first price fetch or while no
+34 -46
View File
@@ -18,6 +18,7 @@
const { formatEther } = require("ethers"); const { formatEther } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const { const {
truncateAmount,
truncateAmountNeverZero, truncateAmountNeverZero,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
@@ -31,7 +32,12 @@ const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
const { networkByChainId } = require("../../shared/networks"); const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices"); 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 { displaySymbol } = require("../../shared/symbolDisplay");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof"); const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { walletHasRecoveryPhrase } = require("../../shared/wallet"); const { walletHasRecoveryPhrase } = require("../../shared/wallet");
@@ -305,32 +311,6 @@ function nativeCurrency() {
return currentNetwork().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 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 // 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 // decimals. Otherwise this returns "". The token is a contract address on the
@@ -394,13 +374,15 @@ function formatFee(wei, symbol, chainId) {
// the row that issue #307 was reported against: every screen that lists a // the row that issue #307 was reported against: every screen that lists a
// holding renders through here. // holding renders through here.
// //
// `amount` is null for a holding whose scale nothing knows // `balance` is the stored balance, a decimal string. It is null for a holding
// whose scale nothing knows
// (https://git.eeqj.de/sneak/AutistMask/issues/349), and for a balance no // (https://git.eeqj.de/sneak/AutistMask/issues/349), and for a balance no
// refresh has read. There is no quantity to print for it and no fiat value to // refresh has read. There is no quantity to print for it and no fiat value to
// derive from one, and printing 0.0000 for a real holding is the failure this // derive from one. A holding whose amount is unknown is shown as
// whole rule exists to prevent, so the row says so instead. // `quantity unknown` rather than as a zero.
function balanceLine(symbol, amount, price, tokenId) { function balanceLine(symbol, balance, price, tokenId) {
const qty = amount === null ? "quantity unknown" : amount.toFixed(4); const amount = unknownableAmount(balance);
const qty = amount === null ? "quantity unknown" : truncateAmount(balance);
const usd = const usd =
price && amount !== null price && amount !== null
? escapeHtml(formatUsd(amount * price)) || "&nbsp;" ? escapeHtml(formatUsd(amount * price)) || "&nbsp;"
@@ -415,7 +397,7 @@ function balanceLine(symbol, amount, price, tokenId) {
`<div class="flex text-xs${clickClass}"${tokenAttr}>` + `<div class="flex text-xs${clickClass}"${tokenAttr}>` +
`<span class="flex justify-between w-[42ch] max-w-full">` + `<span class="flex justify-between w-[42ch] max-w-full">` +
`<span>${escapeHtml(displaySymbol(symbol))}</span>` + `<span>${escapeHtml(displaySymbol(symbol))}</span>` +
`<span>${qty}</span>` + `<span>${escapeHtml(qty)}</span>` +
`</span>` + `</span>` +
`<span class="text-right text-muted flex-1">${usd}</span>` + `<span class="text-right text-muted flex-1">${usd}</span>` +
`</div>` `</div>`
@@ -441,7 +423,7 @@ function markTrackedTokensNotFetched() {
function balanceLinesForAddress(addr, trackedTokens, showZero) { function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine( let html = balanceLine(
nativeCurrency(), nativeCurrency(),
unknownableAmount(addr.balance), addr.balance,
getPrice("ETH"), getPrice("ETH"),
"ETH", "ETH",
); );
@@ -459,7 +441,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
if (bal === 0 && !showZero) continue; if (bal === 0 && !showZero) continue;
html += balanceLine( html += balanceLine(
t.symbol, t.symbol,
bal, t.balance,
getPrice(t.address), getPrice(t.address),
t.address.toLowerCase(), t.address.toLowerCase(),
); );
@@ -477,7 +459,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
} }
html += balanceLine( html += balanceLine(
t.symbol, t.symbol,
t.balanceNotFetched ? null : 0, t.balanceNotFetched ? null : "0",
getPrice(t.address), getPrice(t.address),
t.address.toLowerCase(), t.address.toLowerCase(),
); );
@@ -488,10 +470,10 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
// Whether an address holds anything at all: ETH or any ERC-20 the wallet // 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 // knows about, except a token holding below 0.000001, which the balance list
// under the remove-address warning leaves out too. Deliberately unrounded — // under the remove-address warning leaves out too. Deliberately exact — the
// the rendered lines round to four decimals, so a dust balance displays as // rendered lines cut to four decimals, so a dust balance displays as 0.0000
// 0.0000 while still being real money at a real address. Callers that warn // while still being real money at a real address. Callers that warn about
// about holdings must ask this, not the rendered figure. // holdings must ask this, not the rendered figure.
function addressHoldsFunds(addr) { function addressHoldsFunds(addr) {
if (!addr) return false; if (!addr) return false;
// A null or absent ETH balance reads as unknown, so the address may hold // A null or absent ETH balance reads as unknown, so the address may hold
@@ -633,9 +615,8 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return renderAddressHtml(address, { title, ensName, maxLen }); return renderAddressHtml(address, { title, ensName, maxLen });
} }
// A transaction's time as every screen shows it (README, Amounts): the ISO // The ISO datetime of a transaction's time, in UTC when the UTC Timestamps
// datetime, in UTC when the UTC Timestamps setting is on, and the relative age. // setting is on. Views show it through transactionTimeHtml().
// Views import these two; they keep no copies.
function isoDate(timestamp) { function isoDate(timestamp) {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
@@ -692,6 +673,15 @@ function timeAgo(timestamp) {
return years + " year" + (years !== 1 ? "s" : "") + " ago"; return years + " year" + (years !== 1 ? "s" : "") + " ago";
} }
// A transaction's time as every screen shows it (README, Amounts): the ISO
// datetime, then the relative age in parentheses. With copyable set, the
// datetime is click-to-copy, and the caller attaches the copy handlers.
function transactionTimeHtml(timestamp, { copyable = false } = {}) {
const iso = isoDate(timestamp);
const isoHtml = copyable ? copyableHtml(iso) : escapeHtml(iso);
return isoHtml + " (" + escapeHtml(timeAgo(timestamp)) + ")";
}
// Shared external-link icon SVG used across all views. // Shared external-link icon SVG used across all views.
const EXT_ICON = const EXT_ICON =
`<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` + `<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` +
@@ -866,7 +856,6 @@ module.exports = {
keyMaterialName, keyMaterialName,
unknownableAmount, unknownableAmount,
nativeCurrency, nativeCurrency,
tokenSourcesByChainId,
blockscoutOffTokenNoteHtml, blockscoutOffTokenNoteHtml,
tokenLabel, tokenLabel,
formatFee, formatFee,
@@ -888,6 +877,5 @@ module.exports = {
reviewedNetworkProvider, reviewedNetworkProvider,
EXT_ICON, EXT_ICON,
truncateMiddle, truncateMiddle,
isoDate, transactionTimeHtml,
timeAgo,
}; };
+7 -6
View File
@@ -5,8 +5,7 @@ const {
balanceLinesForAddress, balanceLinesForAddress,
unknownableAmount, unknownableAmount,
markTrackedTokensNotFetched, markTrackedTokensNotFetched,
isoDate, transactionTimeHtml,
timeAgo,
addressDotHtml, addressDotHtml,
txCounterpartyHtml, txCounterpartyHtml,
HISTORY_OFF_TEXT, HISTORY_OFF_TEXT,
@@ -31,6 +30,7 @@ const {
resetSendValidation, resetSendValidation,
} = require("./send"); } = require("./send");
const { deriveAddressFromXpub } = require("../../shared/wallet"); const { deriveAddressFromXpub } = require("../../shared/wallet");
const { truncateAmount } = require("../../shared/amountDisplay");
const { canRemoveAddress } = require("../../shared/walletDelete"); const { canRemoveAddress } = require("../../shared/walletDelete");
const { const {
walletDefect, walletDefect,
@@ -81,7 +81,7 @@ function renderTotalValue() {
if (ethBal === null) { if (ethBal === null) {
el.textContent = "unknown (" + nativeCurrency() + ")"; el.textContent = "unknown (" + nativeCurrency() + ")";
} else { } else {
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency(); const ethStr = truncateAmount(addr.balance) + " " + nativeCurrency();
const ethUsd = ethPrice const ethUsd = ethPrice
? " (" + formatUsd(ethBal * ethPrice) + ")" ? " (" + formatUsd(ethBal * ethPrice) + ")"
: ""; : "";
@@ -149,10 +149,11 @@ function renderHomeTxList(ctx) {
const titleStr = title ? escapeHtml(title) : ""; const titleStr = title ? escapeHtml(title) : "";
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : ""; const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`; html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; // The time has a line of its own, so a long time or direction label
// never pushes the other onto a second line.
html += `<div class="text-muted">${transactionTimeHtml(tx.timestamp)}</div>`;
html += `<div class="text-right">${dirLabel}${err}</div>`;
html += txCounterpartyHtml(counterparty, titleStr, amountStr); html += txCounterpartyHtml(counterparty, titleStr, amountStr);
html += `</div>`; html += `</div>`;
i++; i++;
+11 -10
View File
@@ -10,9 +10,13 @@ const {
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
tokenSourcesByChainId,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, currentNetwork } = require("../../shared/state"); const {
state,
currentAddress,
currentNetwork,
tokenSourcesByChainId,
} = require("../../shared/state");
let ctx; let ctx;
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { resolveTokenDecimals } = require("../../shared/approvalAmount"); const { resolveTokenDecimals } = require("../../shared/approvalAmount");
@@ -20,7 +24,7 @@ const { resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders"); const { isLowHolderCount } = require("../../shared/holders");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof"); const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { const {
truncateAmountNeverZero, truncateAmount,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { const {
@@ -235,7 +239,7 @@ function updateSendBalance() {
addr.balance == null addr.balance == null
? "Current balance: unknown (" + nativeCurrency() + ")" ? "Current balance: unknown (" + nativeCurrency() + ")"
: "Current balance: " + : "Current balance: " +
truncateAmountNeverZero(addr.balance) + truncateAmount(addr.balance) +
" " + " " +
nativeCurrency(); nativeCurrency();
} else { } else {
@@ -254,18 +258,15 @@ function updateSendBalance() {
$("send-balance").textContent = $("send-balance").textContent =
bal == null bal == null
? "Current balance: unknown (" + symbol + ")" ? "Current balance: unknown (" + symbol + ")"
: "Current balance: " + : "Current balance: " + truncateAmount(bal) + " " + symbol;
truncateAmountNeverZero(bal) +
" " +
symbol;
} }
} }
// Fill the amount field with the most the selected holding can send: a // Fill the amount field with the most the selected holding can send: a
// token's whole balance (cut to 18 decimal places), or for ETH the exact // token's whole balance (cut to 18 decimal places), or for ETH the exact
// balance minus the fee reserve the confirmation screen checks against, never // balance minus the fee reserve the confirmation screen checks against, never
// the rounded balance the screen shows. Where there is nothing to fill in, a // the balance the screen shows, which is cut to four decimals. Where there is
// flash message says why. // nothing to fill in, a flash message says why.
async function fillMaxAmount() { async function fillMaxAmount() {
const addr = currentAddress(); const addr = currentAddress();
if (!addr) return; if (!addr) return;
+5 -6
View File
@@ -10,8 +10,7 @@ const {
CONTRACT_CREATION_TEXT, CONTRACT_CREATION_TEXT,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,
isoDate, transactionTimeHtml,
timeAgo,
renderAddressHtml, renderAddressHtml,
blockieHtml, blockieHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -27,7 +26,7 @@ const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { storedSettingIsOn } = require("../../shared/storedSettings"); const { storedSettingIsOn } = require("../../shared/storedSettings");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("../../shared/calldataDecoder");
/** /**
* Determine a human-readable transaction type string from tx fields. * Determine a human-readable transaction type string from tx fields.
@@ -204,9 +203,9 @@ function render() {
); );
loadFullTxDetails(tx.hash, tx.chainId); loadFullTxDetails(tx.hash, tx.chainId);
const isoStr = isoDate(tx.timestamp); $("tx-detail-time").innerHTML = transactionTimeHtml(tx.timestamp, {
$("tx-detail-time").innerHTML = copyable: true,
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")"; });
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction"); showView("transaction");
attachCopyHandlers("view-transaction"); attachCopyHandlers("view-transaction");
+1 -1
View File
@@ -2,7 +2,7 @@
// the one exception to it, in one place. Three call sites had grown their own // the one exception to it, in one place. Three call sites had grown their own
// copy of the truncation — the history and balance lists // copy of the truncation — the history and balance lists
// (`src/shared/transactions.js`), the approval screen's ERC-20 amount line // (`src/shared/transactions.js`), the approval screen's ERC-20 amount line
// (`src/popup/views/approval.js`) and its Uniswap swap detail lines // (`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 // (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
// two showing a different number for the same value. // two showing a different number for the same value.
// //
+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 // same thing in the editor is fast feedback in front of the build, not the
// guarantee. // guarantee.
const { networkById } = require("./networks"); const { networkById, networkByChainId } = require("./networks");
const { copyChainSwitchFields } = require("./chainSwitchFields"); const { copyChainSwitchFields } = require("./chainSwitchFields");
const { const {
DEFAULT_STATE, DEFAULT_STATE,
@@ -125,6 +125,32 @@ function currentNetwork() {
return networkById(state.networkId); 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 // `baseline` holds the persisted fields as this page held them when its last
// loadState() finished, or when its last successful saveState() began. // loadState() finished, or when its last successful saveState() began.
// takeStoredNetwork(), takeStoredTrackedTokens() and // takeStoredNetwork(), takeStoredTrackedTokens() and
@@ -820,5 +846,6 @@ module.exports = {
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,
tokenSourcesByChainId,
StateNotLoadedError, StateNotLoadedError,
}; };
+2 -5
View File
@@ -1,7 +1,7 @@
// Wallet operations: mnemonic generation, HD derivation, signing. // Wallet operations: mnemonic generation, HD derivation, signing.
// All crypto delegated to ethers.js. // All crypto delegated to ethers.js.
const { Mnemonic, HDNodeWallet, Wallet } = require("ethers"); const { Mnemonic, HDNodeWallet, Wallet, randomBytes } = require("ethers");
const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants"); const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
function generateMnemonic() { function generateMnemonic() {
@@ -10,10 +10,7 @@ function generateMnemonic() {
// settings toggle drives, which would let a user of a release build turn // settings toggle drives, which would let a user of a release build turn
// the hardcoded, publicly known test phrase back on for real wallets. // the hardcoded, publicly known test phrase back on for real wallets.
if (DEBUG) return DEBUG_MNEMONIC; if (DEBUG) return DEBUG_MNEMONIC;
const m = Mnemonic.fromEntropy( return Mnemonic.fromEntropy(randomBytes(16)).phrase;
globalThis.crypto.getRandomValues(new Uint8Array(16)),
);
return m.phrase;
} }
// Every extended key (xprv or xpub) entering the app goes through this. // Every extended key (xprv or xpub) entering the app goes through this.
+1 -1
View File
@@ -23,7 +23,7 @@ const {
resolveTokenDecimals, resolveTokenDecimals,
unknownDecimalsAmount, unknownDecimalsAmount,
} = require("../src/shared/approvalAmount"); } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval"); const { decodeCalldata } = require("../src/shared/calldataDecoder");
const iface = new Interface(ERC20_ABI); const iface = new Interface(ERC20_ABI);
+2 -17
View File
@@ -13,7 +13,7 @@
// asserted on `rawValue` alongside the displayed line. // asserted on `rawValue` alongside the displayed line.
// //
// Both amount paths of that screen are covered: the ERC-20 line decoded by // 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`. // lines decoded by `src/shared/uniswap.js`.
globalThis.chrome = { globalThis.chrome = {
@@ -23,12 +23,8 @@ globalThis.chrome = {
const { AbiCoder, Interface } = require("ethers"); const { AbiCoder, Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants"); const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state"); 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 uniswap = require("../src/shared/uniswap");
const {
truncateAmount,
truncateAmountNeverZero,
} = require("../src/shared/amountDisplay");
const iface = new Interface(ERC20_ABI); const iface = new Interface(ERC20_ABI);
@@ -183,15 +179,4 @@ describe("the four-decimal rule is otherwise unchanged", () => {
test("a genuine zero still renders as zero", () => { test("a genuine zero still renders as zero", () => {
expect(amount(transferData(0n), DAI).rawValue).toBe("0.0000"); expect(amount(transferData(0n), DAI).rawValue).toBe("0.0000");
}); });
// The three truncators now share one module. The floor is a policy of the
// approval and confirmation screens only: the history and balance lists
// keep plain truncation, because the transaction detail view is the
// authoritative record and already shows exact precision.
test("the list rule stays unfloored", () => {
expect(truncateAmount("0.000000000000000001")).toBe("0.0000");
expect(truncateAmountNeverZero("0.000000000000000001")).toBe(
"0.000000000000000001",
);
});
}); });
+5 -5
View File
@@ -41,13 +41,13 @@ describe("balanceLine", () => {
// this payload before its id attribute, so an assertion about the // this payload before its id attribute, so an assertion about the
// rest of the payload would pass on the cap and say nothing about // rest of the payload would pass on the cap and say nothing about
// the escape. // the escape.
const html = balanceLine(HOSTILE_SYMBOL, 1, null, null); const html = balanceLine(HOSTILE_SYMBOL, "1", null, null);
expect(html).not.toContain("<iframe"); expect(html).not.toContain("<iframe");
expect(html).toContain("&lt;iframe"); expect(html).toContain("&lt;iframe");
}); });
test("caps the symbol before rendering it", () => { test("caps the symbol before rendering it", () => {
const html = balanceLine("A".repeat(4096), 1, null, null); const html = balanceLine("A".repeat(4096), "1", null, null);
expect(html).toContain("A".repeat(MAX_SYMBOL_LENGTH - 1) + "…"); expect(html).toContain("A".repeat(MAX_SYMBOL_LENGTH - 1) + "…");
expect(html).not.toContain("A".repeat(MAX_SYMBOL_LENGTH + 1)); expect(html).not.toContain("A".repeat(MAX_SYMBOL_LENGTH + 1));
}); });
@@ -55,13 +55,13 @@ describe("balanceLine", () => {
// The token id lands inside data-token="...", so a quote in it is a // The token id lands inside data-token="...", so a quote in it is a
// way out of the attribute and into a new one. // way out of the attribute and into a new one.
test("keeps a quote-bearing token id inside its attribute", () => { test("keeps a quote-bearing token id inside its attribute", () => {
const html = balanceLine("TKN", 1, null, '" onclick="alert(1)'); const html = balanceLine("TKN", "1", null, '" onclick="alert(1)');
expect(html).not.toContain('onclick="'); expect(html).not.toContain('onclick="');
expect(html).toContain('data-token="&quot; onclick=&quot;alert(1)"'); expect(html).toContain('data-token="&quot; onclick=&quot;alert(1)"');
}); });
test("renders an ordinary holding unchanged", () => { test("renders an ordinary holding unchanged", () => {
const html = balanceLine("USDC", 1.5, null, "0xabc"); const html = balanceLine("USDC", "1.5", null, "0xabc");
expect(html).toContain("<span>USDC</span>"); expect(html).toContain("<span>USDC</span>");
expect(html).toContain("<span>1.5000</span>"); expect(html).toContain("<span>1.5000</span>");
expect(html).toContain('data-token="0xabc"'); expect(html).toContain('data-token="0xabc"');
@@ -69,7 +69,7 @@ describe("balanceLine", () => {
// formatUsd() writes a value under a cent as "< $0.01". // formatUsd() writes a value under a cent as "< $0.01".
test("escapes the USD value along with the symbol", () => { test("escapes the USD value along with the symbol", () => {
const html = balanceLine("USDC", 0.001, 1, null); const html = balanceLine("USDC", "0.001", 1, null);
expect(html).toContain("&lt; $0.01"); expect(html).toContain("&lt; $0.01");
expect(html).not.toContain("< $0.01"); expect(html).not.toContain("< $0.01");
}); });
+91
View File
@@ -0,0 +1,91 @@
// The balance lists, Home's ETH line and Send's current balance 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.
const {
ADDRESS,
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const { balanceLine } = require("../src/popup/views/helpers");
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
afterEach(() => {
cleanupPopup();
});
// The quantity cell of a balance row.
function quantity(row) {
return row.match(/<span>([^<]*)<\/span><\/span>/)[1];
}
// The popup on Home over one address holding `eth` ETH and `usdc` USDC.
function bootHolding(eth, usdc) {
const profile = unversionedValidProfile({
trackedTokens: [{ address: USDC, symbol: "USDC", decimals: 6 }],
});
profile.wallets[0].addresses[0] = {
address: ADDRESS,
balance: eth,
tokenBalances: [
{ address: USDC, symbol: "USDC", decimals: 6, balance: usdc },
],
};
return bootPopup(profile);
}
// The balance row for `token` in Home's wallet list.
function homeRow(page, token) {
return page
.node("wallet-list")
.innerHTML.split("<div ")
.find((row) => row.includes('data-token="' + token + '"'));
}
describe("balanceLine", () => {
test("cuts 0.99996 to 0.9999, never rounding it up to 1.0000", () => {
expect(quantity(balanceLine("ETH", "0.99996", null, "ETH"))).toBe(
"0.9999",
);
});
test("cuts 0.00004 to 0.0000", () => {
expect(quantity(balanceLine("USDC", "0.00004", null, USDC))).toBe(
"0.0000",
);
});
});
describe("Home", () => {
test("the ETH line and the balance list read 0.99996 ETH as 0.9999", async () => {
const page = await bootHolding("0.99996", "0.00004");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.9999 ETH");
expect(quantity(homeRow(page, "ETH"))).toBe("0.9999");
});
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.0000");
});
});
describe("Send", () => {
test("the current balance reads 0.00004 USDC as 0.0000", async () => {
const page = await bootHolding("0.99996", "0.00004");
await page.click("btn-main-send");
// USDC chosen in the dropdown, as the user does.
const dropdown = page.node("send-token");
dropdown.value = USDC;
for (const fn of dropdown.listeners.change) await fn();
expect(page.visibleViews()).toEqual(["send"]);
expect(page.text("send-balance")).toBe("Current balance: 0.0000 USDC");
});
});
+5 -5
View File
@@ -4,8 +4,8 @@
// the copy is the substance and is tested as such. Two things it must not // the copy is the substance and is tested as such. Two things it must not
// get wrong: what it takes to get the address back — the app refuses both // get wrong: what it takes to get the address back — the app refuses both
// obvious routes — and what counts as holding something, which is any // obvious routes — and what counts as holding something, which is any
// ERC-20 as well as ETH, at any size, including a balance that rounds to // ERC-20 as well as ETH, at any size, including a balance below the four
// zero at the four decimals the balance lines render. The DOM behaviour // decimals the balance lines render. The DOM behaviour
// around them is driven against the real popup by tests/e2e/run.js. // around them is driven against the real popup by tests/e2e/run.js.
// helpers.js pulls in state.js, which reads chrome.storage.local at load. // helpers.js pulls in state.js, which reads chrome.storage.local at load.
@@ -165,9 +165,9 @@ describe("the balance warning on the removal confirmation", () => {
}); });
// The sentence must not assert an amount, because any amount it could // The sentence must not assert an amount, because any amount it could
// assert has been rounded: "This address holds 0.0000 ETH." is what the // assert has been cut to four decimals: "This address holds 0.0000 ETH." is
// rounded form produces for an address that holds real money. // what the cut form produces for an address that holds real money.
test("the warning sentence asserts no rounded amount", () => { test("the warning sentence asserts no cut amount", () => {
for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) { for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) {
expect(balanceWarningHtml(addr)).not.toMatch( expect(balanceWarningHtml(addr)).not.toMatch(
/holds [\d.]+ (ETH|USDC)/, /holds [\d.]+ (ETH|USDC)/,
+74 -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"); await visible(env.page, "#view-address");
}); });
// The native amount as the transaction list writes it (four decimals) and // The native amount as the transaction list writes it (cut to four decimals,
// as the detail screen writes it (full precision). Both are rendered here // quantity() below) and as the detail screen writes it (full precision). Both
// from the fixture rather than read off the screen, so the assertions // are rendered here from the fixture rather than read off the screen, so the
// compare against the wei the stub served. // assertions compare against the wei the stub served.
const NATIVE_ROW_TEXT = const NATIVE_ROW_TEXT = quantity(STUB_NATIVE_VALUE_WEI) + " ETH";
parseFloat(formatEther(STUB_NATIVE_VALUE_WEI)).toFixed(4) + " ETH";
const NATIVE_DETAIL_TEXT = formatEther(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) => { test("the native ETH transaction detail still renders (#151)", async (env) => {
@@ -952,6 +951,71 @@ test("tap-to-copy on the transaction detail screen copies the address (#151)", a
); );
}); });
// ------------------------------------------ history row time (#760)
// A Home history row shows the ISO datetime and then the relative age, on a
// line of its own. Measured at the popup's size: the time line is one line
// high, and no line of a row is wider than the row or the popup.
test("a Home history row shows the datetime and the age, one line high, without overflow (#760)", async (env) => {
await goHome(env.page);
env.routeOpts.seedNativeTransfer = true;
const page = await openPopup(env.ctx, env.popupUrl);
try {
await page.setViewportSize(POPUP_VIEWPORT);
await visible(page, "#view-main");
await page.waitForFunction(
() =>
document.querySelectorAll("#home-tx-list .home-tx-row")
.length === 2,
null,
{ timeout: 30000 },
);
const rows = await page.evaluate(() => {
const popupRight = document.body.getBoundingClientRect().right;
return [
...document.querySelectorAll("#home-tx-list .home-tx-row"),
].map((row) => {
const time = row.firstElementChild;
return {
time: time.textContent,
timeHeight: time.getBoundingClientRect().height,
lineHeight: parseFloat(getComputedStyle(time).lineHeight),
overflowingLines: [...row.children].filter(
(line) =>
line.scrollWidth > line.clientWidth ||
line.getBoundingClientRect().right > popupRight,
).length,
};
});
});
console.log("# Home history rows: " + JSON.stringify(rows));
for (const row of rows) {
assert(
/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(Z|[+-]\d\d:\d\d) \(\d+ \w+ ago\)$/.test(
row.time,
),
"the time line is not the ISO datetime and then the age: " +
JSON.stringify(row.time),
);
assert(
row.timeHeight === row.lineHeight,
"the time line is " +
row.timeHeight +
"px high, not one line of " +
row.lineHeight +
"px",
);
assert(
row.overflowingLines === 0,
row.overflowingLines + " line(s) of a row overflow it",
);
}
} finally {
env.routeOpts.seedNativeTransfer = false;
await page.close();
}
});
// ------------------------- the last of the #150 and #151 items (#295) // ------------------------- the last of the #150 and #151 items (#295)
// //
// Add Token's confirm button, TransactionDetail opened from the token screen // Add Token's confirm button, TransactionDetail opened from the token screen
@@ -2724,10 +2788,11 @@ async function goToConfirm(page, send) {
await assertAddressesFit(page, "the confirmation screen"); await assertAddressesFit(page, "the confirmation screen");
} }
// A balance as the main view renders it: balanceLinesForAddress() writes // An amount in wei as the main view's balance lines and the transaction list
// every quantity with four decimal places. // write it: cut to four decimal places, never rounded, as feeEth() does.
function quantity(wei) { 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. // 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 { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount"); 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 { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
const HOLDER = "0x" + "a".repeat(40); const HOLDER = "0x" + "a".repeat(40);
+13 -2
View File
@@ -1,4 +1,5 @@
// The release archives: the zip container, and the self-containment rule. // The release archives: the zip container, the self-containment rule, and the
// digest format in release/SHA256SUMS.
// //
// script/package produces one archive per browser and script/lib/package.js // script/package produces one archive per browser and script/lib/package.js
// decides what goes in it. The trap that rule exists for is real and specific: // decides what goes in it. The trap that rule exists for is real and specific:
@@ -11,12 +12,14 @@
// So the packager resolves every reference in the manifest and in every HTML // So the packager resolves every reference in the manifest and in every HTML
// document, and fails on any that leaves the extension root. These are the // document, and fails on any that leaves the extension root. These are the
// cases for that, plus the archive format itself — new code, and the thing the // cases for that, plus the archive format itself — new code, and the thing the
// artifact is made of. // artifact is made of. One more case checks the digest format in
// release/SHA256SUMS.
const { const {
checkSelfContained, checkSelfContained,
htmlReferences, htmlReferences,
manifestReferences, manifestReferences,
sha256,
} = require("../script/lib/package"); } = require("../script/lib/package");
const { readZip, writeZip } = require("../script/lib/zip"); const { readZip, writeZip } = require("../script/lib/zip");
@@ -142,6 +145,14 @@ describe("archive self-containment", () => {
}); });
}); });
// release/SHA256SUMS is checked with `sha256sum -c`, which needs the digest
// as bare lowercase hex. The expected value is the FIPS 180-2 digest of "abc".
test("sha256 prints the digest as sha256sum does", () => {
expect(sha256(Buffer.from("abc"))).toBe(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
);
});
describe("the zip container", () => { describe("the zip container", () => {
const files = [ const files = [
{ name: "manifest.json", data: Buffer.from('{"a":1}') }, { name: "manifest.json", data: Buffer.from('{"a":1}') },
+1 -1
View File
@@ -22,7 +22,7 @@ const { Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants"); const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { resolveTokenSymbol } = require("../src/shared/approvalAmount"); 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); const iface = new Interface(ERC20_ABI);
+21 -16
View File
@@ -6,8 +6,10 @@
// balances and the fee were each cut to six decimal places, a rule of their // balances and the fee were each cut to six decimal places, a rule of their
// own, and a token holding cut to zero was dropped, while the approval screen // own, and a token holding cut to zero was dropped, while the approval screen
// showed the same fee through src/shared/amountDisplay.js with the nonzero // showed the same fee through src/shared/amountDisplay.js with the nonzero
// floor. The balances are now stored exactly, every nonzero token holding // floor. The balances are now stored exactly, and every nonzero token holding
// kept, and the screens show them and the fee through that helper. // is kept. The fee lines show the fee through that helper with the floor. The
// balance lines cut a balance to four decimals, as the balance lists do, so a
// balance below 0.0001 reads 0.0000.
// //
// Driven through the real refreshBalances(), Send screen, confirmation screen // Driven through the real refreshBalances(), Send screen, confirmation screen
// and approval screen, with only the node, the explorer and the DOM stubbed: a // and approval screen, with only the node, the explorer and the DOM stubbed: a
@@ -278,21 +280,22 @@ beforeEach(() => {
mockNode.feeData = { maxFeePerGas: 1n, gasPrice: 1n }; mockNode.feeData = { maxFeePerGas: 1n, gasPrice: 1n };
}); });
describe("an ETH balance below 0.000001 never renders as zero", () => { describe("an ETH balance below 0.0001 reads 0.0000", () => {
test("on the Send screen", async () => { test("on the Send screen", async () => {
await refreshWith(HALF_MICRO_ETH); await refreshWith(HALF_MICRO_ETH);
state.selectedToken = "ETH"; state.selectedToken = "ETH";
send.updateSendBalance(); send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 0.0000005 ETH"); expect(text("send-balance")).toBe("Current balance: 0.0000 ETH");
expect(state.wallets[0].addresses[0].balance).toBe("0.0000005");
}); });
test("on the confirmation screen", async () => { test("on the confirmation screen", async () => {
await refreshWith(HALF_MICRO_ETH); await refreshWith(HALF_MICRO_ETH);
await confirmSend("0.0000001"); await confirmSend("0.0000001");
expect(text("confirm-balance")).toBe("0.0000005 ETH"); expect(text("confirm-balance")).toBe("0.0000 ETH");
}); });
test("while a balance above the floor keeps four decimals", async () => { test("while a larger balance is cut to four decimals", async () => {
await refreshWith(1234567890000000000n); await refreshWith(1234567890000000000n);
await confirmSend("0.1"); await confirmSend("0.1");
expect(text("confirm-balance")).toBe("1.2345 ETH"); expect(text("confirm-balance")).toBe("1.2345 ETH");
@@ -302,7 +305,7 @@ describe("an ETH balance below 0.000001 never renders as zero", () => {
// A token the user tracks stays on the balance list when the explorer's row is // A token the user tracks stays on the balance list when the explorer's row is
// dropped, so a holding of it below 0.000001 reached these screens as zero, and // dropped, so a holding of it below 0.000001 reached these screens as zero, and
// the send was checked against zero. // the send was checked against zero.
describe("a tracked token holding below 0.000001 never renders as zero", () => { describe("a tracked token holding below 0.000001 reads 0.0000", () => {
beforeEach(() => { beforeEach(() => {
state.trackedTokens = [ state.trackedTokens = [
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 }, { address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
@@ -313,13 +316,17 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]); await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
state.selectedToken = TOKEN; state.selectedToken = TOKEN;
send.updateSendBalance(); send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 0.0000005 TOK"); expect(text("send-balance")).toBe("Current balance: 0.0000 TOK");
const stored = state.wallets[0].addresses[0].tokenBalances.find(
(t) => t.address === TOKEN,
);
expect(stored.balance).toBe("0.0000005");
}); });
test("on the confirmation screen, which checks the send against it", async () => { test("on the confirmation screen, which checks the send against it", async () => {
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]); await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
await confirmSend("0.0000005", TOKEN); await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(text("confirm-balance")).toBe("0.0000 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe(""); expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN); await confirmSend("0.0000006", TOKEN);
@@ -347,11 +354,9 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await refreshWith(10n ** 18n, [tokenRow(1n, { decimals: "24" })]); await refreshWith(10n ** 18n, [tokenRow(1n, { decimals: "24" })]);
state.selectedToken = TOKEN; state.selectedToken = TOKEN;
send.updateSendBalance(); send.updateSendBalance();
expect(text("send-balance")).toBe( expect(text("send-balance")).toBe("Current balance: 0.0000 TOK");
"Current balance: 0.000000000000000000000001 TOK",
);
await confirmSend("0.000000000000000001", TOKEN); await confirmSend("0.000000000000000001", TOKEN);
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK"); expect(text("confirm-balance")).toBe("0.0000 TOK");
expect(balanceError()).toContain( expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.", "Your TOK balance does not cover this amount.",
); );
@@ -363,20 +368,20 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
// selected while its own screen is open: after sending 2 of a 2.0000003 // selected while its own screen is open: after sending 2 of a 2.0000003
// holding, the user is back on that screen, and Send read the missing row as // holding, the user is back on that screen, and Send read the missing row as
// zero. // zero.
describe("an untracked token holding below 0.000001 never renders as zero", () => { describe("an untracked token holding below 0.000001 reads 0.0000", () => {
const row = () => tokenRow(HALF_MICRO_ETH, { holders_count: "50000" }); const row = () => tokenRow(HALF_MICRO_ETH, { holders_count: "50000" });
test("on the Send screen", async () => { test("on the Send screen", async () => {
await refreshWith(10n ** 18n, [row()]); await refreshWith(10n ** 18n, [row()]);
state.selectedToken = TOKEN; state.selectedToken = TOKEN;
send.updateSendBalance(); send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 0.0000005 TOK"); expect(text("send-balance")).toBe("Current balance: 0.0000 TOK");
}); });
test("on the confirmation screen, which checks the send against it", async () => { test("on the confirmation screen, which checks the send against it", async () => {
await refreshWith(10n ** 18n, [row()]); await refreshWith(10n ** 18n, [row()]);
await confirmSend("0.0000005", TOKEN); await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(text("confirm-balance")).toBe("0.0000 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe(""); expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN); await confirmSend("0.0000006", TOKEN);
+101 -62
View File
@@ -1,15 +1,14 @@
// A transaction's time is written by isoDate() and timeAgo() in // A transaction's time shows the ISO datetime and the relative age on every
// src/popup/views/helpers.js on every screen that shows one (README, Amounts; // screen that shows one (README, Amounts). Home, AddressDetail and AddressToken
// https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail and // used to show only the age, with the datetime as a tooltip
// AddressToken used to define their own copies, so a fix to the shared pair // (https://git.eeqj.de/sneak/AutistMask/issues/760).
// would not have reached them.
// //
// The pair is replaced before the views are loaded, because a view takes it // The views run against a minimal DOM stub, as in
// when it loads. A view that writes the time with a copy of its own shows the // tests/historyAfterNetworkSwitch.test.js, and over
// real time instead of the replacement. // tests/support/storageStub.js.
//
// Driven against a minimal DOM stub in the shape const { makeStorageStub } = require("./support/storageStub");
// tests/contractCreation.test.js uses. const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
jest.mock("../src/shared/log", () => ({ jest.mock("../src/shared/log", () => ({
log: { log: {
@@ -26,33 +25,78 @@ jest.mock("../src/shared/log", () => ({
})); }));
// The history lists ask the explorer for their transactions and resolve ENS // The history lists ask the explorer for their transactions and resolve ENS
// names for them; here the explorer answers with mockHistory and no name // names for them; here the explorer answers with mockHistory() and no name
// resolves. // resolves.
let mockHistory = [];
jest.mock("../src/shared/transactions", () => ({ jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"), ...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: async () => mockHistory, fetchRecentTransactions: async () => mockHistory(),
})); }));
jest.mock("../src/shared/ens", () => ({ jest.mock("../src/shared/ens", () => ({
...jest.requireActual("../src/shared/ens"), ...jest.requireActual("../src/shared/ens"),
resolveEnsNames: async () => new Map(), resolveEnsNames: async () => new Map(),
})); }));
globalThis.chrome = { const { state, loadState } = require("../src/shared/state");
storage: { local: { get: async () => ({}), set: async () => {} } }, const home = require("../src/popup/views/home");
};
const helpers = require("../src/popup/views/helpers");
jest.spyOn(helpers, "isoDate").mockReturnValue("SHARED-ISO-DATE");
jest.spyOn(helpers, "timeAgo").mockReturnValue("SHARED-TIME-AGO");
const { state } = require("../src/shared/state");
const addressDetail = require("../src/popup/views/addressDetail"); const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken"); const addressToken = require("../src/popup/views/addressToken");
const transactionDetail = require("../src/popup/views/transactionDetail"); const transactionDetail = require("../src/popup/views/transactionDetail");
const FROM = "0x0000000000000000000000000000000000000a11"; const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const TIMESTAMP = 1790000000;
// TIMESTAMP as shown with "UTC Timestamps" on, three hours after it.
const SHOWN_TIME = "2026-09-21T14:13:20Z (3 hours ago)";
// A profile on Ethereum Mainnet with one address, "Use Blockscout" and "UTC
// Timestamps" on.
function profile() {
return {
schemaVersion: STATE_SCHEMA_VERSION,
hasWallet: true,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-main",
encryptedSecret: "encrypted-secret",
nextIndex: 1,
addresses: [
{ address: FROM, balance: "1.0", tokenBalances: [] },
],
},
],
activeAddress: FROM,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true,
utcTimestamps: true,
};
}
// A transfer of ETH from FROM, as Blockscout answers it.
function mockHistory() {
return [
{
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
blockNumber: 1,
from: FROM,
to: RECIPIENT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: TIMESTAMP,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
chainId: "0x1",
},
];
}
function makeElement(id) { function makeElement(id) {
const el = { const el = {
@@ -90,6 +134,7 @@ function makeDocument() {
return els.get(id); return els.get(id);
}, },
createElement: () => makeElement("created"), createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} }, body: { prepend: () => {} },
}; };
} }
@@ -98,64 +143,58 @@ function node(id) {
return globalThis.document.getElementById(id); return globalThis.document.getElementById(id);
} }
// A transaction FROM sent, as the history lists hold it. // Lets every promise that is not held open settle.
function historyTx() { function settle() {
return { return new Promise((resolve) => setTimeout(resolve, 0));
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
from: FROM,
to: RECIPIENT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
};
} }
beforeEach(() => { beforeEach(async () => {
const storage = makeStorageStub({ autistmask: profile() });
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
};
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.wallets = [ jest.spyOn(Date, "now").mockReturnValue((TIMESTAMP + 3 * 3600) * 1000);
{ await loadState();
name: "Main",
type: "key",
addresses: [{ address: FROM, balance: "0.0000" }],
},
];
state.trackedTokens = [];
state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null;
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 0; state.selectedAddress = 0;
state.selectedToken = "ETH"; state.selectedToken = "ETH";
// The history is Blockscout's. const ctx = { pageClosed: new AbortController().signal };
state.useBlockscout = true; addressDetail.init(ctx);
addressToken.init(ctx);
});
afterEach(() => {
jest.restoreAllMocks();
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
}); });
describe.each([ describe.each([
[
"Home",
"home-tx-list",
() => home.render({ pageClosed: new AbortController().signal }),
],
["AddressDetail", "tx-list", () => addressDetail.show()], ["AddressDetail", "tx-list", () => addressDetail.show()],
["AddressToken", "address-token-tx-list", () => addressToken.show()], ["AddressToken", "address-token-tx-list", () => addressToken.show()],
])("a transaction history row on %s", (_name, listId, open) => { ])("a transaction history row on %s", (_name, listId, open) => {
test("shows the time written by the shared isoDate() and timeAgo()", async () => { test("shows the ISO datetime and then the relative age", async () => {
mockHistory = [historyTx()];
open(); open();
// The list is drawn once the history has been fetched. // The list is drawn once the history has been fetched.
await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 10; i++) await settle();
const html = node(listId).innerHTML; const html = node(listId).innerHTML;
expect(html).toContain('title="SHARED-ISO-DATE"'); expect(html).toContain(">" + SHOWN_TIME + "<");
expect(html).toContain(">SHARED-TIME-AGO<"); expect(html).not.toContain("title=");
}); });
}); });
test("the transaction detail view shows the time written by the shared isoDate() and timeAgo()", () => { test("the transaction detail view shows the ISO datetime, copyable, and then the relative age", () => {
transactionDetail.show(historyTx()); transactionDetail.show(mockHistory()[0]);
const html = node("tx-detail-time").innerHTML; const html = node("tx-detail-time").innerHTML;
expect(html).toContain("SHARED-ISO-DATE"); expect(html).toContain('data-copy="2026-09-21T14:13:20Z"');
expect(html).toContain("(SHARED-TIME-AGO)"); expect(html).toContain(">2026-09-21T14:13:20Z</span> (3 hours ago)");
}); });
+25
View File
@@ -1361,6 +1361,31 @@ describe("fetchRecentTransactions merge and dedup", () => {
expect(txs[0].holders).toBe(3500000); expect(txs[0].holders).toBe(3500000);
}); });
// 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(
[],
[
{
transaction_hash: "0x" + "4".repeat(64),
block_number: 21000040,
timestamp: TS,
from: { hash: ORDINARY_PEER },
to: { hash: VICTIM },
total: { value: "40", decimals: "6" },
token: {
symbol: "USDC",
address_hash: USDC_CONTRACT,
holders_count: "3500000",
},
},
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
expect(txs[0].value).toBe("0.0000");
});
test("two transfers of the same token in one transaction collapse to one entry", async () => { test("two transfers of the same token in one transaction collapse to one entry", async () => {
const hash = "0x" + "3".repeat(64); const hash = "0x" + "3".repeat(64);
const leg = (value) => ({ const leg = (value) => ({
+1 -1
View File
@@ -47,7 +47,7 @@ globalThis.chrome = {
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { fetchRecentTransactions } = require("../src/shared/transactions"); const { fetchRecentTransactions } = require("../src/shared/transactions");
const approval = require("../src/popup/views/approval"); const approval = require("../src/popup/views/approval");
const { decodeCalldata } = approval; const { decodeCalldata } = require("../src/shared/calldataDecoder");
const transactionDetail = require("../src/popup/views/transactionDetail"); const transactionDetail = require("../src/popup/views/transactionDetail");
const MAINNET = "0x1"; const MAINNET = "0x1";
+1 -1
View File
@@ -18,7 +18,7 @@ globalThis.chrome = {
const { AbiCoder, Interface } = require("ethers"); const { AbiCoder, Interface } = require("ethers");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount"); const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval"); const { decodeCalldata } = require("../src/shared/calldataDecoder");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
+2 -2
View File
@@ -96,7 +96,7 @@ afterEach(() => {
describe("balanceLine", () => { describe("balanceLine", () => {
test("an unknown quantity and a zero one render differently", () => { test("an unknown quantity and a zero one render differently", () => {
const unknown = balanceLine("NOVEL", null, null, NOVEL); const unknown = balanceLine("NOVEL", null, null, NOVEL);
const zero = balanceLine("NOVEL", 0, null, NOVEL); const zero = balanceLine("NOVEL", "0", null, NOVEL);
expect(unknown).not.toBe(zero); expect(unknown).not.toBe(zero);
expect(quantities(unknown)).toEqual(["NOVEL", "quantity unknown"]); expect(quantities(unknown)).toEqual(["NOVEL", "quantity unknown"]);
expect(quantities(zero)).toEqual(["NOVEL", "0.0000"]); expect(quantities(zero)).toEqual(["NOVEL", "0.0000"]);
@@ -105,7 +105,7 @@ describe("balanceLine", () => {
test("an unknown quantity produces no fiat figure, a zero one does", () => { test("an unknown quantity produces no fiat figure, a zero one does", () => {
prices[NOVEL] = 3; prices[NOVEL] = 3;
const unknown = balanceLine("NOVEL", null, 3, NOVEL); const unknown = balanceLine("NOVEL", null, 3, NOVEL);
const zero = balanceLine("NOVEL", 0, 3, NOVEL); const zero = balanceLine("NOVEL", "0", 3, NOVEL);
// A price times an unknown quantity is not $0.00: that is the same // A price times an unknown quantity is not $0.00: that is the same
// claim of "nothing here" the quantity cell just refused to make. // claim of "nothing here" the quantity cell just refused to make.
expect(unknown).toContain( expect(unknown).toContain(