Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bccfd61f60 |
No files matched your search
@@ -264,8 +264,6 @@ 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
|
||||
@@ -1191,23 +1189,15 @@ 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
|
||||
cut amounts to four decimals and never round up (`truncateAmount()` in
|
||||
`src/shared/amountDisplay.js`). These balances are cut the same way:
|
||||
|
||||
- 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
|
||||
`src/shared/amountDisplay.js`). The balance lists and Home's ETH balance round
|
||||
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
|
||||
(`truncateAmountNeverZero()`). So a nonzero amount never reads as zero there.
|
||||
Those screens are:
|
||||
|
||||
- TxApproval;
|
||||
- SignApproval's Permit warning;
|
||||
- ConfirmTx's amount and network fee;
|
||||
- ConfirmTx, and Send's current balance;
|
||||
- WaitTx, SuccessTx and ErrorTx.
|
||||
|
||||
TransactionDetail shows the exact amount and its base units.
|
||||
@@ -1274,8 +1264,9 @@ 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.
|
||||
The decoded swap lines call the native currency `ETH` on both networks.
|
||||
|
||||
TransactionDetail and every history row show the time as an ISO datetime and
|
||||
then a relative age. With "UTC Timestamps" on, the datetime is in UTC.
|
||||
TransactionDetail shows the time as an ISO datetime and a relative age. A
|
||||
history row shows the age, with the datetime as its tooltip. With "UTC
|
||||
Timestamps" on, the datetime is in UTC.
|
||||
|
||||
Every nonzero holding is stored exactly. A token holding below 0.000001 is left
|
||||
out of:
|
||||
@@ -1388,7 +1379,7 @@ approval screens show a red warning.
|
||||
### Transaction decoding
|
||||
|
||||
TxApproval and TransactionDetail try to decode calldata into a summary
|
||||
(`decodeCalldata()` in `src/shared/calldataDecoder.js`). SuccessTx repeats
|
||||
(`decodeCalldata()` in `src/popup/views/approval.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.
|
||||
@@ -1654,10 +1645,11 @@ The development dependencies ship nowhere:
|
||||
|
||||
### Crypto policy
|
||||
|
||||
In the extension, hashing, key derivation, signing and encryption go through
|
||||
`ethers` or `libsodium-wrappers-sumo`. A new recovery phrase takes its random
|
||||
bytes from `ethers`. `build.js` and `script/lib/package.js` also hash files with
|
||||
`ethers`. No primitive is implemented in this repository.
|
||||
Hashing, key derivation, signing and encryption go through `ethers` or
|
||||
`libsodium-wrappers-sumo`. No primitive is implemented in this repository. A new
|
||||
recovery phrase draws its random bytes from the browser's
|
||||
`crypto.getRandomValues()`. The build tooling, `build.js` and `script/lib/`,
|
||||
hashes files with Node's `crypto`; none of it ships in the extension.
|
||||
|
||||
### Content Security Policy
|
||||
|
||||
|
||||
@@ -108,7 +108,10 @@ contradicts either, the originals govern.
|
||||
- [ ] All tool invocations via `make` targets, never directly
|
||||
- [ ] `make check` = `make test` + `make lint` + `make fmt-check`
|
||||
- [ ] `main` always passes `make check`
|
||||
- [ ] Feature branches for all changes, merge to main when done
|
||||
- [ ] Branch per issue, cut from `next`. One PR per issue, based on `next`,
|
||||
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`
|
||||
- [ ] Pre-commit hook runs `make check`
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const { execSync } = require("child_process");
|
||||
const esbuild = require("esbuild");
|
||||
const { sha256 } = require("ethers");
|
||||
const { resolveVersion } = require("./script/lib/version");
|
||||
const {
|
||||
BACKGROUND_ENTRY_PREFIX,
|
||||
@@ -314,8 +314,10 @@ function copyIcons(distDir) {
|
||||
}
|
||||
|
||||
function sha256File(absPath) {
|
||||
// ethers prefixes the hex with 0x; the receipt holds bare hex.
|
||||
return sha256(fs.readFileSync(absPath)).slice(2);
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(fs.readFileSync(absPath))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
// Write the receipt for the files this build emitted. Deliberately records no
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
|
||||
"use strict";
|
||||
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ethers = require("ethers");
|
||||
|
||||
const { readZip, writeZip } = require("./zip");
|
||||
const { resolveVersion } = require("./version");
|
||||
@@ -60,9 +60,8 @@ function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Lowercase hex without the 0x prefix ethers adds, as sha256sum prints it.
|
||||
function sha256(buf) {
|
||||
return ethers.sha256(buf).slice(2);
|
||||
return crypto.createHash("sha256").update(buf).digest("hex");
|
||||
}
|
||||
|
||||
// Every regular file under dir, as archive-root-relative forward-slashed
|
||||
@@ -292,9 +291,4 @@ if (require.main === module) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkSelfContained,
|
||||
htmlReferences,
|
||||
manifestReferences,
|
||||
sha256,
|
||||
};
|
||||
module.exports = { checkSelfContained, htmlReferences, manifestReferences };
|
||||
@@ -12,7 +12,8 @@ const {
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
pushCurrentView,
|
||||
transactionTimeHtml,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
||||
@@ -235,11 +236,10 @@ function renderTransactions(txs) {
|
||||
const nameStr = escapeHtml(title || ensName || "");
|
||||
const err = tx.isError ? " (failed)" : "";
|
||||
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}">`;
|
||||
// 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 += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
||||
html += `</div>`;
|
||||
i++;
|
||||
|
||||
@@ -18,7 +18,8 @@ const {
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
pushCurrentView,
|
||||
transactionTimeHtml,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { bundledToken, resolveSymbol } = require("../../shared/tokenList");
|
||||
@@ -158,10 +159,10 @@ function renderBalance() {
|
||||
const addr =
|
||||
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
|
||||
const tokenId = state.selectedToken;
|
||||
let balance, price;
|
||||
let amount, price;
|
||||
if (tokenId === "ETH") {
|
||||
// null when no refresh has read the balance.
|
||||
balance = addr.balance;
|
||||
amount = unknownableAmount(addr.balance);
|
||||
price = getPrice("ETH");
|
||||
} else {
|
||||
const id = tokenId.toLowerCase();
|
||||
@@ -174,13 +175,12 @@ function renderBalance() {
|
||||
// null when the scale is unknown, or when no refresh has read the
|
||||
// token's balance: no quantity to show, and none to price.
|
||||
// balanceLine() states that rather than printing 0.0000.
|
||||
if (tb) balance = tb.balance;
|
||||
else balance = tracked && tracked.balanceNotFetched ? null : "0";
|
||||
if (tb) amount = unknownableAmount(tb.balance);
|
||||
else amount = tracked && tracked.balanceNotFetched ? null : 0;
|
||||
price = getPrice(tokenId);
|
||||
}
|
||||
|
||||
// USD total for this token only
|
||||
const amount = unknownableAmount(balance);
|
||||
const usdVal = price && amount !== null ? amount * price : null;
|
||||
const usdStr = formatUsd(usdVal);
|
||||
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " ";
|
||||
@@ -188,7 +188,7 @@ function renderBalance() {
|
||||
// Single token balance line (no tokenId — not clickable here)
|
||||
$("address-token-balance").innerHTML = balanceLine(
|
||||
currentSymbol,
|
||||
balance,
|
||||
amount,
|
||||
price,
|
||||
);
|
||||
}
|
||||
@@ -328,11 +328,10 @@ function renderTransactions(txs) {
|
||||
const nameStr = escapeHtml(title || ensName || "");
|
||||
const err = tx.isError ? " (failed)" : "";
|
||||
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}">`;
|
||||
// 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 += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
||||
html += `</div>`;
|
||||
i++;
|
||||
|
||||
+144
-12
@@ -12,13 +12,9 @@ const {
|
||||
formatFee,
|
||||
tokenLabel,
|
||||
blockscoutOffTokenNoteHtml,
|
||||
} = require("./helpers");
|
||||
const {
|
||||
state,
|
||||
saveState,
|
||||
currentNetwork,
|
||||
tokenSourcesByChainId,
|
||||
} = require("../../shared/state");
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const {
|
||||
networkById,
|
||||
networkByChainId,
|
||||
@@ -30,24 +26,21 @@ 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. The
|
||||
// ERC-20 line gets it from tokenAmountText().
|
||||
// it carries forward to the wait/success/error screens goes through it.
|
||||
const {
|
||||
truncateAmountNeverZero: formatTxValue,
|
||||
} = require("../../shared/amountDisplay");
|
||||
@@ -61,8 +54,11 @@ 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.
|
||||
@@ -74,6 +70,141 @@ 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;
|
||||
@@ -1167,6 +1298,7 @@ function init(_ctx) {
|
||||
module.exports = {
|
||||
init,
|
||||
show,
|
||||
decodeCalldata,
|
||||
formatTypedDataHtml,
|
||||
typedDataRefusal,
|
||||
};
|
||||
@@ -43,12 +43,9 @@ const {
|
||||
transferAmountUnits,
|
||||
} = require("../../shared/transferAmount");
|
||||
const { assertWithinCeilings } = require("../../shared/approvalVerify");
|
||||
// The amount and the fee reserve keep the nonzero floor, as the approval
|
||||
// screen's amounts do. The balance line is cut as the balance lists cut it.
|
||||
const {
|
||||
truncateAmount,
|
||||
truncateAmountNeverZero,
|
||||
} = require("../../shared/amountDisplay");
|
||||
// The amount, the balance line and the fee reserve go through it, as the
|
||||
// approval screen's amounts do.
|
||||
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
|
||||
const {
|
||||
CODES,
|
||||
FEE_PENDING,
|
||||
@@ -163,7 +160,7 @@ function renderBalance(txInfo) {
|
||||
const price = getPrice(txInfo.token, txInfo.chainId);
|
||||
const balUsd = price ? parseFloat(bal) * price : null;
|
||||
$("confirm-balance").textContent = valueWithUsd(
|
||||
truncateAmount(bal) + " " + symbol,
|
||||
truncateAmountNeverZero(bal) + " " + symbol,
|
||||
balUsd,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 cut to four decimals, so a sentence built from their
|
||||
// figure would report "0.0000 ETH" for an address holding real money.
|
||||
// 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.
|
||||
// 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
|
||||
|
||||
+46
-34
@@ -18,7 +18,6 @@
|
||||
const { formatEther } = require("ethers");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const {
|
||||
truncateAmount,
|
||||
truncateAmountNeverZero,
|
||||
isBelowOneMillionth,
|
||||
} = require("../../shared/amountDisplay");
|
||||
@@ -32,12 +31,7 @@ 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,
|
||||
tokenSourcesByChainId,
|
||||
} = require("../../shared/state");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { displaySymbol } = require("../../shared/symbolDisplay");
|
||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||
@@ -311,6 +305,32 @@ 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
|
||||
@@ -374,15 +394,13 @@ function formatFee(wei, symbol, chainId) {
|
||||
// the row that issue #307 was reported against: every screen that lists a
|
||||
// holding renders through here.
|
||||
//
|
||||
// `balance` is the stored balance, a decimal string. It is null for a holding
|
||||
// whose scale nothing knows
|
||||
// `amount` is null for a holding whose scale nothing knows
|
||||
// (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
|
||||
// derive from one. A holding whose amount is unknown is shown as
|
||||
// `quantity unknown` rather than as a zero.
|
||||
function balanceLine(symbol, balance, price, tokenId) {
|
||||
const amount = unknownableAmount(balance);
|
||||
const qty = amount === null ? "quantity unknown" : truncateAmount(balance);
|
||||
// derive from one, and printing 0.0000 for a real holding is the failure this
|
||||
// whole rule exists to prevent, so the row says so instead.
|
||||
function balanceLine(symbol, amount, price, tokenId) {
|
||||
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||
const usd =
|
||||
price && amount !== null
|
||||
? escapeHtml(formatUsd(amount * price)) || " "
|
||||
@@ -397,7 +415,7 @@ function balanceLine(symbol, balance, price, tokenId) {
|
||||
`<div class="flex text-xs${clickClass}"${tokenAttr}>` +
|
||||
`<span class="flex justify-between w-[42ch] max-w-full">` +
|
||||
`<span>${escapeHtml(displaySymbol(symbol))}</span>` +
|
||||
`<span>${escapeHtml(qty)}</span>` +
|
||||
`<span>${qty}</span>` +
|
||||
`</span>` +
|
||||
`<span class="text-right text-muted flex-1">${usd}</span>` +
|
||||
`</div>`
|
||||
@@ -423,7 +441,7 @@ function markTrackedTokensNotFetched() {
|
||||
function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
let html = balanceLine(
|
||||
nativeCurrency(),
|
||||
addr.balance,
|
||||
unknownableAmount(addr.balance),
|
||||
getPrice("ETH"),
|
||||
"ETH",
|
||||
);
|
||||
@@ -441,7 +459,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
if (bal === 0 && !showZero) continue;
|
||||
html += balanceLine(
|
||||
t.symbol,
|
||||
t.balance,
|
||||
bal,
|
||||
getPrice(t.address),
|
||||
t.address.toLowerCase(),
|
||||
);
|
||||
@@ -459,7 +477,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
}
|
||||
html += balanceLine(
|
||||
t.symbol,
|
||||
t.balanceNotFetched ? null : "0",
|
||||
t.balanceNotFetched ? null : 0,
|
||||
getPrice(t.address),
|
||||
t.address.toLowerCase(),
|
||||
);
|
||||
@@ -470,10 +488,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. 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.
|
||||
// under the remove-address warning leaves out too. Deliberately unrounded —
|
||||
// the rendered lines round 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
|
||||
@@ -615,8 +633,9 @@ function formatAddressHtml(address, ensName, maxLen, title) {
|
||||
return renderAddressHtml(address, { title, ensName, maxLen });
|
||||
}
|
||||
|
||||
// The ISO datetime of a transaction's time, in UTC when the UTC Timestamps
|
||||
// setting is on. Views show it through transactionTimeHtml().
|
||||
// A transaction's time as every screen shows it (README, Amounts): the ISO
|
||||
// datetime, in UTC when the UTC Timestamps setting is on, and the relative age.
|
||||
// Views import these two; they keep no copies.
|
||||
function isoDate(timestamp) {
|
||||
const d = new Date(timestamp * 1000);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
@@ -673,15 +692,6 @@ function timeAgo(timestamp) {
|
||||
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.
|
||||
const EXT_ICON =
|
||||
`<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` +
|
||||
@@ -856,6 +866,7 @@ module.exports = {
|
||||
keyMaterialName,
|
||||
unknownableAmount,
|
||||
nativeCurrency,
|
||||
tokenSourcesByChainId,
|
||||
blockscoutOffTokenNoteHtml,
|
||||
tokenLabel,
|
||||
formatFee,
|
||||
@@ -877,5 +888,6 @@ module.exports = {
|
||||
reviewedNetworkProvider,
|
||||
EXT_ICON,
|
||||
truncateMiddle,
|
||||
transactionTimeHtml,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
};
|
||||
@@ -5,7 +5,8 @@ const {
|
||||
balanceLinesForAddress,
|
||||
unknownableAmount,
|
||||
markTrackedTokensNotFetched,
|
||||
transactionTimeHtml,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
addressDotHtml,
|
||||
txCounterpartyHtml,
|
||||
HISTORY_OFF_TEXT,
|
||||
@@ -30,7 +31,6 @@ const {
|
||||
resetSendValidation,
|
||||
} = require("./send");
|
||||
const { deriveAddressFromXpub } = require("../../shared/wallet");
|
||||
const { truncateAmount } = require("../../shared/amountDisplay");
|
||||
const { canRemoveAddress } = require("../../shared/walletDelete");
|
||||
const {
|
||||
walletDefect,
|
||||
@@ -81,7 +81,7 @@ function renderTotalValue() {
|
||||
if (ethBal === null) {
|
||||
el.textContent = "unknown (" + nativeCurrency() + ")";
|
||||
} else {
|
||||
const ethStr = truncateAmount(addr.balance) + " " + nativeCurrency();
|
||||
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
|
||||
const ethUsd = ethPrice
|
||||
? " (" + formatUsd(ethBal * ethPrice) + ")"
|
||||
: "";
|
||||
@@ -149,11 +149,10 @@ function renderHomeTxList(ctx) {
|
||||
const titleStr = title ? escapeHtml(title) : "";
|
||||
const err = tx.isError ? " (failed)" : "";
|
||||
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}">`;
|
||||
// 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 += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
|
||||
html += `</div>`;
|
||||
i++;
|
||||
|
||||
+10
-11
@@ -10,13 +10,9 @@ const {
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
} = require("./helpers");
|
||||
const {
|
||||
state,
|
||||
currentAddress,
|
||||
currentNetwork,
|
||||
tokenSourcesByChainId,
|
||||
} = require("../../shared/state");
|
||||
} = require("./helpers");
|
||||
const { state, currentAddress, currentNetwork } = require("../../shared/state");
|
||||
let ctx;
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
|
||||
@@ -24,7 +20,7 @@ const { resolveSymbol } = require("../../shared/tokenList");
|
||||
const { isLowHolderCount } = require("../../shared/holders");
|
||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||
const {
|
||||
truncateAmount,
|
||||
truncateAmountNeverZero,
|
||||
isBelowOneMillionth,
|
||||
} = require("../../shared/amountDisplay");
|
||||
const {
|
||||
@@ -239,7 +235,7 @@ function updateSendBalance() {
|
||||
addr.balance == null
|
||||
? "Current balance: unknown (" + nativeCurrency() + ")"
|
||||
: "Current balance: " +
|
||||
truncateAmount(addr.balance) +
|
||||
truncateAmountNeverZero(addr.balance) +
|
||||
" " +
|
||||
nativeCurrency();
|
||||
} else {
|
||||
@@ -258,15 +254,18 @@ function updateSendBalance() {
|
||||
$("send-balance").textContent =
|
||||
bal == null
|
||||
? "Current balance: unknown (" + symbol + ")"
|
||||
: "Current balance: " + truncateAmount(bal) + " " + symbol;
|
||||
: "Current balance: " +
|
||||
truncateAmountNeverZero(bal) +
|
||||
" " +
|
||||
symbol;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// balance minus the fee reserve the confirmation screen checks against, never
|
||||
// the balance the screen shows, which is cut to four decimals. Where there is
|
||||
// nothing to fill in, a flash message says why.
|
||||
// the rounded balance the screen shows. Where there is nothing to fill in, a
|
||||
// flash message says why.
|
||||
async function fillMaxAmount() {
|
||||
const addr = currentAddress();
|
||||
if (!addr) return;
|
||||
|
||||
@@ -10,7 +10,8 @@ const {
|
||||
CONTRACT_CREATION_TEXT,
|
||||
addressDotHtml,
|
||||
escapeHtml,
|
||||
transactionTimeHtml,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
renderAddressHtml,
|
||||
blockieHtml,
|
||||
attachCopyHandlers,
|
||||
@@ -26,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("../../shared/calldataDecoder");
|
||||
const { decodeCalldata } = require("./approval");
|
||||
|
||||
/**
|
||||
* Determine a human-readable transaction type string from tx fields.
|
||||
@@ -203,9 +204,9 @@ function render() {
|
||||
);
|
||||
loadFullTxDetails(tx.hash, tx.chainId);
|
||||
|
||||
$("tx-detail-time").innerHTML = transactionTimeHtml(tx.timestamp, {
|
||||
copyable: true,
|
||||
});
|
||||
const isoStr = isoDate(tx.timestamp);
|
||||
$("tx-detail-time").innerHTML =
|
||||
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
|
||||
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
|
||||
showView("transaction");
|
||||
attachCopyHandlers("view-transaction");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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/popup/views/approval.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.
|
||||
//
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
// 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 };
|
||||
+1
-28
@@ -22,7 +22,7 @@
|
||||
// same thing in the editor is fast feedback in front of the build, not the
|
||||
// guarantee.
|
||||
|
||||
const { networkById, networkByChainId } = require("./networks");
|
||||
const { networkById } = require("./networks");
|
||||
const { copyChainSwitchFields } = require("./chainSwitchFields");
|
||||
const {
|
||||
DEFAULT_STATE,
|
||||
@@ -125,32 +125,6 @@ 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
|
||||
@@ -846,6 +820,5 @@ module.exports = {
|
||||
loadState,
|
||||
currentAddress,
|
||||
currentNetwork,
|
||||
tokenSourcesByChainId,
|
||||
StateNotLoadedError,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Wallet operations: mnemonic generation, HD derivation, signing.
|
||||
// All crypto delegated to ethers.js.
|
||||
|
||||
const { Mnemonic, HDNodeWallet, Wallet, randomBytes } = require("ethers");
|
||||
const { Mnemonic, HDNodeWallet, Wallet } = require("ethers");
|
||||
const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
|
||||
|
||||
function generateMnemonic() {
|
||||
@@ -10,7 +10,10 @@ function generateMnemonic() {
|
||||
// settings toggle drives, which would let a user of a release build turn
|
||||
// the hardcoded, publicly known test phrase back on for real wallets.
|
||||
if (DEBUG) return DEBUG_MNEMONIC;
|
||||
return Mnemonic.fromEntropy(randomBytes(16)).phrase;
|
||||
const m = Mnemonic.fromEntropy(
|
||||
globalThis.crypto.getRandomValues(new Uint8Array(16)),
|
||||
);
|
||||
return m.phrase;
|
||||
}
|
||||
|
||||
// Every extended key (xprv or xpub) entering the app goes through this.
|
||||
|
||||
@@ -23,7 +23,7 @@ const {
|
||||
resolveTokenDecimals,
|
||||
unknownDecimalsAmount,
|
||||
} = require("../src/shared/approvalAmount");
|
||||
const { decodeCalldata } = require("../src/shared/calldataDecoder");
|
||||
const { decodeCalldata } = require("../src/popup/views/approval");
|
||||
|
||||
const iface = new Interface(ERC20_ABI);
|
||||
|
||||
|
||||
@@ -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/shared/calldataDecoder.js`, and the swap's `Amount` and `Min. received`
|
||||
// `src/popup/views/approval.js`, and the swap's `Amount` and `Min. received`
|
||||
// lines decoded by `src/shared/uniswap.js`.
|
||||
|
||||
globalThis.chrome = {
|
||||
@@ -23,8 +23,12 @@ globalThis.chrome = {
|
||||
const { AbiCoder, Interface } = require("ethers");
|
||||
const { ERC20_ABI } = require("../src/shared/constants");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { decodeCalldata } = require("../src/shared/calldataDecoder");
|
||||
const { decodeCalldata } = require("../src/popup/views/approval");
|
||||
const uniswap = require("../src/shared/uniswap");
|
||||
const {
|
||||
truncateAmount,
|
||||
truncateAmountNeverZero,
|
||||
} = require("../src/shared/amountDisplay");
|
||||
|
||||
const iface = new Interface(ERC20_ABI);
|
||||
|
||||
@@ -179,4 +183,15 @@ 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");
|
||||
});
|
||||
|
||||
// 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -41,13 +41,13 @@ describe("balanceLine", () => {
|
||||
// this payload before its id attribute, so an assertion about the
|
||||
// rest of the payload would pass on the cap and say nothing about
|
||||
// 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).toContain("<iframe");
|
||||
});
|
||||
|
||||
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).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
|
||||
// way out of the attribute and into a new one.
|
||||
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).toContain('data-token="" onclick="alert(1)"');
|
||||
});
|
||||
|
||||
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>1.5000</span>");
|
||||
expect(html).toContain('data-token="0xabc"');
|
||||
@@ -69,7 +69,7 @@ describe("balanceLine", () => {
|
||||
|
||||
// formatUsd() writes a value under a cent as "< $0.01".
|
||||
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("< $0.01");
|
||||
expect(html).not.toContain("< $0.01");
|
||||
});
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@
|
||||
// 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
|
||||
// obvious routes — and what counts as holding something, which is any
|
||||
// ERC-20 as well as ETH, at any size, including a balance below the four
|
||||
// decimals the balance lines render. The DOM behaviour
|
||||
// ERC-20 as well as ETH, at any size, including a balance that rounds to
|
||||
// zero at the four decimals the balance lines render. The DOM behaviour
|
||||
// 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.
|
||||
@@ -165,9 +165,9 @@ describe("the balance warning on the removal confirmation", () => {
|
||||
});
|
||||
|
||||
// The sentence must not assert an amount, because any amount it could
|
||||
// assert has been cut to four decimals: "This address holds 0.0000 ETH." is
|
||||
// what the cut form produces for an address that holds real money.
|
||||
test("the warning sentence asserts no cut amount", () => {
|
||||
// assert has been rounded: "This address holds 0.0000 ETH." is what the
|
||||
// rounded form produces for an address that holds real money.
|
||||
test("the warning sentence asserts no rounded amount", () => {
|
||||
for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) {
|
||||
expect(balanceWarningHtml(addr)).not.toMatch(
|
||||
/holds [\d.]+ (ETH|USDC)/,
|
||||
|
||||
+9
-74
@@ -829,11 +829,12 @@ 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 (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";
|
||||
// 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";
|
||||
const NATIVE_DETAIL_TEXT = formatEther(STUB_NATIVE_VALUE_WEI) + " ETH";
|
||||
|
||||
test("the native ETH transaction detail still renders (#151)", async (env) => {
|
||||
@@ -951,71 +952,6 @@ 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)
|
||||
//
|
||||
// Add Token's confirm button, TransactionDetail opened from the token screen
|
||||
@@ -2788,11 +2724,10 @@ async function goToConfirm(page, send) {
|
||||
await assertAddressesFit(page, "the confirmation screen");
|
||||
}
|
||||
|
||||
// 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.
|
||||
// A balance as the main view renders it: balanceLinesForAddress() writes
|
||||
// every quantity with four decimal places.
|
||||
function quantity(wei) {
|
||||
const [whole, frac = ""] = formatEther(wei).split(".");
|
||||
return whole + "." + (frac + "0000").slice(0, 4);
|
||||
return parseFloat(formatEther(wei)).toFixed(4);
|
||||
}
|
||||
|
||||
// Wait on the main view until a changed balance fixture has been picked up.
|
||||
|
||||
@@ -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/shared/calldataDecoder");
|
||||
const { decodeCalldata } = require("../src/popup/views/approval");
|
||||
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
|
||||
|
||||
const HOLDER = "0x" + "a".repeat(40);
|
||||
|
||||
+2
-13
@@ -1,5 +1,4 @@
|
||||
// The release archives: the zip container, the self-containment rule, and the
|
||||
// digest format in release/SHA256SUMS.
|
||||
// The release archives: the zip container, and the self-containment rule.
|
||||
//
|
||||
// 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:
|
||||
@@ -12,14 +11,12 @@
|
||||
// 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
|
||||
// cases for that, plus the archive format itself — new code, and the thing the
|
||||
// artifact is made of. One more case checks the digest format in
|
||||
// release/SHA256SUMS.
|
||||
// artifact is made of.
|
||||
|
||||
const {
|
||||
checkSelfContained,
|
||||
htmlReferences,
|
||||
manifestReferences,
|
||||
sha256,
|
||||
} = require("../script/lib/package");
|
||||
const { readZip, writeZip } = require("../script/lib/zip");
|
||||
|
||||
@@ -145,14 +142,6 @@ 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", () => {
|
||||
const files = [
|
||||
{ name: "manifest.json", data: Buffer.from('{"a":1}') },
|
||||
|
||||
@@ -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/shared/calldataDecoder");
|
||||
const { decodeCalldata } = require("../src/popup/views/approval");
|
||||
|
||||
const iface = new Interface(ERC20_ABI);
|
||||
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
// 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
|
||||
// showed the same fee through src/shared/amountDisplay.js with the nonzero
|
||||
// floor. The balances are now stored exactly, and every nonzero token holding
|
||||
// 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.
|
||||
// floor. The balances are now stored exactly, every nonzero token holding
|
||||
// kept, and the screens show them and the fee through that helper.
|
||||
//
|
||||
// Driven through the real refreshBalances(), Send screen, confirmation screen
|
||||
// and approval screen, with only the node, the explorer and the DOM stubbed: a
|
||||
@@ -280,22 +278,21 @@ beforeEach(() => {
|
||||
mockNode.feeData = { maxFeePerGas: 1n, gasPrice: 1n };
|
||||
});
|
||||
|
||||
describe("an ETH balance below 0.0001 reads 0.0000", () => {
|
||||
describe("an ETH balance below 0.000001 never renders as zero", () => {
|
||||
test("on the Send screen", async () => {
|
||||
await refreshWith(HALF_MICRO_ETH);
|
||||
state.selectedToken = "ETH";
|
||||
send.updateSendBalance();
|
||||
expect(text("send-balance")).toBe("Current balance: 0.0000 ETH");
|
||||
expect(state.wallets[0].addresses[0].balance).toBe("0.0000005");
|
||||
expect(text("send-balance")).toBe("Current balance: 0.0000005 ETH");
|
||||
});
|
||||
|
||||
test("on the confirmation screen", async () => {
|
||||
await refreshWith(HALF_MICRO_ETH);
|
||||
await confirmSend("0.0000001");
|
||||
expect(text("confirm-balance")).toBe("0.0000 ETH");
|
||||
expect(text("confirm-balance")).toBe("0.0000005 ETH");
|
||||
});
|
||||
|
||||
test("while a larger balance is cut to four decimals", async () => {
|
||||
test("while a balance above the floor keeps four decimals", async () => {
|
||||
await refreshWith(1234567890000000000n);
|
||||
await confirmSend("0.1");
|
||||
expect(text("confirm-balance")).toBe("1.2345 ETH");
|
||||
@@ -305,7 +302,7 @@ describe("an ETH balance below 0.0001 reads 0.0000", () => {
|
||||
// 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
|
||||
// the send was checked against zero.
|
||||
describe("a tracked token holding below 0.000001 reads 0.0000", () => {
|
||||
describe("a tracked token holding below 0.000001 never renders as zero", () => {
|
||||
beforeEach(() => {
|
||||
state.trackedTokens = [
|
||||
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
|
||||
@@ -316,17 +313,13 @@ describe("a tracked token holding below 0.000001 reads 0.0000", () => {
|
||||
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
|
||||
state.selectedToken = TOKEN;
|
||||
send.updateSendBalance();
|
||||
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");
|
||||
expect(text("send-balance")).toBe("Current balance: 0.0000005 TOK");
|
||||
});
|
||||
|
||||
test("on the confirmation screen, which checks the send against it", async () => {
|
||||
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
|
||||
await confirmSend("0.0000005", TOKEN);
|
||||
expect(text("confirm-balance")).toBe("0.0000 TOK");
|
||||
expect(text("confirm-balance")).toBe("0.0000005 TOK");
|
||||
expect(errors()).toBe("");
|
||||
expect(balanceError()).toBe("");
|
||||
await confirmSend("0.0000006", TOKEN);
|
||||
@@ -354,9 +347,11 @@ describe("a tracked token holding below 0.000001 reads 0.0000", () => {
|
||||
await refreshWith(10n ** 18n, [tokenRow(1n, { decimals: "24" })]);
|
||||
state.selectedToken = TOKEN;
|
||||
send.updateSendBalance();
|
||||
expect(text("send-balance")).toBe("Current balance: 0.0000 TOK");
|
||||
expect(text("send-balance")).toBe(
|
||||
"Current balance: 0.000000000000000000000001 TOK",
|
||||
);
|
||||
await confirmSend("0.000000000000000001", TOKEN);
|
||||
expect(text("confirm-balance")).toBe("0.0000 TOK");
|
||||
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK");
|
||||
expect(balanceError()).toContain(
|
||||
"Your TOK balance does not cover this amount.",
|
||||
);
|
||||
@@ -368,20 +363,20 @@ describe("a tracked token holding below 0.000001 reads 0.0000", () => {
|
||||
// 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
|
||||
// zero.
|
||||
describe("an untracked token holding below 0.000001 reads 0.0000", () => {
|
||||
describe("an untracked token holding below 0.000001 never renders as zero", () => {
|
||||
const row = () => tokenRow(HALF_MICRO_ETH, { holders_count: "50000" });
|
||||
|
||||
test("on the Send screen", async () => {
|
||||
await refreshWith(10n ** 18n, [row()]);
|
||||
state.selectedToken = TOKEN;
|
||||
send.updateSendBalance();
|
||||
expect(text("send-balance")).toBe("Current balance: 0.0000 TOK");
|
||||
expect(text("send-balance")).toBe("Current balance: 0.0000005 TOK");
|
||||
});
|
||||
|
||||
test("on the confirmation screen, which checks the send against it", async () => {
|
||||
await refreshWith(10n ** 18n, [row()]);
|
||||
await confirmSend("0.0000005", TOKEN);
|
||||
expect(text("confirm-balance")).toBe("0.0000 TOK");
|
||||
expect(text("confirm-balance")).toBe("0.0000005 TOK");
|
||||
expect(errors()).toBe("");
|
||||
expect(balanceError()).toBe("");
|
||||
await confirmSend("0.0000006", TOKEN);
|
||||
|
||||
+62
-101
@@ -1,14 +1,15 @@
|
||||
// A transaction's time shows the ISO datetime and the relative age on every
|
||||
// screen that shows one (README, Amounts). Home, AddressDetail and AddressToken
|
||||
// used to show only the age, with the datetime as a tooltip
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/760).
|
||||
// A transaction's time is written by isoDate() and timeAgo() in
|
||||
// src/popup/views/helpers.js on every screen that shows one (README, Amounts;
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail and
|
||||
// AddressToken used to define their own copies, so a fix to the shared pair
|
||||
// would not have reached them.
|
||||
//
|
||||
// The views run against a minimal DOM stub, as in
|
||||
// tests/historyAfterNetworkSwitch.test.js, and over
|
||||
// tests/support/storageStub.js.
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
|
||||
// The pair is replaced before the views are loaded, because a view takes it
|
||||
// when it loads. A view that writes the time with a copy of its own shows the
|
||||
// real time instead of the replacement.
|
||||
//
|
||||
// Driven against a minimal DOM stub in the shape
|
||||
// tests/contractCreation.test.js uses.
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
@@ -25,78 +26,33 @@ jest.mock("../src/shared/log", () => ({
|
||||
}));
|
||||
|
||||
// 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.
|
||||
let mockHistory = [];
|
||||
jest.mock("../src/shared/transactions", () => ({
|
||||
...jest.requireActual("../src/shared/transactions"),
|
||||
fetchRecentTransactions: async () => mockHistory(),
|
||||
fetchRecentTransactions: async () => mockHistory,
|
||||
}));
|
||||
jest.mock("../src/shared/ens", () => ({
|
||||
...jest.requireActual("../src/shared/ens"),
|
||||
resolveEnsNames: async () => new Map(),
|
||||
}));
|
||||
|
||||
const { state, loadState } = require("../src/shared/state");
|
||||
const home = require("../src/popup/views/home");
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
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 addressToken = require("../src/popup/views/addressToken");
|
||||
const transactionDetail = require("../src/popup/views/transactionDetail");
|
||||
|
||||
const FROM = "0x0000000000000000000000000000000000000a11";
|
||||
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) {
|
||||
const el = {
|
||||
@@ -134,7 +90,6 @@ function makeDocument() {
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => makeElement("created"),
|
||||
addEventListener: () => {},
|
||||
body: { prepend: () => {} },
|
||||
};
|
||||
}
|
||||
@@ -143,58 +98,64 @@ function node(id) {
|
||||
return globalThis.document.getElementById(id);
|
||||
}
|
||||
|
||||
// Lets every promise that is not held open settle.
|
||||
function settle() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// A transaction FROM sent, as the history lists hold it.
|
||||
function historyTx() {
|
||||
return {
|
||||
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(async () => {
|
||||
const storage = makeStorageStub({ autistmask: profile() });
|
||||
globalThis.chrome = {
|
||||
storage: { local: storage.local, onChanged: storage.onChanged },
|
||||
};
|
||||
beforeEach(() => {
|
||||
globalThis.document = makeDocument();
|
||||
globalThis.window = { location: { search: "" } };
|
||||
jest.spyOn(Date, "now").mockReturnValue((TIMESTAMP + 3 * 3600) * 1000);
|
||||
await loadState();
|
||||
state.wallets = [
|
||||
{
|
||||
name: "Main",
|
||||
type: "key",
|
||||
addresses: [{ address: FROM, balance: "0.0000" }],
|
||||
},
|
||||
];
|
||||
state.trackedTokens = [];
|
||||
state.viewData = {};
|
||||
state.viewStack = [];
|
||||
state.currentView = null;
|
||||
state.selectedWallet = 0;
|
||||
state.selectedAddress = 0;
|
||||
state.selectedToken = "ETH";
|
||||
const ctx = { pageClosed: new AbortController().signal };
|
||||
addressDetail.init(ctx);
|
||||
addressToken.init(ctx);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
delete globalThis.chrome;
|
||||
delete globalThis.document;
|
||||
delete globalThis.window;
|
||||
// The history is Blockscout's.
|
||||
state.useBlockscout = true;
|
||||
});
|
||||
|
||||
describe.each([
|
||||
[
|
||||
"Home",
|
||||
"home-tx-list",
|
||||
() => home.render({ pageClosed: new AbortController().signal }),
|
||||
],
|
||||
["AddressDetail", "tx-list", () => addressDetail.show()],
|
||||
["AddressToken", "address-token-tx-list", () => addressToken.show()],
|
||||
])("a transaction history row on %s", (_name, listId, open) => {
|
||||
test("shows the ISO datetime and then the relative age", async () => {
|
||||
test("shows the time written by the shared isoDate() and timeAgo()", async () => {
|
||||
mockHistory = [historyTx()];
|
||||
open();
|
||||
// The list is drawn once the history has been fetched.
|
||||
for (let i = 0; i < 10; i++) await settle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const html = node(listId).innerHTML;
|
||||
expect(html).toContain(">" + SHOWN_TIME + "<");
|
||||
expect(html).not.toContain("title=");
|
||||
expect(html).toContain('title="SHARED-ISO-DATE"');
|
||||
expect(html).toContain(">SHARED-TIME-AGO<");
|
||||
});
|
||||
});
|
||||
|
||||
test("the transaction detail view shows the ISO datetime, copyable, and then the relative age", () => {
|
||||
transactionDetail.show(mockHistory()[0]);
|
||||
test("the transaction detail view shows the time written by the shared isoDate() and timeAgo()", () => {
|
||||
transactionDetail.show(historyTx());
|
||||
const html = node("tx-detail-time").innerHTML;
|
||||
expect(html).toContain('data-copy="2026-09-21T14:13:20Z"');
|
||||
expect(html).toContain(">2026-09-21T14:13:20Z</span> (3 hours ago)");
|
||||
expect(html).toContain("SHARED-ISO-DATE");
|
||||
expect(html).toContain("(SHARED-TIME-AGO)");
|
||||
});
|
||||
@@ -1361,31 +1361,6 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
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 () => {
|
||||
const hash = "0x" + "3".repeat(64);
|
||||
const leg = (value) => ({
|
||||
|
||||
@@ -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 } = require("../src/shared/calldataDecoder");
|
||||
const { decodeCalldata } = approval;
|
||||
const transactionDetail = require("../src/popup/views/transactionDetail");
|
||||
|
||||
const MAINNET = "0x1";
|
||||
|
||||
@@ -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/shared/calldataDecoder");
|
||||
const { decodeCalldata } = require("../src/popup/views/approval");
|
||||
|
||||
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
|
||||
@@ -96,7 +96,7 @@ afterEach(() => {
|
||||
describe("balanceLine", () => {
|
||||
test("an unknown quantity and a zero one render differently", () => {
|
||||
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(quantities(unknown)).toEqual(["NOVEL", "quantity unknown"]);
|
||||
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", () => {
|
||||
prices[NOVEL] = 3;
|
||||
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
|
||||
// claim of "nothing here" the quantity cell just refused to make.
|
||||
expect(unknown).toContain(
|
||||
|
||||
Reference in new issue
Block a user