Porównaj commity

..
1 Commity
Autor SHA1 Wiadomość Data
clawbot f47d17a98f chore: the native token's label follows the active network (closes #372)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
networks.js gives each network a nativeCurrency (ETH, SepoliaETH) and nothing
read it: every screen wrote a hardcoded ETH, so on Sepolia the balance, the
value and the fee all read ETH. A nativeCurrency() helper in the popup's
helpers.js returns the active network's, and every native balance, value, fee,
reserve, type line and history entry is labelled with it. The "ETH" that
selectedToken and txInfo.token hold is the native token's id and is unchanged.
The confirmation screen's not-enough-to-pay-the-fee sentence now names the
coin to add, so show() sets it.

Model: opus-5-5
2026-10-04 21:53:00 +00:00
15 zmienionych plików z 60 dodań i 450 usunięć
+26 -47
Wyświetl plik
@@ -848,11 +848,11 @@ 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
this document shows ETH as the label of a native balance, value or fee, or in a
"Native ETH transfer" type line, 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.
line, the contract-recipient warning and the refusal of a fee above 1 ETH keep
their 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
@@ -1114,8 +1114,7 @@ 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"`, and every network's `nativeCurrency`, such
as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
legitimate contract at all (`"ETH"`). 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
@@ -1143,18 +1142,16 @@ 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()` 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.
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.
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
@@ -1198,9 +1195,8 @@ 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 — 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.
covers — a quota, a revoked permission, a record a newer build wrote — and the
wallet must never look healthy while that is true.
The `networkId` check is not cosmetic: that value is an object KEY into
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
@@ -1598,9 +1594,7 @@ 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. A
transfer with a token contract is an ERC-20 Token Transfer whatever symbol
the token reports.
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
- Status: "Success" or "Failed"
- From: blockie + color dot + full address (tap to copy) + etherscan link;
ENS name if available
@@ -2012,19 +2006,9 @@ view would leave a wallet one click from deletion.
#### StateRecovery (`state-recovery`)
- **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.
- **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.
- **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.
@@ -2051,20 +2035,16 @@ 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. `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.
could not be read, and `showView()` is not used to raise it for the same
reason — it reads and writes the state singleton.
- **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`, 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.
- Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because
nothing on this path writes state at all.
### External Services
@@ -2373,8 +2353,7 @@ 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 — the native asset's labels: `"ETH"` and every network's
`nativeCurrency`, such as `"SepoliaETH"`, on every network — may be borne by
contract at all — `"ETH"`, the native asset, is the only one — 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.
+3 -21
Wyświetl plik
@@ -49,27 +49,9 @@ but the review is broader than any of them.
([#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.
lists, Send, confirmation, approval, wait, success and error screens, and the
transaction history now read `nativeCurrency`, which is `ETH` on mainnet and
`SepoliaETH` on Sepolia.
- 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`
+10 -4
Wyświetl plik
@@ -640,13 +640,19 @@
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 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
class="mb-2"
style="visibility: hidden"
></div>
>
<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
id="confirm-burn-warning"
class="mb-2"
+2 -27
Wyświetl plik
@@ -50,10 +50,6 @@ 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;
@@ -159,28 +155,7 @@ 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.
//
// 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);
}
});
onSaveFailure(showSaveFailureBanner);
try {
await loadState();
} catch (e) {
@@ -269,7 +244,7 @@ async function init() {
renderWalletList();
restoreView();
doRefreshAndRender();
refreshTimer = setInterval(doRefreshAndRender, 10000);
setInterval(doRefreshAndRender, 10000);
}
}
-7
Wyświetl plik
@@ -205,13 +205,6 @@ function show(txInfo) {
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
// the estimate fails every time and going back cannot help; any other
+3 -18
Wyświetl plik
@@ -52,8 +52,9 @@ const VIEWS = [
"export-privkey",
"show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile
// cannot be read, never by showView() (see there), but listed so that
// every view-hiding loop covers it.
// 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.
"state-recovery",
];
@@ -84,28 +85,12 @@ 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) {
+2 -9
Wyświetl plik
@@ -3,10 +3,8 @@
// 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, 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).
// the time this runs, loadState() has REFUSED, deliberately, and reading the
// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// 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
@@ -172,11 +170,6 @@ 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);
+3 -3
Wyświetl plik
@@ -42,9 +42,9 @@ function getTransactionType(tx) {
return "Token Approval";
return "Contract Call";
}
// 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";
if (tx.symbol && tx.symbol !== nativeCurrency()) {
return "ERC-20 Token Transfer";
}
return "Native " + nativeCurrency() + " Transfer";
}
+2 -15
Wyświetl plik
@@ -54,11 +54,9 @@ 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
@@ -409,23 +407,12 @@ 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) +
" " +
nativeCurrency +
", which is more than the " +
" ETH, which is more than the " +
formatEther(MAX_TOTAL_FEE) +
" " +
nativeCurrency +
" this wallet will sign for.",
" ETH this wallet will sign for.",
);
}
}
+2 -2
Wyświetl plik
@@ -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 one such entry, and every network's
// `nativeCurrency` in networks.js (`SepoliaETH`) is another, on every network.
// 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.
//
// 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
+1 -7
Wyświetl plik
@@ -6,7 +6,6 @@
// 511 tokens.
const { debugFetch } = require("./log");
const { NETWORKS } = require("./networks");
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
@@ -3611,9 +3610,7 @@ 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. 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.
// bear its symbol.
//
// 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
@@ -3627,9 +3624,6 @@ 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)) {
-18
Wyświetl plik
@@ -599,24 +599,6 @@ 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 () => {
+1 -79
Wyświetl plik
@@ -5,8 +5,7 @@
// 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.
// with only the node and the DOM stubbed.
"use strict";
@@ -112,10 +111,6 @@ function makeEl(id) {
querySelector: () => null,
remove() {},
focus() {},
// Views reach for .parentElement to hide whole sections.
get parentElement() {
return global.document.getElementById(id + "-parent");
},
};
}
@@ -136,14 +131,10 @@ 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;
@@ -239,75 +230,6 @@ describe.each([
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(
-167
Wyświetl plik
@@ -148,173 +148,6 @@ 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.
+5 -26
Wyświetl plik
@@ -40,10 +40,6 @@ 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",
@@ -296,20 +292,9 @@ async function bootPopup(stored, options) {
}),
addEventListener: () => {},
};
// 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);
};
// The 10s refresh loop init() starts would outlive the test.
const realSetInterval = globalThis.setInterval;
globalThis.setInterval = () => 0;
require("../../src/popup/index");
@@ -331,6 +316,8 @@ async function bootPopup(stored, options) {
}
await settle();
globalThis.setInterval = realSetInterval;
return {
storage,
document,
@@ -350,12 +337,6 @@ 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: () => {
@@ -373,8 +354,6 @@ function cleanupPopup() {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval;
}
module.exports = {