Compare commits
2
Commits
f47d17a98f
...
8a39df08b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a39df08b0 | ||
|
|
2b97aae04a |
@@ -846,6 +846,14 @@ wherever shown. If a formatting rule applies in one place, it applies in every
|
||||
place. Users should never see the same value rendered differently on two
|
||||
screens.
|
||||
|
||||
The native token's label is the active network's `nativeCurrency` in
|
||||
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. Wherever
|
||||
this document shows ETH as the label of a native balance, value or fee, in a
|
||||
"Native ETH transfer" type line, in the contract-recipient warning or in the
|
||||
refusal of a fee above 1 ETH, Sepolia shows `SepoliaETH`. The swap lines keep
|
||||
`ETH`, the router's own name for the native currency, and the ETH/USD price
|
||||
line, shown on mainnet only, keeps its fixed wording.
|
||||
|
||||
**Specific Exception — Truncation:** On some non-critical display locations, we
|
||||
may truncate _a small number_ of characters from the middle of an address solely
|
||||
due to display size constraints. Wherever possible, and, notably, **in all
|
||||
@@ -1106,7 +1114,8 @@ balance is nonzero and it is in the bundled known-token list, is tracked by the
|
||||
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
|
||||
list from any other contract address is always dropped, and so is any token
|
||||
claiming a symbol that belongs to the native asset and therefore has no
|
||||
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
|
||||
legitimate contract at all (`"ETH"`, and every network's `nativeCurrency`, such
|
||||
as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
|
||||
tokens with fewer than 1,000 holders" setting governs the transaction history
|
||||
and the send-screen token selector, not this list. `fetchTokenBalances()` stores
|
||||
every nonzero holding of a token it admits, however small, but a holding below
|
||||
@@ -1134,16 +1143,18 @@ because bumping for one would send every older install to StateRecovery for
|
||||
nothing.
|
||||
|
||||
Every read of the record goes through `assertStateUsable()` first, on the raw
|
||||
bytes, before normalization: `loadState()` for the popup and `getState()` for
|
||||
the background. It refuses a record that is not an object, a `schemaVersion`
|
||||
this build does not understand (a newer one included), a `wallets` that is not a
|
||||
list of wallet records with address records in them, and a `networkId` that is
|
||||
not a network in `src/shared/networks.js`. Refusing is the whole point — a
|
||||
record the wallet cannot vouch for is never normalized, never written back, and
|
||||
never half-loaded. The popup shows StateRecovery; a dApp gets a specific error
|
||||
(`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the
|
||||
saved data cannot be read and that nothing was signed or sent, rather than the
|
||||
generic `-32603` every request used to answer.
|
||||
bytes, before normalization: `loadState()` and every `saveState()` for the
|
||||
popup, and `getState()` for the background. It refuses a record that is not an
|
||||
object, a `schemaVersion` this build does not understand (a newer one included),
|
||||
a `wallets` that is not a list of wallet records with address records in them,
|
||||
and a `networkId` that is not a network in `src/shared/networks.js`. Refusing is
|
||||
the whole point — a record the wallet cannot vouch for is never normalized,
|
||||
never written back, and never half-loaded. The popup shows StateRecovery,
|
||||
whether it finds the record unreadable when it opens or at a save while it is
|
||||
open; a dApp gets a specific error (`-32007`, an EIP-1474 server-error code the
|
||||
spec leaves unassigned) saying the saved data cannot be read and that nothing
|
||||
was signed or sent, rather than the generic `-32603` every request used to
|
||||
answer.
|
||||
|
||||
Every other field of the record is floored in `normalizePersisted()` rather than
|
||||
gated, and the floor is not the same for every field. Some are type-checked as a
|
||||
@@ -1187,8 +1198,9 @@ now also reported rather than swallowed: `onSaveFailure()` in
|
||||
`src/shared/state.js` is called for every failed save, awaited or not, and the
|
||||
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
|
||||
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
|
||||
covers — a quota, a revoked permission, a record a newer build wrote — and the
|
||||
wallet must never look healthy while that is true.
|
||||
covers — a quota, a revoked permission — and the wallet must never look healthy
|
||||
while that is true. A save that fails because the stored record fails the gate,
|
||||
such as one a newer build wrote, gets StateRecovery instead of the banner.
|
||||
|
||||
The `networkId` check is not cosmetic: that value is an object KEY into
|
||||
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
|
||||
@@ -1586,7 +1598,9 @@ view would leave a wallet one click from deletion.
|
||||
- "Transaction" heading, "Back" button
|
||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||
- Type: transaction classification — one of: Native ETH Transfer, ERC-20
|
||||
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
|
||||
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A
|
||||
transfer with a token contract is an ERC-20 Token Transfer whatever symbol
|
||||
the token reports.
|
||||
- Status: "Success" or "Failed"
|
||||
- From: blockie + color dot + full address (tap to copy) + etherscan link;
|
||||
ENS name if available
|
||||
@@ -1998,9 +2012,19 @@ view would leave a wallet one click from deletion.
|
||||
|
||||
#### StateRecovery (`state-recovery`)
|
||||
|
||||
- **When**: `loadState()` refused the stored profile, so the popup has no
|
||||
profile at all. It is the only screen reached without one, and the only one
|
||||
that never appears during ordinary use.
|
||||
- **When**: the stored profile fails `assertStateUsable()`. At open, that is
|
||||
`loadState()` refusing it, so the popup has no profile at all. While the popup
|
||||
is open, on any screen, it is a save refusing it: every `saveState()` reads
|
||||
the stored record and runs the same check before writing, so the popup finds
|
||||
it at the next navigation or ten-second refresh, whether or not the network
|
||||
answers ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). A save that
|
||||
fails for any other reason, such as a storage read or write that errors, gets
|
||||
the "NOT SAVED" banner instead and leaves the screen as it is. The screen it
|
||||
replaces is left as any navigation leaves it, so a revealed phrase or key, or
|
||||
a typed password, is wiped. Once up, the screen stays until the popup closes
|
||||
or reloads: work still running in the popup, such as a transaction wait,
|
||||
cannot replace it, even after the record is erased in another window. It is
|
||||
the only screen that never appears during ordinary use.
|
||||
- **Why it exists**: a record the wallet cannot read used to render nothing — no
|
||||
view, no message, no control — while every dApp call answered a generic
|
||||
internal error, and no reset or wipe control existed anywhere in the product.
|
||||
@@ -2027,16 +2051,20 @@ view would leave a wallet one click from deletion.
|
||||
Nothing was erased." on the error line
|
||||
- **No other control is reachable.** The Settings gear is hidden while this
|
||||
screen is up, because every screen behind it renders from the profile that
|
||||
could not be read, and `showView()` is not used to raise it for the same
|
||||
reason — it reads and writes the state singleton.
|
||||
could not be read. `showView()` is not used to raise it, for the same reason:
|
||||
it reads and writes the state singleton. Under an open popup the screen is
|
||||
passed to `showView()` only to run the replaced screen's cleanup; from then on
|
||||
`showView()` shows nothing else in that popup.
|
||||
- **Both controls are required.** An export with no reset leaves the user
|
||||
looking at a broken profile with no way to use the wallet again; a reset with
|
||||
no export destroys the only copy of a record that may hold recoverable key
|
||||
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
|
||||
and for the same reason: there is no password to gate this with, since there
|
||||
is no profile to check one against.
|
||||
- Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because
|
||||
nothing on this path writes state at all.
|
||||
- Not in `RESTORABLE_VIEWS`, and never recorded as the current view: the record
|
||||
can become readable again under an open popup, erased in another window, and
|
||||
the next save from that popup then succeeds. A popup opened after that opens
|
||||
normally.
|
||||
|
||||
### External Services
|
||||
|
||||
@@ -2345,7 +2373,8 @@ indexes it as a real token transfer.
|
||||
act on and what the user believes they own rather than what the history
|
||||
displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`,
|
||||
so they cannot answer the question differently. A symbol the list maps to no
|
||||
contract at all — `"ETH"`, the native asset, is the only one — may be borne by
|
||||
contract at all — the native asset's labels: `"ETH"` and every network's
|
||||
`nativeCurrency`, such as `"SepoliaETH"`, on every network — may be borne by
|
||||
no contract, so every ERC-20 claiming it is a spoof on all three. The user's
|
||||
real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees
|
||||
it.
|
||||
|
||||
@@ -45,6 +45,31 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-04: The native token's label follows the active network
|
||||
([#372](https://git.eeqj.de/sneak/AutistMask/issues/372)). `networks.js` gives
|
||||
each network a `nativeCurrency` and nothing read it: every screen wrote `ETH`,
|
||||
so on Sepolia the balance, the value and the fee all read `ETH`. The balance
|
||||
lists, Send, confirmation, approval, wait, success and error screens, the
|
||||
transaction history, the contract-recipient warning and the refusal of a fee
|
||||
above the limit now read `nativeCurrency`, which is `ETH` on mainnet and
|
||||
`SepoliaETH` on Sepolia. A token claiming any network's `nativeCurrency` is
|
||||
dropped as a fake, as one claiming `ETH` already was, and the transaction
|
||||
detail screen calls an entry a token transfer when it has a token contract,
|
||||
not by its symbol.
|
||||
- 2026-10-04: A popup that is already open when the stored profile becomes
|
||||
unreadable moves to the recovery screen
|
||||
([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). It used to stay on
|
||||
the last good profile, with the "NOT SAVED" banner at most, until reopened.
|
||||
Every save already ran the check the popup runs at open, so the popup finds
|
||||
the record at the next navigation or ten-second refresh, whether or not the
|
||||
network answers; a save that fails that check now raises the recovery screen
|
||||
and stops the refresh. The screen it replaces is left as any navigation leaves
|
||||
it, so a revealed phrase or key or a typed password is wiped. Once up, nothing
|
||||
else in that popup can replace it, and a later save or a transaction wait that
|
||||
ends does not clear an export or a typed confirmation. It is never saved as
|
||||
the current view, so a popup opened after the record is erased in another
|
||||
window opens normally. Any other failed save still gets the banner and leaves
|
||||
the screen alone.
|
||||
- 2026-10-04: A swap whose deadline is later than a JavaScript date can hold is
|
||||
decoded ([#437](https://git.eeqj.de/sneak/AutistMask/issues/437)). A date
|
||||
reaches only to 275760-09-13, so a later deadline, such as the `uint256`
|
||||
|
||||
+7
-14
@@ -640,19 +640,13 @@
|
||||
Double-check the address before sending.
|
||||
</div>
|
||||
</div>
|
||||
<!-- Its sentence names the network's native token, so show()
|
||||
in confirmTx.js sets it. -->
|
||||
<div
|
||||
id="confirm-contract-warning"
|
||||
class="mb-2"
|
||||
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
<div
|
||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||
>
|
||||
WARNING: The recipient is a smart contract. Sending ETH
|
||||
or tokens directly to a contract may result in permanent
|
||||
loss of funds.
|
||||
</div>
|
||||
</div>
|
||||
></div>
|
||||
<div
|
||||
id="confirm-burn-warning"
|
||||
class="mb-2"
|
||||
@@ -690,14 +684,13 @@
|
||||
Your balance does not cover this amount plus the network
|
||||
fee. Please go back and send a smaller amount.
|
||||
</div>
|
||||
<!-- Its sentence names the network's native token, so show()
|
||||
in confirmTx.js sets it. -->
|
||||
<div
|
||||
id="confirm-gas-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
You do not have enough ETH to pay the network fee for this
|
||||
transfer. Please add ETH to this address and try again.
|
||||
</div>
|
||||
></div>
|
||||
<!-- Its sentence names why the fee could not be estimated,
|
||||
so show() in confirmTx.js sets it. -->
|
||||
<div
|
||||
|
||||
+27
-2
@@ -50,6 +50,10 @@ function renderWalletList() {
|
||||
|
||||
let refreshInFlight = false;
|
||||
|
||||
// The ten-second refresh init() starts, stopped when the popup moves to the
|
||||
// recovery screen: there is no profile left to refresh.
|
||||
let refreshTimer = null;
|
||||
|
||||
async function doRefreshAndRender() {
|
||||
if (refreshInFlight) return;
|
||||
refreshInFlight = true;
|
||||
@@ -155,7 +159,28 @@ async function init() {
|
||||
// reported rather than being swallowed by the save queue
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
|
||||
// the approval-window branch below too, since that window saves as well.
|
||||
onSaveFailure(showSaveFailureBanner);
|
||||
//
|
||||
// Every save first reads the stored record and refuses it with the same
|
||||
// check loadState() runs below. So a record that becomes unreadable while
|
||||
// the popup is open is found by the next save, a navigation or the
|
||||
// ten-second refresh, and gets the screen it would get at open
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/373). Passing the recovery
|
||||
// screen to showView() first leaves the current screen as any navigation
|
||||
// does, so a phrase, key or password on it is wiped, and from then on
|
||||
// showView() shows nothing else. A later save that fails the same way,
|
||||
// such as a refresh already in flight, comes back here, where both calls
|
||||
// see the screen already up and do nothing. Any other failed save is a
|
||||
// read or write that failed, and gets the banner without changing the
|
||||
// screen.
|
||||
onSaveFailure((e) => {
|
||||
if (e instanceof StateUnusableError) {
|
||||
clearInterval(refreshTimer);
|
||||
showView("state-recovery");
|
||||
stateRecovery.show(e);
|
||||
} else {
|
||||
showSaveFailureBanner(e);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await loadState();
|
||||
} catch (e) {
|
||||
@@ -244,7 +269,7 @@ async function init() {
|
||||
renderWalletList();
|
||||
restoreView();
|
||||
doRefreshAndRender();
|
||||
setInterval(doRefreshAndRender, 10000);
|
||||
refreshTimer = setInterval(doRefreshAndRender, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
nativeCurrency,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
@@ -153,6 +154,7 @@ async function loadTransactions(address) {
|
||||
const rawTxs = await fetchRecentTransactions(
|
||||
address,
|
||||
state.blockscoutUrl,
|
||||
nativeCurrency(),
|
||||
);
|
||||
const result = filterTransactions(rawTxs, {
|
||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
nativeCurrency,
|
||||
balanceLine,
|
||||
unknownableAmount,
|
||||
renderAddressHtml,
|
||||
@@ -106,7 +107,7 @@ function show() {
|
||||
let symbol, amount, price;
|
||||
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
|
||||
if (tokenId === "ETH") {
|
||||
symbol = "ETH";
|
||||
symbol = nativeCurrency();
|
||||
amount = parseFloat(addr.balance || "0");
|
||||
price = getPrice("ETH");
|
||||
} else {
|
||||
@@ -226,6 +227,7 @@ async function loadTransactions(address, tokenId) {
|
||||
const rawTxs = await fetchRecentTransactions(
|
||||
address,
|
||||
state.blockscoutUrl,
|
||||
nativeCurrency(),
|
||||
);
|
||||
const result = filterTransactions(rawTxs, {
|
||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
attachCopyHandlers,
|
||||
onViewLeave,
|
||||
formatFee,
|
||||
nativeCurrency,
|
||||
} = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { networkByChainId } = require("../../shared/networks");
|
||||
@@ -330,7 +331,10 @@ function showTxApproval(details) {
|
||||
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
|
||||
const usdStr = formatUsd(ethUsd);
|
||||
$("approve-tx-value").textContent =
|
||||
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
|
||||
ethValueFormatted +
|
||||
" " +
|
||||
nativeCurrency() +
|
||||
(usdStr ? " (" + usdStr + ")" : "");
|
||||
|
||||
showTxFee(approvedTx);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
nativeCurrency,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
@@ -90,7 +91,7 @@ function show(txInfo) {
|
||||
// The raw symbol is the price-table key; the capped one is what the
|
||||
// screen says. Truncating before the lookup would silently drop the
|
||||
// price of any token whose symbol is long enough to be capped.
|
||||
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
||||
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
|
||||
const symbol = displaySymbol(rawSymbol);
|
||||
|
||||
// Transaction type
|
||||
@@ -98,7 +99,7 @@ function show(txInfo) {
|
||||
$("confirm-type").textContent =
|
||||
"ERC-20 token transfer (" + symbol + ")";
|
||||
} else {
|
||||
$("confirm-type").textContent = "Native ETH transfer";
|
||||
$("confirm-type").textContent = "Native " + symbol + " transfer";
|
||||
}
|
||||
|
||||
// Token contract section (ERC-20 only)
|
||||
@@ -162,7 +163,7 @@ function show(txInfo) {
|
||||
const bal = txInfo.balance || "0";
|
||||
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
||||
$("confirm-balance").textContent = valueWithUsd(
|
||||
truncateAmountNeverZero(bal) + " ETH",
|
||||
truncateAmountNeverZero(bal) + " " + symbol,
|
||||
balUsd,
|
||||
);
|
||||
}
|
||||
@@ -197,6 +198,19 @@ function show(txInfo) {
|
||||
// estimate landing later never moves anything.
|
||||
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
||||
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
||||
$("confirm-gas-error").textContent =
|
||||
"You do not have enough " +
|
||||
nativeCurrency() +
|
||||
" to pay the network fee for this transfer. Please add " +
|
||||
nativeCurrency() +
|
||||
" to this address and try again.";
|
||||
|
||||
// Shown later, once checkRecipientHistory() finds a contract.
|
||||
$("confirm-contract-warning").textContent =
|
||||
"WARNING: The recipient is a smart contract. Sending " +
|
||||
nativeCurrency() +
|
||||
" or tokens directly to a contract may result in permanent loss of" +
|
||||
" funds.";
|
||||
|
||||
// The fee-unknown message names its cause, which is also known here.
|
||||
// Without the token's scale estimateGas() cannot encode the transfer, so
|
||||
@@ -245,7 +259,9 @@ function show(txInfo) {
|
||||
// touches already occupies its space, so re-running it never moves anything.
|
||||
function renderValidation(txInfo) {
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH";
|
||||
const symbol = isErc20
|
||||
? displaySymbol(txInfo.tokenSymbol || "?")
|
||||
: nativeCurrency();
|
||||
|
||||
const { canSend, codes } = validateTransfer({
|
||||
isErc20,
|
||||
@@ -258,7 +274,7 @@ function renderValidation(txInfo) {
|
||||
|
||||
// Messages carrying the user's own numbers are built here; the fixed
|
||||
// sentences live in the reserved elements in index.html, except the
|
||||
// fee-unknown one, which show() sets.
|
||||
// gas and fee-unknown ones, which show() sets.
|
||||
const messages = [];
|
||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||
messages.push("Please enter a valid amount to send.");
|
||||
@@ -287,9 +303,13 @@ function renderValidation(txInfo) {
|
||||
messages.push(
|
||||
"Insufficient balance. You have " +
|
||||
truncateAmountNeverZero(txInfo.balance || "0") +
|
||||
" ETH but are trying to send " +
|
||||
" " +
|
||||
symbol +
|
||||
" but are trying to send " +
|
||||
txInfo.amount +
|
||||
" ETH.",
|
||||
" " +
|
||||
symbol +
|
||||
".",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -382,7 +402,9 @@ async function estimateGas(txInfo) {
|
||||
$("confirm-fee-reserve").textContent =
|
||||
"up to " +
|
||||
truncateAmountNeverZero(formatEther(gasCostWei)) +
|
||||
" ETH reserved";
|
||||
" " +
|
||||
nativeCurrency() +
|
||||
" reserved";
|
||||
setVisible("confirm-fee-reserve", true);
|
||||
} else {
|
||||
// No spread to report: either there is no estimate, or the node
|
||||
|
||||
@@ -52,9 +52,8 @@ const VIEWS = [
|
||||
"export-privkey",
|
||||
"show-phrase",
|
||||
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
||||
// cannot be read. It is never reached through showView() — by then the
|
||||
// state singleton this file writes on every navigation refuses to be read
|
||||
// — but it is listed so that every view-hiding loop covers it.
|
||||
// cannot be read, never by showView() (see there), but listed so that
|
||||
// every view-hiding loop covers it.
|
||||
"state-recovery",
|
||||
];
|
||||
|
||||
@@ -85,12 +84,28 @@ function hideError(id) {
|
||||
el.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
// Set when src/popup/index.js passes the recovery screen to showView(), and
|
||||
// never cleared. Kept in memory for this popup's life, never in
|
||||
// state.currentView, which is saved: a popup opened later must not inherit it.
|
||||
let stateRecoveryShown = false;
|
||||
|
||||
function showView(name) {
|
||||
// The recovery screen, once up, is never replaced: work still running
|
||||
// when it went up, such as a transaction wait, must not take the user off
|
||||
// it or clear what they exported or typed there
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
|
||||
if (stateRecoveryShown) return;
|
||||
const leaving = state.currentView;
|
||||
if (leaving && leaving !== name) {
|
||||
const onLeave = viewLeaveHandlers.get(leaving);
|
||||
if (onLeave) onLeave();
|
||||
}
|
||||
// Passed here only so the screen it replaces is left like any other;
|
||||
// stateRecovery.show() raises it, and it is never the current view.
|
||||
if (name === "state-recovery") {
|
||||
stateRecoveryShown = true;
|
||||
return;
|
||||
}
|
||||
for (const v of VIEWS) {
|
||||
const el = document.getElementById(`view-${v}`);
|
||||
if (el) {
|
||||
@@ -252,6 +267,14 @@ function unknownableAmount(balance) {
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH`
|
||||
// on Sepolia, as src/shared/networks.js names it. Every screen labels a native
|
||||
// amount with this. The "ETH" that state.selectedToken and txInfo.token hold
|
||||
// is the native token's id, not its label, and stays "ETH" on every network.
|
||||
function nativeCurrency() {
|
||||
return currentNetwork().nativeCurrency;
|
||||
}
|
||||
|
||||
// A network fee in wei as the confirmation and approval screens both show it:
|
||||
// the ETH figure through truncateAmountNeverZero(), then its USD value when the
|
||||
// ETH price is known. The USD value is of the exact fee, not of the truncated
|
||||
@@ -261,7 +284,10 @@ function formatFee(wei) {
|
||||
const ethPrice = getPrice("ETH");
|
||||
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
|
||||
return (
|
||||
truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "")
|
||||
truncateAmountNeverZero(eth) +
|
||||
" " +
|
||||
nativeCurrency() +
|
||||
(usd ? " (" + usd + ")" : "")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -303,7 +329,7 @@ function balanceLine(symbol, amount, price, tokenId) {
|
||||
|
||||
function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
let html = balanceLine(
|
||||
"ETH",
|
||||
nativeCurrency(),
|
||||
parseFloat(addr.balance || "0"),
|
||||
getPrice("ETH"),
|
||||
"ETH",
|
||||
@@ -660,6 +686,7 @@ module.exports = {
|
||||
balanceLinesForAddress,
|
||||
addressHoldsFunds,
|
||||
unknownableAmount,
|
||||
nativeCurrency,
|
||||
formatFee,
|
||||
addressColor,
|
||||
addressDotHtml,
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
nativeCurrency,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
pushCurrentView,
|
||||
@@ -68,7 +69,7 @@ function renderTotalValue() {
|
||||
return;
|
||||
}
|
||||
const ethBal = parseFloat(addr.balance || "0");
|
||||
const ethStr = ethBal.toFixed(4) + " ETH";
|
||||
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
|
||||
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
|
||||
el.textContent = ethStr + ethUsd;
|
||||
|
||||
@@ -182,7 +183,11 @@ async function loadHomeTxs(ctx) {
|
||||
|
||||
try {
|
||||
const fetches = allAddresses.map((addr) =>
|
||||
fetchRecentTransactions(addr, state.blockscoutUrl),
|
||||
fetchRecentTransactions(
|
||||
addr,
|
||||
state.blockscoutUrl,
|
||||
nativeCurrency(),
|
||||
),
|
||||
);
|
||||
const results = await Promise.all(fetches);
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ const {
|
||||
showFlash,
|
||||
addressTitle,
|
||||
displaySymbol,
|
||||
escapeHtml,
|
||||
nativeCurrency,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
@@ -124,7 +126,7 @@ function updateToValidation() {
|
||||
|
||||
function renderSendTokenSelect(addr) {
|
||||
const sel = $("send-token");
|
||||
sel.innerHTML = '<option value="ETH">ETH</option>';
|
||||
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
|
||||
const fraudSet = new Set(
|
||||
(state.fraudContracts || []).map((a) => a.toLowerCase()),
|
||||
);
|
||||
@@ -204,7 +206,8 @@ function updateSendBalance() {
|
||||
$("send-balance").textContent =
|
||||
"Current balance: " +
|
||||
truncateAmountNeverZero(addr.balance || "0") +
|
||||
" ETH";
|
||||
" " +
|
||||
nativeCurrency();
|
||||
} else {
|
||||
const symbol = resolveSymbol(
|
||||
token,
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
// Everything else in the popup assumes a loaded profile: showView() reads and
|
||||
// writes the state singleton, every view renders from it, and the Settings
|
||||
// gear leads to a screen that does both. None of that is available here — by
|
||||
// the time this runs, loadState() has REFUSED, deliberately, and reading the
|
||||
// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||
// the time this runs, the stored record has been REFUSED, deliberately: at
|
||||
// open loadState() refused it and reading the singleton throws
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/311), and under an open popup
|
||||
// a save refused it (https://git.eeqj.de/sneak/AutistMask/issues/373).
|
||||
//
|
||||
// So this module talks to the DOM directly and touches no state at all. It is
|
||||
// the one screen that must work when nothing else can, which is also why it
|
||||
@@ -170,6 +172,11 @@ function wire() {
|
||||
* refused, or its sentence.
|
||||
*/
|
||||
function show(problem) {
|
||||
// Already up: a later save that trips over the same record, such as a
|
||||
// refresh that was in flight when the screen went up, must not clear what
|
||||
// the user has exported or typed here.
|
||||
if (!$("view-state-recovery").classList.contains("hidden")) return;
|
||||
|
||||
const sentence =
|
||||
(problem && (problem.problem || problem.message)) || String(problem);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ const {
|
||||
etherscanLinkHtml,
|
||||
explorerUrl,
|
||||
displaySymbol,
|
||||
nativeCurrency,
|
||||
goBack,
|
||||
} = require("./helpers");
|
||||
const { state } = require("../../shared/state");
|
||||
@@ -41,8 +42,10 @@ function getTransactionType(tx) {
|
||||
return "Token Approval";
|
||||
return "Contract Call";
|
||||
}
|
||||
if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer";
|
||||
return "Native ETH Transfer";
|
||||
// By the token contract, not the symbol: a token chooses its own symbol
|
||||
// and can report the native token's, but only a token transfer has one.
|
||||
if (tx.contractAddress) return "ERC-20 Token Transfer";
|
||||
return "Native " + nativeCurrency() + " Transfer";
|
||||
}
|
||||
|
||||
function blockieHtml(address) {
|
||||
@@ -227,7 +230,7 @@ function populateOnChainDetails(txData) {
|
||||
showDetailField(
|
||||
"tx-detail-fee-section",
|
||||
"tx-detail-fee",
|
||||
feeEth + " ETH",
|
||||
feeEth + " " + nativeCurrency(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
etherscanLinkHtml,
|
||||
explorerUrl,
|
||||
displaySymbol,
|
||||
nativeCurrency,
|
||||
clearViewStack,
|
||||
} = require("./helpers");
|
||||
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
||||
@@ -88,7 +89,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
|
||||
const symbol =
|
||||
txInfo.token === "ETH"
|
||||
? "ETH"
|
||||
? nativeCurrency()
|
||||
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
||||
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
|
||||
@@ -223,7 +224,7 @@ function showSuccess(txInfo, txHash, blockNumber) {
|
||||
|
||||
const symbol =
|
||||
txInfo.token === "ETH"
|
||||
? "ETH"
|
||||
? nativeCurrency()
|
||||
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||
state.viewData = {
|
||||
amount: txInfo.amount,
|
||||
@@ -320,7 +321,7 @@ function showError(txInfo, txHash, message) {
|
||||
|
||||
const symbol =
|
||||
txInfo.token === "ETH"
|
||||
? "ETH"
|
||||
? nativeCurrency()
|
||||
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||
state.viewData = {
|
||||
amount: txInfo.amount,
|
||||
|
||||
@@ -54,9 +54,11 @@ const {
|
||||
formatEther,
|
||||
getAddress,
|
||||
getBytes,
|
||||
toQuantity,
|
||||
verifyMessage,
|
||||
verifyTypedData,
|
||||
} = require("ethers");
|
||||
const { networkByChainId } = require("./networks");
|
||||
|
||||
// The only transaction types this wallet signs: legacy, EIP-2930 and
|
||||
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
|
||||
@@ -407,12 +409,23 @@ function assertWithinCeilings(tx) {
|
||||
price = normalizeQuantity(tx.gasPrice, "gas price");
|
||||
}
|
||||
if (price !== null && gasLimit * price > MAX_TOTAL_FEE) {
|
||||
// The fee is paid in the native currency of the network the
|
||||
// transaction is for. Every caller's transaction names it; one
|
||||
// that does not, or names a network not in networks.js, says ETH.
|
||||
const network = present(tx.chainId)
|
||||
? networkByChainId(toQuantity(tx.chainId))
|
||||
: null;
|
||||
const nativeCurrency = network ? network.nativeCurrency : "ETH";
|
||||
throw refuse(
|
||||
"This transaction would allow a network fee of up to " +
|
||||
formatEther(gasLimit * price) +
|
||||
" ETH, which is more than the " +
|
||||
" " +
|
||||
nativeCurrency +
|
||||
", which is more than the " +
|
||||
formatEther(MAX_TOTAL_FEE) +
|
||||
" ETH this wallet will sign for.",
|
||||
" " +
|
||||
nativeCurrency +
|
||||
" this wallet will sign for.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
|
||||
// that may bear it, or to null. Null means the symbol belongs to the native
|
||||
// asset, which has no contract at all, so no contract may bear it and every
|
||||
// one that does is a spoof. "ETH" is the only such entry today; the rule is
|
||||
// written so that a second one needs no change here or at any call site.
|
||||
// one that does is a spoof. "ETH" is one such entry, and every network's
|
||||
// `nativeCurrency` in networks.js (`SepoliaETH`) is another, on every network.
|
||||
//
|
||||
// The value is a set because a ticker is not unique: seven symbols in the
|
||||
// bundled list belong to two real contracts each, and answering with one of
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// 511 tokens.
|
||||
|
||||
const { debugFetch } = require("./log");
|
||||
const { NETWORKS } = require("./networks");
|
||||
|
||||
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
|
||||
|
||||
@@ -3610,7 +3611,9 @@ for (const t of TOKENS) {
|
||||
// Build a map of symbol (uppercased) -> the set of contract addresses
|
||||
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
|
||||
// "ETH" maps to null: the native asset has no contract, so no contract may
|
||||
// bear its symbol.
|
||||
// bear its symbol. So does every network's `nativeCurrency` in networks.js
|
||||
// (`SepoliaETH`), on every network, since that is the label the wallet shows
|
||||
// its native asset under on that network.
|
||||
//
|
||||
// The value is a set and not a single address because tickers are not unique
|
||||
// and the list above proves it: seven of these 512 tokens share a symbol with
|
||||
@@ -3624,6 +3627,9 @@ for (const t of TOKENS) {
|
||||
// loosen the rule, because a contract outside the set is still a spoof.
|
||||
const KNOWN_SYMBOLS = new Map();
|
||||
KNOWN_SYMBOLS.set("ETH", null);
|
||||
for (const network of Object.values(NETWORKS)) {
|
||||
KNOWN_SYMBOLS.set(network.nativeCurrency.toUpperCase(), null);
|
||||
}
|
||||
for (const t of TOKENS) {
|
||||
const upper = t.symbol.toUpperCase();
|
||||
if (!KNOWN_SYMBOLS.has(upper)) {
|
||||
|
||||
@@ -28,7 +28,7 @@ function normalizeAddress(addr) {
|
||||
return (addr || "").toLowerCase();
|
||||
}
|
||||
|
||||
function parseTx(tx, addrLower) {
|
||||
function parseTx(tx, addrLower, nativeCurrency) {
|
||||
const from = tx.from?.hash || "";
|
||||
const to = tx.to?.hash || "";
|
||||
const rawWei = tx.value || "0";
|
||||
@@ -36,7 +36,7 @@ function parseTx(tx, addrLower) {
|
||||
const method = tx.method || null;
|
||||
|
||||
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
|
||||
let symbol = "ETH";
|
||||
let symbol = nativeCurrency;
|
||||
let value = formatTxValue(formatEther(rawWei));
|
||||
let exactValue = formatEther(rawWei);
|
||||
let rawAmount = rawWei;
|
||||
@@ -221,7 +221,14 @@ function mergeTransactions(txs, tokenTransfers) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||
// `nativeCurrency` is the active network's native token symbol from
|
||||
// networks.js (`ETH`, `SepoliaETH`), which a native entry is labelled with.
|
||||
async function fetchRecentTransactions(
|
||||
address,
|
||||
blockscoutUrl,
|
||||
nativeCurrency,
|
||||
count = 25,
|
||||
) {
|
||||
log.debugf("fetchRecentTransactions", address);
|
||||
const addrLower = normalizeAddress(address);
|
||||
|
||||
@@ -254,7 +261,9 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
||||
|
||||
const txs = mergeTransactions(
|
||||
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
|
||||
(txJson.items || []).map((tx) =>
|
||||
parseTx(tx, addrLower, nativeCurrency),
|
||||
),
|
||||
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
|
||||
);
|
||||
|
||||
|
||||
@@ -599,6 +599,24 @@ describe("verifySignedTx field comparison", () => {
|
||||
expect(e.message).toContain("1.0 ETH");
|
||||
}
|
||||
});
|
||||
|
||||
// The fee is in the native currency of the network the transaction is
|
||||
// for, whether its chain id is the hex string the background prepares
|
||||
// or the number ethers parses from a signed transaction.
|
||||
test.each([
|
||||
["0x1", "ETH"],
|
||||
[1n, "ETH"],
|
||||
["0xaa36a7", "SepoliaETH"],
|
||||
[11155111n, "SepoliaETH"],
|
||||
])("the refusal on chain %p names %s", (chainId, nativeCurrency) => {
|
||||
expect(() => assertWithinCeilings({ ...OVER, chainId })).toThrow(
|
||||
"up to 3000.0 " +
|
||||
nativeCurrency +
|
||||
", which is more than the 1.0 " +
|
||||
nativeCurrency +
|
||||
" this wallet",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("every field mismatch is a refusal, not a warning", async () => {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// The native token's label on the screens that show a native amount.
|
||||
//
|
||||
// src/shared/networks.js gives each network a nativeCurrency, `ETH` on mainnet
|
||||
// and `SepoliaETH` on Sepolia, and nothing read it: every screen wrote a
|
||||
// hardcoded "ETH", so on Sepolia the balance, the value and the fee all read
|
||||
// ETH (https://git.eeqj.de/sneak/AutistMask/issues/372). Each line is asserted
|
||||
// on both networks, through the real Send, confirmation and approval screens,
|
||||
// with only the node and the DOM stubbed. So is that a token cannot pass for
|
||||
// the native token by reporting its label.
|
||||
|
||||
"use strict";
|
||||
|
||||
jest.mock("ethers", () => {
|
||||
const actual = jest.requireActual("ethers");
|
||||
class StubProvider {
|
||||
async lookupAddress() {
|
||||
return null;
|
||||
}
|
||||
// 10 gwei expected, 20 gwei reserved per gas.
|
||||
async getFeeData() {
|
||||
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
|
||||
}
|
||||
async estimateGas() {
|
||||
return 21000n;
|
||||
}
|
||||
async getCode() {
|
||||
return "0x";
|
||||
}
|
||||
async getTransactionCount() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
JsonRpcProvider: StubProvider,
|
||||
Network: { from: () => ({}) },
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
debugf: () => {},
|
||||
infof: () => {},
|
||||
warnf: () => {},
|
||||
errorf: () => {},
|
||||
},
|
||||
debugFetch: jest.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [] }),
|
||||
})),
|
||||
urlOrigin: () => "",
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn(() => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
|
||||
// The approval the background hands the approval screen. Set per test.
|
||||
let approvalDetails = null;
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
global.chrome = {
|
||||
storage: makeStorageStub(),
|
||||
runtime: {
|
||||
connect: () => ({
|
||||
postMessage() {},
|
||||
disconnect() {},
|
||||
onDisconnect: { addListener() {} },
|
||||
}),
|
||||
sendMessage(message, callback) {
|
||||
callback(
|
||||
message.type === "AUTISTMASK_GET_APPROVAL"
|
||||
? approvalDetails
|
||||
: undefined,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// A stub DOM: every id resolves to a recording element.
|
||||
const elements = new Map();
|
||||
|
||||
function makeEl(id) {
|
||||
const handlers = new Map();
|
||||
return {
|
||||
id,
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
value: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: {
|
||||
add() {},
|
||||
remove() {},
|
||||
toggle() {},
|
||||
contains: () => false,
|
||||
},
|
||||
handlers,
|
||||
children: [],
|
||||
addEventListener(name, fn) {
|
||||
handlers.set(name, fn);
|
||||
},
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
remove() {},
|
||||
focus() {},
|
||||
// Views reach for .parentElement to hide whole sections.
|
||||
get parentElement() {
|
||||
return global.document.getElementById(id + "-parent");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
if (!elements.has(id)) elements.set(id, makeEl(id));
|
||||
return elements.get(id);
|
||||
},
|
||||
createElement: (tag) => makeEl(tag),
|
||||
body: { prepend() {}, appendChild() {} },
|
||||
addEventListener() {},
|
||||
};
|
||||
global.navigator = { clipboard: { writeText() {} } };
|
||||
|
||||
const { state } = require("../src/shared/state");
|
||||
const { NETWORKS } = require("../src/shared/networks");
|
||||
const { clearPrices } = require("../src/shared/prices");
|
||||
const send = require("../src/popup/views/send");
|
||||
const confirmTx = require("../src/popup/views/confirmTx");
|
||||
const approval = require("../src/popup/views/approval");
|
||||
const transactionDetail = require("../src/popup/views/transactionDetail");
|
||||
const { balanceLinesForAddress } = require("../src/popup/views/helpers");
|
||||
const { filterTransactions } = require("../src/shared/transactions");
|
||||
|
||||
const HOLDER = "0x" + "a".repeat(40);
|
||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
// A token contract that is not in the bundled token list.
|
||||
const TOKEN_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
|
||||
|
||||
function text(id) {
|
||||
return global.document.getElementById(id).textContent;
|
||||
}
|
||||
|
||||
// Press Review on the Send screen for a native send of `amount`, and show the
|
||||
// confirmation screen it leads to with its fee estimate settled.
|
||||
async function confirmSend(amount) {
|
||||
let txInfo = null;
|
||||
send.init({ showConfirmTx: (info) => (txInfo = info) });
|
||||
state.selectedToken = "ETH";
|
||||
global.document.getElementById("send-to").value = RECIPIENT;
|
||||
global.document.getElementById("send-amount").value = amount;
|
||||
await global.document
|
||||
.getElementById("btn-send-review")
|
||||
.handlers.get("click")();
|
||||
confirmTx.show(txInfo);
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
// The approval screen for a dApp transaction sending 0.01 of the native token
|
||||
// with 21000 gas at up to 20 gwei, on the active network.
|
||||
async function approveTx() {
|
||||
approvalDetails = {
|
||||
type: "tx",
|
||||
origin: "https://dapp.example",
|
||||
approvedFrom: HOLDER,
|
||||
approvedTx: {
|
||||
to: RECIPIENT,
|
||||
value: "10000000000000000",
|
||||
data: "0x",
|
||||
chainId: NETWORKS[state.networkId].chainId,
|
||||
gasLimit: "21000",
|
||||
maxFeePerGas: "20000000000",
|
||||
nonce: 0,
|
||||
},
|
||||
};
|
||||
await approval.show("1");
|
||||
}
|
||||
|
||||
describe.each([
|
||||
["mainnet", "ETH"],
|
||||
["sepolia", "SepoliaETH"],
|
||||
])("on %s the native token reads %s", (networkId, symbol) => {
|
||||
beforeEach(() => {
|
||||
elements.clear();
|
||||
clearPrices();
|
||||
state.networkId = networkId;
|
||||
state.wallets = [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
addresses: [{ address: HOLDER, balance: "1.5" }],
|
||||
},
|
||||
];
|
||||
state.selectedWallet = 0;
|
||||
state.selectedAddress = 0;
|
||||
state.trackedTokens = [];
|
||||
state.fraudContracts = [];
|
||||
state.currentView = null;
|
||||
});
|
||||
|
||||
test("the balance", async () => {
|
||||
const addr = state.wallets[0].addresses[0];
|
||||
expect(balanceLinesForAddress(addr, [], false)).toContain(
|
||||
`<span>${symbol}</span><span>1.5000</span>`,
|
||||
);
|
||||
state.selectedToken = "ETH";
|
||||
send.updateSendBalance();
|
||||
expect(text("send-balance")).toBe("Current balance: 1.5000 " + symbol);
|
||||
await confirmSend("0.1");
|
||||
expect(text("confirm-balance")).toBe("1.5000 " + symbol);
|
||||
});
|
||||
|
||||
test("the value", async () => {
|
||||
await confirmSend("0.1");
|
||||
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
|
||||
expect(text("confirm-amount")).toBe("0.1 " + symbol);
|
||||
await approveTx();
|
||||
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
|
||||
});
|
||||
|
||||
test("the fee", async () => {
|
||||
await confirmSend("0.1");
|
||||
// 21000 gas at 10 gwei expected, at 20 gwei reserved.
|
||||
expect(text("confirm-fee-amount")).toBe("~0.0002 " + symbol);
|
||||
expect(text("confirm-fee-reserve")).toBe(
|
||||
"up to 0.0004 " + symbol + " reserved",
|
||||
);
|
||||
expect(text("confirm-gas-error")).toContain(
|
||||
"You do not have enough " + symbol + " to pay the network fee",
|
||||
);
|
||||
await approveTx();
|
||||
expect(text("approve-tx-fee")).toBe("0.0004 " + symbol);
|
||||
});
|
||||
|
||||
test("the contract-recipient warning", async () => {
|
||||
await confirmSend("0.1");
|
||||
expect(text("confirm-contract-warning")).toContain(
|
||||
"Sending " + symbol + " or tokens directly to a contract",
|
||||
);
|
||||
});
|
||||
|
||||
// A token reports whatever symbol it likes. One reporting the label the
|
||||
// wallet shows its native token under, on this network or any other, is
|
||||
// a fake, exactly as one reporting `ETH` always was.
|
||||
test.each(["ETH", symbol])(
|
||||
"a token claiming %s is dropped from the history and the Send selector",
|
||||
(claim) => {
|
||||
const result = filterTransactions(
|
||||
[
|
||||
{
|
||||
hash: "0x" + "1".repeat(64),
|
||||
symbol: claim,
|
||||
contractAddress: TOKEN_CONTRACT,
|
||||
holders: 900000,
|
||||
valueGwei: null,
|
||||
isContractCall: false,
|
||||
},
|
||||
],
|
||||
{ hideSpoofedSymbols: true },
|
||||
);
|
||||
expect(result.transactions).toEqual([]);
|
||||
expect(result.newFraudContracts).toEqual([TOKEN_CONTRACT]);
|
||||
|
||||
send.renderSendTokenSelect({
|
||||
address: HOLDER,
|
||||
tokenBalances: [
|
||||
{
|
||||
address: TOKEN_CONTRACT,
|
||||
symbol: claim,
|
||||
decimals: 18,
|
||||
balance: "5",
|
||||
holders: 900000,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
global.document.getElementById("send-token").children,
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
// The detail screen tells the two apart by the token contract, which only
|
||||
// a token transfer has, so a token reporting the native label still reads
|
||||
// as a token transfer.
|
||||
test("the transaction detail screen's type line", () => {
|
||||
const entry = {
|
||||
hash: "0x" + "2".repeat(64),
|
||||
from: RECIPIENT,
|
||||
to: HOLDER,
|
||||
value: "1.0000",
|
||||
exactValue: "1.0",
|
||||
symbol,
|
||||
timestamp: 1790000000,
|
||||
isError: false,
|
||||
direction: "received",
|
||||
directionLabel: "Received",
|
||||
};
|
||||
transactionDetail.show({ ...entry, contractAddress: null });
|
||||
expect(text("tx-detail-type")).toBe("Native " + symbol + " Transfer");
|
||||
transactionDetail.show({ ...entry, contractAddress: TOKEN_CONTRACT });
|
||||
expect(text("tx-detail-type")).toBe("ERC-20 Token Transfer");
|
||||
});
|
||||
|
||||
test("the insufficient-balance error", async () => {
|
||||
await confirmSend("2");
|
||||
expect(
|
||||
global.document.getElementById("confirm-errors").innerHTML,
|
||||
).toContain(
|
||||
"You have 1.5000 " +
|
||||
symbol +
|
||||
" but are trying to send 2 " +
|
||||
symbol +
|
||||
".",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -148,6 +148,173 @@ describe("the destructive reset on the recovery screen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a popup already open when the stored profile becomes unreadable", () => {
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/373. The popup used to stay
|
||||
// on the wallet list with the last good balances, and only a reopen
|
||||
// reached the recovery screen.
|
||||
test("moves to the recovery screen at its next refresh", async () => {
|
||||
const env = await bootPopup(unversionedValidProfile());
|
||||
expect(env.visibleViews()).toEqual(["main"]);
|
||||
|
||||
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
||||
await env.tick();
|
||||
|
||||
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||
expect(env.text("state-recovery-problem").length).toBeGreaterThan(10);
|
||||
expect(env.hidden("btn-settings")).toBe(true);
|
||||
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
|
||||
});
|
||||
|
||||
test("stops the ten-second refresh", async () => {
|
||||
const env = await bootPopup(unversionedValidProfile());
|
||||
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
||||
await env.tick();
|
||||
const { refreshBalances } = require("../src/shared/balances");
|
||||
const calls = refreshBalances.mock.calls.length;
|
||||
|
||||
await env.tick();
|
||||
|
||||
expect(refreshBalances).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
|
||||
test("a later save does not clear what the user exported or typed", async () => {
|
||||
const env = await bootPopup(unversionedValidProfile());
|
||||
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
|
||||
await env.tick();
|
||||
|
||||
await env.click("btn-state-recovery-export");
|
||||
env.node("state-recovery-reset-input").value = "erase my";
|
||||
// Such as the save of a refresh already in flight when the screen
|
||||
// went up.
|
||||
const { saveState } = require("../src/shared/state");
|
||||
await expect(saveState()).rejects.toThrow();
|
||||
await env.settle();
|
||||
|
||||
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||
expect(env.hidden("state-recovery-blob")).toBe(false);
|
||||
expect(env.value("state-recovery-reset-input")).toBe("erase my");
|
||||
});
|
||||
|
||||
// The record can become readable again under this popup, erased from the
|
||||
// recovery screen of another window, so a save from this one can succeed.
|
||||
test("a popup opened after the record is erased elsewhere shows a screen", async () => {
|
||||
const env = await bootPopup(unversionedValidProfile());
|
||||
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
||||
await env.tick();
|
||||
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||
|
||||
await env.storage.remove("autistmask");
|
||||
const { saveState } = require("../src/shared/state");
|
||||
await saveState();
|
||||
|
||||
const reopened = await bootPopup(env.storage.read("autistmask"));
|
||||
expect(reopened.visibleViews()).toEqual(["welcome"]);
|
||||
});
|
||||
|
||||
test("a stored current view of the recovery screen does not blank the popup", async () => {
|
||||
const env = await bootPopup(
|
||||
unversionedValidProfile({ currentView: "state-recovery" }),
|
||||
);
|
||||
expect(env.visibleViews()).toEqual(["main"]);
|
||||
});
|
||||
|
||||
test("a transaction wait that ends under it does not replace it", async () => {
|
||||
const env = await bootPopup(
|
||||
unversionedValidProfile({
|
||||
currentView: "wait-tx",
|
||||
viewData: {
|
||||
pendingWait: {
|
||||
hash: "0x1",
|
||||
txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(env.visibleViews()).toEqual(["wait-tx"]);
|
||||
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
|
||||
await env.tick();
|
||||
await env.click("btn-state-recovery-export");
|
||||
env.node("state-recovery-reset-input").value = "erase my";
|
||||
|
||||
// The test provider answers no receipt lookup, and six that fail in
|
||||
// a row end the wait with an error.
|
||||
for (let i = 0; i < 6; i++) await env.tick();
|
||||
|
||||
expect(env.text("error-tx-message")).toMatch(/could not be reached/);
|
||||
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||
expect(env.hidden("state-recovery-blob")).toBe(false);
|
||||
expect(env.value("state-recovery-reset-input")).toBe("erase my");
|
||||
});
|
||||
|
||||
test("a storage read that fails once leaves the wallet list up", async () => {
|
||||
const env = await bootPopup(unversionedValidProfile());
|
||||
|
||||
env.storage.local.get.mockRejectedValueOnce(
|
||||
new Error("IO error: storage busy"),
|
||||
);
|
||||
await env.tick();
|
||||
|
||||
// Reported as a failed save, not mistaken for an unreadable profile.
|
||||
expect(env.visibleViews()).toEqual(["main"]);
|
||||
expect(env.node("save-failure-banner")).not.toBeNull();
|
||||
|
||||
await env.tick();
|
||||
expect(env.visibleViews()).toEqual(["main"]);
|
||||
});
|
||||
|
||||
// The screen it replaces is left as any navigation leaves it: the rules
|
||||
// at the top of src/popup/views/showPhrase.js and exportPrivkey.js hold
|
||||
// for this way off them too.
|
||||
describe("from a screen holding a secret", () => {
|
||||
const PHRASE =
|
||||
"abandon abandon abandon abandon abandon abandon abandon" +
|
||||
" abandon abandon abandon abandon about";
|
||||
|
||||
afterEach(() => jest.dontMock("../src/shared/vault"));
|
||||
|
||||
test("a recovery phrase on screen is wiped", async () => {
|
||||
jest.doMock("../src/shared/vault", () => ({
|
||||
decryptWithPassword: async () => PHRASE,
|
||||
}));
|
||||
const env = await bootPopup(unversionedValidProfile());
|
||||
require("../src/popup/views/showPhrase").show(0);
|
||||
env.node("show-phrase-password").value = "password";
|
||||
await env.click("btn-show-phrase-reveal");
|
||||
expect(env.text("show-phrase-value")).toBe(PHRASE);
|
||||
|
||||
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
||||
await env.tick();
|
||||
|
||||
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||
expect(env.text("show-phrase-value")).toBe("");
|
||||
});
|
||||
|
||||
test("a private key still being decrypted is never written", async () => {
|
||||
let answer;
|
||||
jest.doMock("../src/shared/vault", () => ({
|
||||
decryptWithPassword: () =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
}));
|
||||
const env = await bootPopup(unversionedValidProfile());
|
||||
require("../src/popup/views/exportPrivkey").show(0, 0);
|
||||
env.node("export-privkey-password").value = "password";
|
||||
const revealing = env.click("btn-export-privkey-confirm");
|
||||
|
||||
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
||||
await env.tick();
|
||||
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||
expect(env.value("export-privkey-password")).toBe("");
|
||||
|
||||
answer(PHRASE);
|
||||
await revealing;
|
||||
expect(env.text("export-privkey-value")).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("an unversioned profile that is perfectly valid", () => {
|
||||
// The upgrade case. Every install in the field is in this state, and the
|
||||
// popup must load it, not offer to wipe it.
|
||||
|
||||
@@ -40,6 +40,10 @@ jest.doMock("libsodium-wrappers-sumo", () => sodium);
|
||||
jest.doMock("qrcode", () => QRCode);
|
||||
jest.doMock("ethereum-blockies-base64", () => makeBlockie);
|
||||
|
||||
// Taken before any boot replaces them; see bootPopup().
|
||||
const realSetInterval = globalThis.setInterval;
|
||||
const realClearInterval = globalThis.clearInterval;
|
||||
|
||||
const POPUP_HTML = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
|
||||
"utf8",
|
||||
@@ -292,9 +296,20 @@ async function bootPopup(stored, options) {
|
||||
}),
|
||||
addEventListener: () => {},
|
||||
};
|
||||
// The 10s refresh loop init() starts would outlive the test.
|
||||
const realSetInterval = globalThis.setInterval;
|
||||
globalThis.setInterval = () => 0;
|
||||
// The ten-second refresh init() starts, and a transaction wait's timers,
|
||||
// would outlive the test. So every interval is recorded rather than
|
||||
// started, clearInterval() removes it as a browser would, and tick() below
|
||||
// runs the ones still set. Put back by cleanupPopup().
|
||||
const intervals = new Map();
|
||||
let lastId = 0;
|
||||
globalThis.setInterval = (fn) => {
|
||||
lastId += 1;
|
||||
intervals.set(lastId, fn);
|
||||
return lastId;
|
||||
};
|
||||
globalThis.clearInterval = (id) => {
|
||||
intervals.delete(id);
|
||||
};
|
||||
|
||||
require("../../src/popup/index");
|
||||
|
||||
@@ -316,8 +331,6 @@ async function bootPopup(stored, options) {
|
||||
}
|
||||
await settle();
|
||||
|
||||
globalThis.setInterval = realSetInterval;
|
||||
|
||||
return {
|
||||
storage,
|
||||
document,
|
||||
@@ -337,6 +350,12 @@ async function bootPopup(stored, options) {
|
||||
for (const fn of fns) await fn();
|
||||
await settle();
|
||||
},
|
||||
// Every interval still set runs once: the ten-second refresh, and a
|
||||
// transaction wait's receipt poll and elapsed counter while one runs.
|
||||
tick: async () => {
|
||||
for (const fn of intervals.values()) await fn();
|
||||
await settle();
|
||||
},
|
||||
settle,
|
||||
// The view ids whose section is not hidden, as the audit measured them.
|
||||
visibleViews: () => {
|
||||
@@ -354,6 +373,8 @@ function cleanupPopup() {
|
||||
delete globalThis.chrome;
|
||||
delete globalThis.document;
|
||||
delete globalThis.window;
|
||||
globalThis.setInterval = realSetInterval;
|
||||
globalThis.clearInterval = realClearInterval;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
+62
-13
@@ -1219,7 +1219,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
|
||||
test("queries only the two Blockscout endpoints for the address", async () => {
|
||||
respondWith([], []);
|
||||
await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH");
|
||||
expect(debugFetch).toHaveBeenCalledTimes(2);
|
||||
const urls = debugFetch.mock.calls.map((c) => c[0]);
|
||||
expect(urls).toContain(
|
||||
@@ -1283,7 +1283,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
],
|
||||
);
|
||||
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH");
|
||||
expect(txs).toHaveLength(1);
|
||||
const merged = txs[0];
|
||||
// The received leg (the swap output) supplies the display amount.
|
||||
@@ -1320,7 +1320,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
],
|
||||
);
|
||||
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH");
|
||||
expect(txs).toHaveLength(1);
|
||||
expect(txs[0].symbol).toBe("USDC");
|
||||
expect(txs[0].value).toBe("1500.5000");
|
||||
@@ -1346,7 +1346,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
});
|
||||
respondWith([], [leg("1000000"), leg("2000000")]);
|
||||
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH");
|
||||
expect(txs).toHaveLength(1);
|
||||
// Keyed by hash plus contract, so the later leg wins.
|
||||
expect(txs[0].exactValue).toBe("2.0");
|
||||
@@ -1386,7 +1386,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
],
|
||||
);
|
||||
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH");
|
||||
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
|
||||
});
|
||||
|
||||
@@ -1427,7 +1427,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
],
|
||||
);
|
||||
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH");
|
||||
expect(txs).toHaveLength(1);
|
||||
expect(txs[0].symbol).toBe("USDC");
|
||||
expect(txs[0].exactValue).toBe("1.0");
|
||||
@@ -1452,10 +1452,43 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
});
|
||||
respondWith([item(6), item(8), item(7)], []);
|
||||
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, 2);
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH", 2);
|
||||
expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]);
|
||||
});
|
||||
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/372: the caller hands in
|
||||
// the active network's nativeCurrency, and a native entry carries it.
|
||||
test("a native entry is labelled with the native token symbol handed in", async () => {
|
||||
respondWith(
|
||||
[
|
||||
{
|
||||
hash: "0x" + "a".repeat(64),
|
||||
block_number: 21000090,
|
||||
timestamp: TS,
|
||||
from: { hash: ORDINARY_PEER },
|
||||
to: { hash: VICTIM, is_contract: false },
|
||||
value: "10000000000000000",
|
||||
method: null,
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
const sepolia = await fetchRecentTransactions(
|
||||
VICTIM,
|
||||
BLOCKSCOUT,
|
||||
"SepoliaETH",
|
||||
);
|
||||
expect(sepolia[0].symbol).toBe("SepoliaETH");
|
||||
expect(sepolia[0].value).toBe("0.0100");
|
||||
const mainnet = await fetchRecentTransactions(
|
||||
VICTIM,
|
||||
BLOCKSCOUT,
|
||||
"ETH",
|
||||
);
|
||||
expect(mainnet[0].symbol).toBe("ETH");
|
||||
});
|
||||
|
||||
test("the fake token transfer survives fetching and is then filtered", async () => {
|
||||
respondWith(
|
||||
[],
|
||||
@@ -1476,7 +1509,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
],
|
||||
);
|
||||
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH");
|
||||
expect(txs).toHaveLength(1);
|
||||
expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT);
|
||||
expect(txs[0].holders).toBe(0);
|
||||
@@ -1515,19 +1548,31 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
|
||||
test("an omitted holders_count parses to null", async () => {
|
||||
respondWith([], spamTransferWithToken(OMITTED));
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
const txs = await fetchRecentTransactions(
|
||||
VICTIM,
|
||||
BLOCKSCOUT,
|
||||
"ETH",
|
||||
);
|
||||
expect(txs[0].holders).toBeNull();
|
||||
});
|
||||
|
||||
test("a null holders_count parses to null", async () => {
|
||||
respondWith([], spamTransferWithToken(NULLED));
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
const txs = await fetchRecentTransactions(
|
||||
VICTIM,
|
||||
BLOCKSCOUT,
|
||||
"ETH",
|
||||
);
|
||||
expect(txs[0].holders).toBeNull();
|
||||
});
|
||||
|
||||
test("the transfer survives the low-holder filter", async () => {
|
||||
respondWith([], spamTransferWithToken(OMITTED));
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
const txs = await fetchRecentTransactions(
|
||||
VICTIM,
|
||||
BLOCKSCOUT,
|
||||
"ETH",
|
||||
);
|
||||
expect(filterTransactions(txs, filters()).transactions).toEqual(
|
||||
txs,
|
||||
);
|
||||
@@ -1539,7 +1584,11 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
// holder count is the only rule that can catch it.
|
||||
test('a reported holders_count of "0" still parses to 0 and is filtered', async () => {
|
||||
respondWith([], spamTransferWithToken(ZERO));
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
const txs = await fetchRecentTransactions(
|
||||
VICTIM,
|
||||
BLOCKSCOUT,
|
||||
"ETH",
|
||||
);
|
||||
expect(txs[0].holders).toBe(0);
|
||||
expect(filterTransactions(txs, filters()).transactions).toEqual([]);
|
||||
});
|
||||
@@ -1555,7 +1604,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
},
|
||||
}));
|
||||
await expect(
|
||||
fetchRecentTransactions(VICTIM, BLOCKSCOUT),
|
||||
fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user