Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 70f0feee28 fix: open no approval window for a site-connection prompt already answered (closes #287)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
When a site-connection prompt was decided before the toolbar popup raised
for it had loaded, that popup was torn down, chrome.action.openPopup()
rejected, and the background opened its fallback window for the answered
approval and only then removed it. In the Chrome end-to-end suite the next
test could take that window for its own prompt and lose it under its wait.
openApprovalWindow() now returns before creating a window when the approval
is no longer pending.

The blocklist test clicked its self-closing Reject with a plain click; it
now clicks it as the other site Reject does, with the click witnessed.

Model: opus-5-5
2026-10-05 01:10:10 +00:00
37 changed files with 678 additions and 2453 deletions
+5 -4
View File
@@ -22,10 +22,11 @@ on: [push]
# These jobs REPORT, they do not gate. Whether a check blocks a merge is # These jobs REPORT, they do not gate. Whether a check blocks a merge is
# Gitea branch protection, which this repo does not configure, so a failure # Gitea branch protection, which this repo does not configure, so a failure
# here is a red mark a reviewer has to account for rather than a hard # here is a red mark a reviewer has to account for rather than a hard
# block. Making e2e-chrome a required check is blocked while reports of the # block. Making e2e-chrome a required check is blocked on the measured
# Chrome suite failing under load are still open; the "In CI" section of # flake in the dApp signing wait -- two of six runs of unmutated code on a
# README.md names them. A gate that fails at random teaches people to merge # loaded machine -- tracked as
# past red. # https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at
# random teaches people to merge past red.
# #
# Nothing here may pass vacuously. There is no continue-on-error and no # Nothing here may pass vacuously. There is no continue-on-error and no
# `|| true`. Both scripts exit non-zero when docker is missing, when the # `|| true`. Both scripts exit non-zero when docker is missing, when the
+30 -63
View File
@@ -342,11 +342,6 @@ fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
offline; unrecognised outbound requests are reported as failures rather than offline; unrecognised outbound requests are reported as failures rather than
silently allowed. silently allowed.
It also covers the StateRecovery screen, under the shipped CSP: a stored record
this build cannot read opens the popup on it, its export text box holds that
record exactly as stored, a near-miss confirmation phrase erases nothing, and
the exact one erases the record and reloads into Welcome.
It also covers the **Settings screen**, which holds the densest run of element It also covers the **Settings screen**, which holds the densest run of element
id lookups in the codebase and where one wrong id leaves the whole popup blank id lookups in the codebase and where one wrong id leaves the whole popup blank
rather than only degrading Settings: that the screen renders populated — the rather than only degrading Settings: that the screen renders populated — the
@@ -386,13 +381,11 @@ handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
the runner and compared against the active address, the transaction assertions the runner and compared against the active address, the transaction assertions
run against the raw signed transaction captured at `eth_sendRawTransaction` run against the raw signed transaction captured at `eth_sendRawTransaction`
rather than against anything the extension reported, rejecting each prompt is rather than against anything the extension reported, rejecting each prompt is
required to return a rejection to the page rather than hang or resolve, a prompt required to return a rejection to the page rather than hang or resolve, and the
raised while another approval window has focus is required to open a window of password is required to be absent from every message the approval window sends
its own, and the password is required to be absent from every message the to the background — with the message that would carry it required to be present,
approval window sends to the background — with the message that would carry it so that check cannot pass by observing nothing. That last one is the standing
required to be present, so that check cannot pass by observing nothing. That floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
Two limits of that coverage, neither of them papered over. The RPC is stubbed Two limits of that coverage, neither of them papered over. The RPC is stubbed
throughout, so this is **not** a real dApp against a real network with real throughout, so this is **not** a real dApp against a real network with real
@@ -469,11 +462,10 @@ Chrome that ever changes this fails the run instead of passing it.
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a `make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver. real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
It covers popup load, the StateRecovery screen (the same cases as the Chrome It covers popup load, wallet creation through the UI, the Add Token screen, and
suite), wallet creation through the UI, the Add Token screen, and the four dApp the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and `eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
a closed approval window rejecting with EIP-1193 4001 — driven through the real — driven through the real content script, background page and approval windows.
content script, background page and approval windows.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and **no npm dependencies at all**: it is built on global `fetch` and
@@ -627,10 +619,15 @@ The jobs **report, they do not gate.** A failure is a red mark against the
commit that a reviewer has to account for, not a hard block: whether a check commit that a reviewer has to account for, not a hard block: whether a check
blocks a merge is Gitea branch protection, which this repo does not configure. blocks a merge is Gitea branch protection, which this repo does not configure.
That is not only a statement about configuration. No report of the Chrome suite That is not only a statement about configuration. Reports of the Chrome suite
**failing under load** is open now, but it has failed that way before, so a red **failing under load** are still open, among them
`e2e-chrome` is read before it is believed. Do not answer one with a retry [#290](https://git.eeqj.de/sneak/AutistMask/issues/290), runs on a busy machine
wrapper: a suite that reruns until it is green stops being evidence. failing with `the extension opened no approval window within 30000ms`, and
[#446](https://git.eeqj.de/sneak/AutistMask/issues/446), the Settings round trip
closing the popup before its network switch is saved. So a red `e2e-chrome` has
to be read before it is believed, and those failures are the blocker to ever
making this a required check. Do not answer them with a retry wrapper: a suite
that reruns until it is green stops being evidence.
Nothing in either job can pass vacuously. There is no `continue-on-error` and no Nothing in either job can pass vacuously. There is no `continue-on-error` and no
`|| true`; both scripts exit non-zero when docker is missing, when the image `|| true`; both scripts exit non-zero when docker is missing, when the image
@@ -846,12 +843,10 @@ something when you click it.
The same data must be formatted identically everywhere it appears. Token and ETH The same data must be formatted identically everywhere it appears. Token and ETH
amounts are displayed with exactly 4 decimal places (e.g. "1.0500 ETH", "17.1900 amounts are displayed with exactly 4 decimal places (e.g. "1.0500 ETH", "17.1900
USDT") in balance lists, transaction lists, send confirmations, and approval USDT") in balance lists, transaction lists, send confirmations, and approval
screens. A transaction's time includes both an ISO datetime and a humanized screens. Timestamps include both an ISO datetime and a humanized relative age
relative age, written by `isoDate()` and `timeAgo()` in wherever shown. If a formatting rule applies in one place, it applies in every
`src/popup/views/helpers.js` on every screen that shows one; the ISO datetime is place. Users should never see the same value rendered differently on two
in UTC when the UTC Timestamps setting is on. If a formatting rule applies in screens.
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 a network's `nativeCurrency` in The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The `src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
@@ -1474,17 +1469,6 @@ view would leave a wallet one click from deletion.
- Amount input with current balance display, which reads - Amount input with current balance display, which reads
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as `Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
ConfirmTx's balance line does (see Unknown token scale) ConfirmTx's balance line does (see Unknown token scale)
- "Max" button beside the amount input, always in place. It fills in a
token's balance, cut down to the 18 decimal places ConfirmTx accepts for a
token that has more, or for ETH the exact balance minus the network fee
reserve that ConfirmTx's balance check gates on, never the rounded balance
shown above it. The ETH fee is estimated for the recipient entered, so it
asks for a recipient first; an estimate that finishes after the screen was
left or the address, holding or recipient changed fills nothing in. Where
there is nothing to fill in, a flash message says why: the balance does
not cover the fee, the fee could not be estimated, or the token's balance
is unknown or zero. Typing in the amount makes it an ordinary amount;
changing what to send clears an amount Max filled in
- "Review" button, disabled until the recipient validates - "Review" button, disabled until the recipient validates
- **Transitions**: - **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx** - "Review" (valid inputs, ENS resolved) → **ConfirmTx**
@@ -1501,14 +1485,7 @@ view would leave a wallet one click from deletion.
- Token contract: full address + etherscan link (ERC-20 only) - Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title - From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name - To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max" - Amount: value + symbol (USD in parentheses)
filled in is worked out again from this screen's own fee estimate when it
arrives, as the balance minus the reserve, and the transaction is signed
with that estimate's fee fields, so a fee fetched again at signing cannot
exceed what the amount leaves behind. The address keeps whatever part of
the reserve the transaction does not use. If the balance no longer covers
the fee, the amount is left as it was and the amount-plus-fee error below
blocks the send
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)` - Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
for a token whose scale is unknown for a token whose scale is unknown
- Network fee: "Estimating..." then two lines, or "Unable to estimate", - Network fee: "Estimating..." then two lines, or "Unable to estimate",
@@ -1812,10 +1789,7 @@ view would leave a wallet one click from deletion.
new password — and, in bold, that without that phrase written down the new password — and, in bold, that without that phrase written down the
deletion loses everything the wallet holds, forever deletion loses everything the wallet holds, forever
- That the other wallets are not touched - That the other wallets are not touched
- The wallet's name, and a text input asking for it to be typed back. A name - The wallet's name, and a text input asking for it to be typed back
that shows nothing at all (only spaces, or only characters that paint
nothing) is shown as "Wallet N", its position in the list, and that is
what is typed back.
- Error line - Error line
- "Delete This Wallet Forever" button - "Delete This Wallet Forever" button
- **Transitions**: - **Transitions**:
@@ -1823,9 +1797,9 @@ view would leave a wallet one click from deletion.
outcomes as "Confirm Delete" above, through the same `finishDelete()`, so outcomes as "Confirm Delete" above, through the same `finishDelete()`, so
the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED` the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED`
broadcast are identical on both routes broadcast are identical on both routes
- "Delete This Wallet Forever" (name does not match, or the field is empty) - "Delete This Wallet Forever" (name does not match) → "That is not the name
→ "That is not the name of this wallet. Type <name> to confirm." on of this wallet. Type <name> to confirm." on the error line, nothing
the error line, nothing deleted deleted
- "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet - "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet
selection comes back with it. The two delete screens are siblings rather selection comes back with it. The two delete screens are siblings rather
than parent and child: nothing is pushed on the way here, so both have than parent and child: nothing is pushed on the way here, so both have
@@ -1834,13 +1808,8 @@ view would leave a wallet one click from deletion.
secret protects nobody: an attacker at the popup who wants the wallet gone can secret protects nobody: an attacker at the popup who wants the wallet gone can
uninstall the extension, so the only person such a gate stops is the owner who uninstall the extension, so the only person such a gate stops is the owner who
forgot it. The typed name is a check that the user knows which wallet they are forgot it. The typed name is a check that the user knows which wallet they are
on, not a secret, so it is matched as the user can see it: letter case, on, not a secret, so it is matched with surrounding spaces and letter case
surrounding spaces and repeated inner spaces are ignored, and characters that ignored.
paint nothing (format characters such as the zero-width space,
default-ignorable characters, and DELETE — the same set
`src/shared/symbolSpoof.js` strips) are removed from both sides before
comparing. An empty field, or one holding only spaces or such characters, is
refused whatever the wallet is called.
- Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened - Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened
by accident must not land on a screen whose button erases key material. by accident must not land on a screen whose button erases key material.
@@ -1965,9 +1934,7 @@ view would leave a wallet one click from deletion.
`eth_sendTransaction` arriving while one is unanswered is refused with `eth_sendTransaction` arriving while one is unanswered is refused with
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens EIP-1193 code `-32002` rather than being populated at the same nonce. It opens
no window and takes no nonce, and the site can send it again once the pending no window and takes no nonce, and the site can send it again once the pending
one is answered. The window is centred on the browser window the user was last one is answered.
in; if that was another approval window, or the browser refuses the centred
position, the browser picks the position.
- **Elements**: - **Elements**:
- "Transaction Request" heading - "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing - Phishing warning banner (shown when the hostname is on the phishing
+1 -130
View File
@@ -45,122 +45,6 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-10-05: The StateRecovery screen is driven in a real browser under the
shipped CSP, in both end-to-end suites
([#361](https://git.eeqj.de/sneak/AutistMask/issues/361)). A stored record
this build cannot read opens the popup on it; its export text box holds the
record exactly as stored; a near-miss confirmation phrase erases nothing; and
the exact phrase erases the record and reloads into Welcome. The cases run
before any wallet exists: with no wallet nothing saves on a timer, so no save
can write a good record over the unreadable one, and the erase leaves the
popup on Welcome for wallet creation.
- 2026-10-05: Escaping in the popup's views follows its own rule with no
exceptions ([#329](https://git.eeqj.de/sneak/AutistMask/issues/329)). The
decimals and holder count on a token's screen, and every USD figure (the ETH
price, each total and each balance row's value), went into `innerHTML`
unescaped; they are escaped now. None could carry markup, but `formatUsd()`
writes a value under a cent as `< $0.01`. `displaySymbol()` counts a symbol in
code points rather than UTF-16 units, so a cut never splits an emoji into a
half that renders as U+FFFD. `explorerLink()`, also named in the issue, was
already removed by [#168](https://git.eeqj.de/sneak/AutistMask/issues/168).
- 2026-10-05: A Chrome end-to-end test that fails no longer takes later tests
down with it ([#318](https://git.eeqj.de/sneak/AutistMask/issues/318)). Each
test that turns a fixture switch on for itself alone (a held or failing gas
estimate, a seeded native transfer or receipt, a token's lying `decimals()` or
markup symbol) turns it off again in a `finally`, and the two tests that drive
the popup's own send end on the address screen whether they pass or not,
reopening the popup to leave a wait for a receipt. The lying-`decimals()` test
checks that nothing was broadcast as soon as the send ends, before it waits
for the failure screen, so a broadcast fails it in seconds rather than after a
60-second wait. The fixture's `decimals()` override tells 0 from no override,
so a token with no decimal places can be fixtured.
- 2026-10-05: Chrome draws the popup in its monospace font
([#418](https://git.eeqj.de/sneak/AutistMask/issues/418)), as Firefox does.
Chrome adds a stylesheet of its own to extension pages that sets the font on
`body`, and it beat Tailwind's `font-mono`: Tailwind 4 puts its classes in a
cascade layer, and a rule outside any layer wins over them. `body` now carries
`font-mono!`, which marks the class important. Both end-to-end suites check
the popup's font. The same stylesheet also makes Chrome draw the popup's text
at 12px rather than the 14px `text-sm` asks for; that is unchanged, and filed
as [#456](https://git.eeqj.de/sneak/AutistMask/issues/456).
- 2026-10-05: Dead code removed and copied view helpers shared
([#168](https://git.eeqj.de/sneak/AutistMask/issues/168)). AddressDetail and
AddressToken each defined their own `isoDate()` and `timeAgo()` in place of
the ones in `src/popup/views/helpers.js`, so a fix to the shared pair would
not have reached them. The copies were identical; every screen now uses the
shared pair. `blockieHtml()` and `tokenLabel()`, each defined twice, live in
`helpers.js` too. Removed as never called: `explorerLink()` (the views build
explorer links with `explorerUrl()`), `ETHEREUM_SEPOLIA_CHAIN_ID` (the chain
id lives in `src/shared/networks.js`), and `getWalletValue()` and
`getTotalValue()`: Home's "Total:" is the active address's total, as
`README.md` says. `addressColor()` and `etherscanAddressUrl()` are no longer
exported. Nothing the user sees changed.
- 2026-10-05: A prompt raised while another approval window has focus opens a
window of its own ([#290](https://git.eeqj.de/sneak/AutistMask/issues/290)).
The background centred each approval window on the last focused window, which
could be an earlier approval window still open; headless Chrome reports one as
1280x720, so the new window came out where the browser refused to create it,
and the request failed with no window at all. It now centres only on a browser
window, and when the browser refuses the position it asks again without one
and lets the browser place the window. In the Chrome end-to-end suite a test
could raise its prompt while the previous test's window was still closing, and
then either hit that refusal or take the closing window for its own. After a
test that passed, the runner now waits a few seconds for approval windows to
close and fails the test if one is still open; after a test that failed, it
closes them.
- 2026-10-05: The Send screen has a "Max" button
([#198](https://git.eeqj.de/sneak/AutistMask/issues/198)). Emptying an ETH
address took guessing an amount and being refused by the confirmation screen's
balance check. Max fills in a token's whole balance, cut to the 18 decimal
places the confirmation screen accepts, or for ETH the exact balance minus the
fee reserve that check gates on, never the four-decimal balance shown; a fee
estimate that finishes after the Send screen was left, or its address, holding
or recipient changed, fills nothing in. The confirmation screen works a max
ETH amount out again from its own fee estimate and signs it with that
estimate's fee fields: fetched again at signing, a fee that had risen since
would leave amount plus fee above the balance, and the node would refuse the
send. A token's maximum is still refused when ETH cannot pay the fee. Where
there is nothing to fill in, a flash message says why.
- 2026-10-05: A token scale of zero decimals is tested
([#325](https://git.eeqj.de/sneak/AutistMask/issues/325)).
`resolveTokenDecimals()` already used a scale of 0 from the bundled list or
from a tracked token, but no test said so: turning either of its `d !== null`
checks into a plain truthiness check left every test green while a
zero-decimal token fell through to the next source, or to "decimals unknown".
The approval tests now assert a scale of 0 from each of those two sources,
both where it is resolved and on the approval screen's Amount line. The second
half of the issue, one shared `toDecimals()`, had already landed with
[#349](https://git.eeqj.de/sneak/AutistMask/issues/349).
- 2026-10-05: The e2e suite waits for a save to land before it closes the popup
([#446](https://git.eeqj.de/sneak/AutistMask/issues/446)). The Settings round
trip switched the theme and the network and closed the popup at once, and a
close before the save lands loses the switch; with the network left on
Sepolia, a dozen later tests failed too. Each Settings switch and spam-filter
toggle is now waited for in storage before the close, and `reopenPopup()`
waits until the view it expects to reopen on is the saved one. The restore
half of the round trip and the second filter toggle change a setting right
after a reopen, while the reopened popup's own saves may still be running;
they rely on the fix for
[#448](https://git.eeqj.de/sneak/AutistMask/issues/448).
- 2026-10-05: A change made while an earlier save from the same page is still
running is stored ([#448](https://git.eeqj.de/sneak/AutistMask/issues/448)).
`saveStateOnce()` took its baseline from the page's state after the write, so
a change made while the save waited on storage counted as already stored and
the save queued after it wrote nothing. A setting changed during the read was
lost; so was a wallet added, a site revoked or an endpoint changed during the
write, and a wallet deleted then stayed in storage. The save now copies the
page's fields when it starts, writes from that copy, and keeps the copy as the
baseline.
- 2026-10-05: The extension no longer opens a window for a site-connection - 2026-10-05: The extension no longer opens a window for a site-connection
prompt already answered prompt already answered
([#287](https://git.eeqj.de/sneak/AutistMask/issues/287)). When the prompt was ([#287](https://git.eeqj.de/sneak/AutistMask/issues/287)). When the prompt was
@@ -171,20 +55,7 @@ but the review is broader than any of them.
lose it under its wait. `openApprovalWindow()` now opens nothing for an lose it under its wait. `openApprovalWindow()` now opens nothing for an
approval that is no longer pending. The blocklist test's Reject, whose window approval that is no longer pending. The blocklist test's Reject, whose window
closes itself, is clicked as the other site Reject is, with the click closes itself, is clicked as the other site Reject is, with the click
witnessed. Making `e2e-chrome` a required check is still blocked: other witnessed.
reports of the Chrome suite failing under load are open, among them
[#290](https://git.eeqj.de/sneak/AutistMask/issues/290) and
[#446](https://git.eeqj.de/sneak/AutistMask/issues/446), as `README.md` says.
- 2026-10-05: The lost-password delete confirmation refuses an empty field and
ignores characters that paint nothing
([#336](https://git.eeqj.de/sneak/AutistMask/issues/336)). A wallet named only
with spaces compared equal to an empty field, so typing nothing would have
deleted it, and a zero-width space in a name made the name impossible to type
back. An empty field is now refused whatever the name is, the same invisible
characters `src/shared/symbolSpoof.js` strips are removed from both sides, and
a name that shows nothing is shown and typed back as "Wallet N".
- 2026-10-05: A `holders_count` that is not a whole number in plain digits is - 2026-10-05: A `holders_count` that is not a whole number in plain digits is
unknown, not read in part unknown, not read in part
([#251](https://git.eeqj.de/sneak/AutistMask/issues/251)). `parseInt` read ([#251](https://git.eeqj.de/sneak/AutistMask/issues/251)). `parseInt` read
+1 -3
View File
@@ -240,9 +240,7 @@ screen. Tokens can also be added from Settings, under "Tracked Tokens".
2. Select what to send (ETH, or any ERC-20 token with a balance on this address 2. Select what to send (ETH, or any ERC-20 token with a balance on this address
that survives the spam filters). that survives the spam filters).
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`). 3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18 4. Enter the amount.
decimal places, or your ETH balance minus the amount reserved for the network
fee.
5. Click "Review" to see the confirmation screen. 5. Click "Review" to see the confirmation screen.
The confirmation screen shows: The confirmation screen shows:
+3 -22
View File
@@ -437,13 +437,7 @@ async function openApprovalWindow(id) {
width: popupWidth, width: popupWidth,
height: popupHeight, height: popupHeight,
}; };
// Centred on a browser window only. The last focused window can be if (currentWin) {
// another approval window still open, and centring on one can give a
// position the browser refuses ("Bounds must be at least 50% within
// visible screen space"): headless Chrome reports this 360x600 popup as
// 1280x720. The request then failed with no window at all. Over a popup,
// the browser picks the position.
if (currentWin && currentWin.type === "normal") {
opts.left = Math.round( opts.left = Math.round(
currentWin.left + (currentWin.width - popupWidth) / 2, currentWin.left + (currentWin.width - popupWidth) / 2,
); );
@@ -461,23 +455,10 @@ async function openApprovalWindow(id) {
win = await windowsCreate(opts); win = await windowsCreate(opts);
} catch (e) { } catch (e) {
// The promise namespace reports the failure by rejecting where the // The promise namespace reports the failure by rejecting where the
// callback namespace reported it by handing back no window; both // callback namespace reported it by handing back no window; both land
// leave win null. // on the !win branch below, which settles the approval.
log.errorf("could not open the approval window:", e); log.errorf("could not open the approval window:", e);
} }
// The browser also refuses a centred position that is too far off screen,
// as it is over a browser window near the screen edge. Asked again
// without a position, it places the window itself. If that fails too,
// the !win branch below settles the approval.
if (!win && opts.left !== undefined) {
delete opts.left;
delete opts.top;
try {
win = await windowsCreate(opts);
} catch (e) {
log.errorf("could not open the approval window:", e);
}
}
const approval = pendingApprovals[id]; const approval = pendingApprovals[id];
if (!approval) { if (!approval) {
+7 -18
View File
@@ -6,10 +6,7 @@
<title>AutistMask</title> <title>AutistMask</title>
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<!-- Chrome gives extension pages a stylesheet of its own that sets the <body class="bg-bg text-fg font-mono text-sm">
font on body, and a Tailwind class beats it only when marked
important: hence font-mono! rather than font-mono. -->
<body class="bg-bg text-fg font-mono! text-sm">
<div id="app" class="p-2 pr-5 overflow-x-hidden"> <div id="app" class="p-2 pr-5 overflow-x-hidden">
<!-- ============ GLOBAL TITLE BAR ============ --> <!-- ============ GLOBAL TITLE BAR ============ -->
<div <div
@@ -554,20 +551,12 @@
class="text-xs text-muted" class="text-xs text-muted"
></span> ></span>
</div> </div>
<div class="flex gap-1"> <input
<input type="text"
type="text" id="send-amount"
id="send-amount" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
class="border border-border p-1 flex-1 min-w-0 font-mono text-sm bg-bg text-fg" placeholder="0.0"
placeholder="0.0" />
/>
<button
id="btn-send-max"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Max
</button>
</div>
</div> </div>
<button <button
id="btn-send-review" id="btn-send-review"
+57 -3
View File
@@ -11,8 +11,6 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
@@ -66,7 +64,7 @@ function show() {
$("address-line").dataset.full = addr.address; $("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line")); attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr)); const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;"; $("address-usd-total").innerHTML = usdTotal || "&nbsp;";
const ensEl = $("address-ens"); const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element // ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden"); ensEl.classList.add("hidden");
@@ -90,6 +88,62 @@ function show() {
loadTransactions(addr.address); loadTransactions(addr.address);
} }
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
"T" +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds()) +
tzStr
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
let loadedTxs = []; let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
+59 -5
View File
@@ -17,8 +17,6 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
@@ -39,6 +37,62 @@ const { walletDefect } = require("../../shared/walletDefects");
let ctx; let ctx;
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
"T" +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds()) +
tzStr
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
let loadedTxs = []; let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
let currentSymbol = null; let currentSymbol = null;
@@ -103,7 +157,7 @@ function show() {
// USD total for this token only // USD total for this token only
const usdVal = price && amount !== null ? amount * price : null; const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal); const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;"; $("address-token-usd-total").innerHTML = usdStr || "&nbsp;";
// Single token balance line (no tokenId — not clickable here) // Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price); $("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
@@ -148,9 +202,9 @@ function show() {
if (tokenSymbol) if (tokenSymbol)
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
if (tokenDecimals != null) if (tokenDecimals != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${escapeHtml(tokenDecimals)}</div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
if (tokenHolders != null) if (tokenHolders != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${escapeHtml(Number(tokenHolders).toLocaleString())}</div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`;
if (projectUrl) if (projectUrl)
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
contractInfo.innerHTML = infoHtml; contractInfo.innerHTML = infoHtml;
+11 -1
View File
@@ -10,7 +10,6 @@ const {
attachCopyHandlers, attachCopyHandlers,
onViewLeave, onViewLeave,
formatFee, formatFee,
tokenLabel,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { const {
@@ -74,6 +73,17 @@ function tokenAmountText(rawAmount, decimals, symbol) {
}; };
} }
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
// Try to decode calldata using known ABIs. // Try to decode calldata using known ABIs.
// Returns { name, description, details } or null. // Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) { function decodeCalldata(data, toAddress) {
+24 -60
View File
@@ -13,7 +13,6 @@ const {
displaySymbol, displaySymbol,
nativeCurrency, nativeCurrency,
renderAddressHtml, renderAddressHtml,
blockieHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
onViewLeave, onViewLeave,
@@ -44,10 +43,10 @@ const {
FEE_UNAVAILABLE, FEE_UNAVAILABLE,
feeReserveWei, feeReserveWei,
feeEstimateWei, feeEstimateWei,
maxEthAmount,
validateTransfer, validateTransfer,
} = require("../../shared/txValidation"); } = require("../../shared/txValidation");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
let pendingTx = null; let pendingTx = null;
@@ -55,10 +54,6 @@ let pendingTx = null;
// filled in by estimateGas() when the estimate resolves or fails. // filled in by estimateGas() when the estimate resolves or fails.
let feeStatus = FEE_PENDING; let feeStatus = FEE_PENDING;
let feeWei = null; let feeWei = null;
// The fee fields a max ETH send is signed with: those of the estimate its
// amount was derived from. Null for any other send, which ethers prices from
// the node at signing time.
let maxSendFees = null;
function restore() { function restore() {
const d = state.viewData; const d = state.viewData;
@@ -67,6 +62,11 @@ function restore() {
} }
} }
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function confirmAddressHtml(address, ensName, title) { function confirmAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
return ( return (
@@ -82,24 +82,10 @@ function valueWithUsd(text, usdAmount) {
return text; return text;
} }
// The Amount line, with its USD value. A max ETH send's line is drawn again
// once its amount is re-derived from the fee estimate.
function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + displaySymbol(rawSymbol),
amountUsd,
);
}
function show(txInfo) { function show(txInfo) {
pendingTx = txInfo; pendingTx = txInfo;
feeStatus = FEE_PENDING; feeStatus = FEE_PENDING;
feeWei = null; feeWei = null;
maxSendFees = null;
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the // The raw symbol is the price-table key; the capped one is what the
@@ -146,11 +132,18 @@ function show(txInfo) {
); );
$("confirm-to-ens").classList.add("hidden"); $("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo); // Amount (with inline USD)
// Balance (with inline USD)
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol); const tokenPrice = getPrice(rawSymbol);
const amountNum = parseFloat(txInfo.amount);
const price = isErc20 ? tokenPrice : ethPrice;
const amountUsd = price ? amountNum * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + symbol,
amountUsd,
);
// Balance (with inline USD)
if (isErc20) { if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero // null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is // (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
@@ -387,8 +380,8 @@ async function estimateGas(txInfo) {
} }
// What the node will require to be reserved, which is what the gate // What the node will require to be reserved, which is what the gate
// must be: the send is broadcast as a type-2 transaction priced at // must be: the send pins no fee fields, so it is broadcast as a
// maxFeePerGas, which only a max ETH send pins (see below). // type-2 transaction priced at maxFeePerGas.
const gasCostWei = feeReserveWei(gasLimit, feeData); const gasCostWei = feeReserveWei(gasLimit, feeData);
if (gasCostWei === null) { if (gasCostWei === null) {
throw new Error("no usable gas price from the provider"); throw new Error("no usable gas price from the provider");
@@ -426,30 +419,6 @@ async function estimateGas(txInfo) {
} }
feeStatus = FEE_KNOWN; feeStatus = FEE_KNOWN;
feeWei = gasCostWei; feeWei = gasCostWei;
// A max ETH send is the balance minus this estimate's reserve, not the
// Send screen's, and is signed with this estimate's fee fields: fees
// fetched again at signing could exceed the reserve it leaves, and the
// node would refuse it for want of funds. Where the balance no longer
// covers the fee, the amount is left as it is and the balance check
// below says so.
if (txInfo.max && txInfo.token === "ETH") {
const amount = maxEthAmount(txInfo.balance, gasCostWei);
if (amount !== null) {
txInfo.amount = amount;
renderAmount(txInfo);
// Priced as feeReserveWei() priced the reserve: maxFeePerGas,
// or gasPrice on a network with no type-2 pricing.
if (feeData.maxFeePerGas != null) {
maxSendFees = {
gasLimit,
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
};
} else {
maxSendFees = { gasLimit, gasPrice: feeData.gasPrice };
}
}
}
renderValidation(txInfo); renderValidation(txInfo);
} catch (e) { } catch (e) {
log.errorf("gas estimation failed:", e.shortMessage || e.message); log.errorf("gas estimation failed:", e.shortMessage || e.message);
@@ -463,19 +432,18 @@ async function estimateGas(txInfo) {
} }
// Populate the transaction this send describes, enforce the fee bound against // Populate the transaction this send describes, enforce the fee bound against
// the fees that were actually filled in, then sign and broadcast it. Apart // the fees that were actually filled in, then sign and broadcast it. The send
// from a max ETH send, which passes its estimate's fee fields as `fees`, the // pins no fee fields, so ethers fills maxFeePerGas and the gas limit from what
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from // the configured RPC node answers, with nothing otherwise bounding what a
// what the configured RPC node answers, with nothing otherwise bounding what a
// hostile node can set — the dApp path's ceilings never reached this one. // hostile node can set — the dApp path's ceilings never reached this one.
// Populating before the check is what makes assertWithinCeilings() see the // Populating before the check is what makes assertWithinCeilings() see the
// same numbers that would be signed; it throws an ApprovalMismatchError when // same numbers that would be signed; it throws an ApprovalMismatchError when
// the product gasLimit × maxFeePerGas is over the bound, which the caller // the product gasLimit × maxFeePerGas is over the bound, which the caller
// shows in the reserved error area rather than sending. // shows in the reserved error area rather than sending.
async function populateVerifyAndSend(connectedSigner, tx, fees = null) { async function populateVerifyAndSend(connectedSigner, tx) {
let request; let request;
if (tx.token === "ETH") { if (tx.token === "ETH") {
request = { to: tx.to, value: parseEther(tx.amount), ...fees }; request = { to: tx.to, value: parseEther(tx.amount) };
} else { } else {
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner); const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
// The contract's decimals() is read to be COMPARED with the scale the // The contract's decimals() is read to be COMPARED with the scale the
@@ -580,11 +548,7 @@ function init(_ctx) {
const provider = getProvider(state.rpcUrl, state.networkId); const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider); const connectedSigner = signer.connect(provider);
tx = await populateVerifyAndSend( tx = await populateVerifyAndSend(connectedSigner, pendingTx);
connectedSigner,
pendingTx,
maxSendFees,
);
// Best-effort: clear decrypted secret after use. // Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but // Note: JS strings are immutable; this nulls the reference but
+13 -26
View File
@@ -12,7 +12,6 @@ const {
removeWalletFromState, removeWalletFromState,
broadcastActiveChanged, broadcastActiveChanged,
} = require("../../shared/walletDelete"); } = require("../../shared/walletDelete");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
let deleteWalletIndex = null; let deleteWalletIndex = null;
let lostPasswordIndex = null; let lostPasswordIndex = null;
@@ -21,31 +20,21 @@ let ctx = null;
// The name shown for a wallet, and on the lost-password screen the string // The name shown for a wallet, and on the lost-password screen the string
// the user has to type back. One function so the two cannot disagree: a // the user has to type back. One function so the two cannot disagree: a
// confirmation that asks for a name other than the one on screen is // confirmation that asks for a name other than the one on screen is
// unusable. A name that shows nothing at all (only spaces, or only // unusable.
// zero-width characters) is replaced by "Wallet N" for the same reason:
// there would be nothing on screen to type back.
function displayName(walletIdx) { function displayName(walletIdx) {
const wallet = state.wallets[walletIdx]; const wallet = state.wallets[walletIdx];
const name = wallet && wallet.name; return (wallet && wallet.name) || "Wallet " + (walletIdx + 1);
if (name && confirmKey(name)) return name;
return "Wallet " + (walletIdx + 1);
} }
// What the typed confirmation and the wallet name are compared as. HTML // What the typed confirmation and the wallet name are compared as. HTML
// collapses runs of whitespace when it renders the name, so a wallet named // collapses runs of whitespace when it renders the name, so a wallet named
// "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot // "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot
// see the second space and cannot type a string that matches the stored // see the second space and cannot type a string that matches the stored
// name. Characters that paint nothing, such as a zero-width space, are // name. Comparing collapsed on both sides is what keeps the confirmation
// invisible the same way and are removed first. Comparing this form on // satisfiable, on the one screen whose whole purpose is unwedging a user
// both sides is what keeps the confirmation satisfiable, on the one screen // who is already stuck. Case and surrounding space go the same way.
// whose whole purpose is unwedging a user who is already stuck. Case and
// surrounding space go the same way.
function confirmKey(name) { function confirmKey(name) {
return name return name.trim().replace(/\s+/g, " ").toLowerCase();
.replace(INVISIBLE_CHARACTERS, "")
.trim()
.replace(/\s+/g, " ")
.toLowerCase();
} }
// Drop the password from the DOM and the wallet selection from the // Drop the password from the DOM and the wallet selection from the
@@ -185,16 +174,14 @@ function init(_ctx) {
return; return;
} }
// Case, surrounding spaces, repeated inner spaces and invisible // Case, surrounding spaces and repeated inner spaces are not part
// characters are not part of the confirmation; see confirmKey(). // of the confirmation; see confirmKey(). This asks whether the
// This asks whether the user knows which wallet they are on; it is // user knows which wallet they are on; it is not a secret, and
// not a secret, and refusing "wallet 2" for "Wallet 2" would only // refusing "wallet 2" for "Wallet 2" would only teach the user to
// teach the user to distrust the control. An empty field is // distrust the control.
// refused whatever the wallet is called, so no stored name can const typed = $("delete-wallet-lost-name-input").value;
// ever be confirmed by typing nothing.
const typed = confirmKey($("delete-wallet-lost-name-input").value);
const expected = displayName(lostPasswordIndex); const expected = displayName(lostPasswordIndex);
if (typed === "" || typed !== confirmKey(expected)) { if (confirmKey(typed) !== confirmKey(expected)) {
$("delete-wallet-lost-flash").textContent = $("delete-wallet-lost-flash").textContent =
"That is not the name of this wallet. Type " + "That is not the name of this wallet. Type " +
expected + expected +
+3 -24
View File
@@ -13,12 +13,10 @@
// reasoning behind it are; it is re-exported below so views keep importing // reasoning behind it are; it is re-exported below so views keep importing
// it from here. // it from here.
const { formatEther } = require("ethers"); const { formatEther } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { const {
truncateAmountNeverZero, truncateAmountNeverZero,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { DEBUG } = require("../../shared/constants"); const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
@@ -280,17 +278,6 @@ function nativeCurrency() {
return currentNetwork().nativeCurrency; return currentNetwork().nativeCurrency;
} }
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
// A network fee in wei as the confirmation and approval screens both show it: // A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the // the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
// native currency of the network the fee is paid on, then its USD value when // native currency of the network the fee is paid on, then its USD value when
@@ -325,7 +312,7 @@ function balanceLine(symbol, amount, price, tokenId) {
const qty = amount === null ? "quantity unknown" : amount.toFixed(4); const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
const usd = const usd =
price && amount !== null price && amount !== null
? escapeHtml(formatUsd(amount * price)) || "&nbsp;" ? formatUsd(amount * price) || "&nbsp;"
: "&nbsp;"; : "&nbsp;";
// tokenId is a contract address out of the same explorer JSON, and it // tokenId is a contract address out of the same explorer JSON, and it
// lands inside a quoted attribute. // lands inside a quoted attribute.
@@ -459,11 +446,6 @@ function addressDotHtml(address) {
return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`; return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`;
} }
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
// Look up an address across all wallets and return its title // Look up an address across all wallets and return its title
// (e.g. "Address 1.2") or null if it's not one of ours. // (e.g. "Address 1.2") or null if it's not one of ours.
function addressTitle(address, wallets) { function addressTitle(address, wallets) {
@@ -510,9 +492,6 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return renderAddressHtml(address, { title, ensName, maxLen }); return renderAddressHtml(address, { title, ensName, maxLen });
} }
// A transaction's time as every screen shows it (README, Display
// Consistency): the ISO datetime, in UTC when the UTC Timestamps setting is
// on, and the relative age. Views import these two; they keep no copies.
function isoDate(timestamp) { function isoDate(timestamp) {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
@@ -712,10 +691,9 @@ module.exports = {
addressHoldsFunds, addressHoldsFunds,
unknownableAmount, unknownableAmount,
nativeCurrency, nativeCurrency,
tokenLabel,
formatFee, formatFee,
addressColor,
addressDotHtml, addressDotHtml,
blockieHtml,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
addressTitle, addressTitle,
@@ -725,6 +703,7 @@ module.exports = {
renderAddressHtml, renderAddressHtml,
copyableHtml, copyableHtml,
attachCopyHandlers, attachCopyHandlers,
etherscanAddressUrl,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
EXT_ICON, EXT_ICON,
+3 -4
View File
@@ -63,7 +63,7 @@ function renderTotalValue() {
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
if (priceEl) { if (priceEl) {
priceEl.innerHTML = ethPrice priceEl.innerHTML = ethPrice
? escapeHtml(formatUsd(ethPrice) + " USD/ETH") ? formatUsd(ethPrice) + " USD/ETH"
: "&nbsp;"; : "&nbsp;";
} }
@@ -79,8 +79,7 @@ function renderTotalValue() {
el.textContent = ethStr + ethUsd; el.textContent = ethStr + ethUsd;
if (subEl) { if (subEl) {
subEl.innerHTML = subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || "&nbsp;";
escapeHtml(formatAddressTotal(getAddressValue(addr))) || "&nbsp;";
} }
} }
@@ -281,7 +280,7 @@ function walletListHtml() {
} }
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`; html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr)); const addrTotal = formatAddressTotal(getAddressValue(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${escapeHtml(addrTotal) || "&nbsp;"}</div>`; html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`;
html += balanceLinesForAddress( html += balanceLinesForAddress(
addr, addr,
state.trackedTokens, state.trackedTokens,
+2 -114
View File
@@ -22,25 +22,10 @@ const {
truncateAmountNeverZero, truncateAmountNeverZero,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { const { getAddress } = require("ethers");
feeReserveWei,
maxEthAmount,
maxTokenAmount,
} = require("../../shared/txValidation");
const { log } = require("../../shared/log");
const { getAddress, parseEther } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
// Whether the amount field holds what Max filled in. The confirmation screen
// re-derives a max ETH amount from its own fee estimate; typing in the field
// makes it an ordinary amount again.
let amountIsMax = false;
// Counts the times the Send screen has opened, so a Max fee estimate started
// before it was last opened fills nothing in.
let sendScreenOpenings = 0;
/** /**
* Validate a destination address string. * Validate a destination address string.
* Returns { valid: true } or { valid: false, error: "..." }. * Returns { valid: true } or { valid: false, error: "..." }.
@@ -244,102 +229,9 @@ function updateSendBalance() {
} }
} }
// Fill the amount field with the most the selected holding can send: a
// token's whole balance (cut to 18 decimal places), or for ETH the exact
// balance minus the fee reserve the confirmation screen checks against, never
// the rounded balance the screen shows. Where there is nothing to fill in, a
// flash message says why.
async function fillMaxAmount() {
const addr = currentAddress();
if (!addr) return;
const token = state.selectedToken || $("send-token").value;
if (token !== "ETH") {
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
if (bal == null) {
showFlash("This token's balance is unknown.");
return;
}
const amount = maxTokenAmount(bal);
if (!(parseFloat(amount) > 0)) {
showFlash("This token's balance is zero.");
return;
}
$("send-amount").value = amount;
amountIsMax = true;
return;
}
// The fee is estimated for this recipient, as the confirmation screen
// estimates it: sending to a contract can cost more gas.
const to = $("send-to").value.trim();
if (!validateToAddress(to).valid) {
showFlash("Please enter a recipient address first.");
return;
}
const typed = $("send-amount").value;
const opening = sendScreenOpenings;
let feeWei = null;
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const [feeData, gasLimit] = await Promise.all([
provider.getFeeData(),
provider.estimateGas({
from: addr.address,
to,
value: parseEther(addr.balance || "0"),
}),
]);
feeWei = feeReserveWei(gasLimit, feeData);
} catch (e) {
log.errorf(
"max amount fee estimate failed:",
e.shortMessage || e.message,
);
}
// While the estimate was in flight the user left the screen (and perhaps
// opened it again), typed an amount, or changed the address, the holding
// or the recipient: what they did wins.
if (
state.currentView !== "send" ||
sendScreenOpenings !== opening ||
currentAddress()?.address !== addr.address ||
(state.selectedToken || $("send-token").value) !== token ||
$("send-to").value.trim() !== to ||
$("send-amount").value !== typed
) {
return;
}
if (feeWei === null) {
showFlash("The network fee could not be estimated.");
return;
}
const amount = maxEthAmount(addr.balance, feeWei);
if (amount === null) {
showFlash("Your balance does not cover the network fee.");
return;
}
$("send-amount").value = amount;
amountIsMax = true;
}
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("send-token").addEventListener("change", () => { $("send-token").addEventListener("change", updateSendBalance);
// A filled-in maximum is the maximum of the holding it was filled in
// for.
if (amountIsMax) {
$("send-amount").value = "";
amountIsMax = false;
}
updateSendBalance();
});
$("btn-send-max").addEventListener("click", fillMaxAmount);
$("send-amount").addEventListener("input", () => {
amountIsMax = false;
});
// Initial state: disable review button until address is entered // Initial state: disable review button until address is entered
$("btn-send-review").disabled = true; $("btn-send-review").disabled = true;
@@ -416,7 +308,6 @@ function init(_ctx) {
tokenSymbol: tokenSymbol, tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance, tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals, tokenDecimals: tokenDecimals,
max: amountIsMax,
}); });
}); });
@@ -427,10 +318,7 @@ function init(_ctx) {
}); });
} }
// Called each time the Send screen opens, with its fields cleared.
function resetSendValidation() { function resetSendValidation() {
sendScreenOpenings++;
amountIsMax = false;
const errorEl = $("send-to-error"); const errorEl = $("send-to-error");
const btn = $("btn-send-review"); const btn = $("btn-send-review");
if (errorEl) errorEl.textContent = ""; if (errorEl) errorEl.textContent = "";
+2 -2
View File
@@ -12,7 +12,7 @@ function isTracked(address) {
return state.trackedTokens.some((t) => t.address.toLowerCase() === lower); return state.trackedTokens.some((t) => t.address.toLowerCase() === lower);
} }
function nameAndSymbol(t) { function tokenLabel(t) {
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol; return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
} }
@@ -60,7 +60,7 @@ function renderDropdown() {
let html = '<option value="">-- select --</option>'; let html = '<option value="">-- select --</option>';
for (const t of tokens) { for (const t of tokens) {
const tracked = isTracked(t.address); const tracked = isTracked(t.address);
const label = nameAndSymbol(t) + (tracked ? " (tracked)" : ""); const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
html += html +=
`<option value="${escapeHtml(t.address)}"` + `<option value="${escapeHtml(t.address)}"` +
` data-symbol="${escapeHtml(t.symbol)}"` + ` data-symbol="${escapeHtml(t.symbol)}"` +
+6 -1
View File
@@ -13,7 +13,6 @@ const {
isoDate, isoDate,
timeAgo, timeAgo,
renderAddressHtml, renderAddressHtml,
blockieHtml,
attachCopyHandlers, attachCopyHandlers,
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
@@ -24,6 +23,7 @@ const {
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("./approval");
@@ -48,6 +48,11 @@ function getTransactionType(tx) {
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer"; return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
} }
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function txAddressHtml(address, ensName, title) { function txAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
return ( return (
+12 -1
View File
@@ -13,8 +13,8 @@ const {
explorerUrl, explorerUrl,
displaySymbol, displaySymbol,
clearViewStack, clearViewStack,
tokenLabel,
} = require("./helpers"); } = require("./helpers");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
@@ -243,6 +243,17 @@ function showSuccess(txInfo, txHash, blockNumber) {
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
} }
// The symbol shown for a decoded token line, resolved from the bundled list,
// the tokens the user tracks, and the explorer's report — the same chain the
// approval screen uses. Null when no source names one, so the line keeps
// saying `Unknown token`.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
function decodedDetailsHtml(decoded) { function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return ""; if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`; let html = `<div class="border border-border border-dashed p-2 mb-3">`;
+2
View File
@@ -33,6 +33,7 @@ const DEBUG_MNEMONIC = DEBUG
: null; : null;
const ETHEREUM_MAINNET_CHAIN_ID = "0x1"; const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com"; const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
@@ -68,6 +69,7 @@ module.exports = {
BUILD_DEBUG_MARKER, BUILD_DEBUG_MARKER,
DEBUG_MNEMONIC, DEBUG_MNEMONIC,
ETHEREUM_MAINNET_CHAIN_ID, ETHEREUM_MAINNET_CHAIN_ID,
ETHEREUM_SEPOLIA_CHAIN_ID,
DEFAULT_RPC_URL, DEFAULT_RPC_URL,
DEFAULT_BLOCKSCOUT_URL, DEFAULT_BLOCKSCOUT_URL,
BIP44_ETH_PATH, BIP44_ETH_PATH,
+7
View File
@@ -85,6 +85,12 @@ function nativeCurrencyByChainId(chainId) {
return network ? network.nativeCurrency : "ETH"; return network ? network.nativeCurrency : "ETH";
} }
// Build a block explorer link for the given path type and value.
// type: "address" | "tx" | "token" | "block"
function explorerLink(network, type, value) {
return `${network.explorerUrl}/${type}/${value}`;
}
module.exports = { module.exports = {
NETWORKS, NETWORKS,
SUPPORTED_CHAIN_IDS, SUPPORTED_CHAIN_IDS,
@@ -93,4 +99,5 @@ module.exports = {
networkById, networkById,
networkByChainId, networkByChainId,
nativeCurrencyByChainId, nativeCurrencyByChainId,
explorerLink,
}; };
+23
View File
@@ -104,6 +104,27 @@ function getAddressValue(addr) {
return { usd, partial }; return { usd, partial };
} }
// The same pair for a whole wallet, and for every wallet at once. One
// unpriced holding anywhere makes the sum a floor, so partial carries up.
function getWalletValue(wallet) {
return sumValues(wallet.addresses.map(getAddressValue));
}
function getTotalValue(wallets) {
return sumValues(wallets.map(getWalletValue));
}
function sumValues(values) {
let usd = null;
let partial = false;
for (const value of values) {
if (value.usd === null) continue;
usd = (usd === null ? 0 : usd) + value.usd;
partial = partial || value.partial;
}
return { usd, partial };
}
// The one rendering of an address total, so no screen says it differently. // The one rendering of an address total, so no screen says it differently.
// //
// A partial total is shown and named as partial: the figure is the ETH and // A partial total is shown and named as partial: the figure is the ETH and
@@ -128,4 +149,6 @@ module.exports = {
formatUsd, formatUsd,
formatAddressTotal, formatAddressTotal,
getAddressValue, getAddressValue,
getWalletValue,
getTotalValue,
}; };
+5 -12
View File
@@ -122,9 +122,9 @@ function currentNetwork() {
return networkById(state.networkId); return networkById(state.networkId);
} }
// The persisted fields as this page held them when its last loadState() // The persisted fields as they stood at the end of this page's last
// finished, or when its last successful saveState() began. saveState() diffs // loadState() or saveState(). saveState() diffs the live state against this
// the live state against this to find only the fields THIS page changed since. // to find only the fields THIS page actually changed.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -464,10 +464,7 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// does not own goes on being whatever its last loadState() saw, same as // does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current. // before this fix; only the persisted record is guaranteed current.
async function saveStateOnce() { async function saveStateOnce() {
// A copy, so what this save compares and writes is the page's state as it const current = snapshotPersisted();
// stood when the save began. A change made while it waits on storage is
// left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const result = await storageGet("autistmask"); const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written // The record in storage right now is about to be merged into and written
// back, so it is validated exactly like a load validates it. Without this, // back, so it is validated exactly like a load validates it. Without this,
@@ -524,11 +521,7 @@ async function saveStateOnce() {
// exactly as it stood; see the note above. // exactly as it stood; see the note above.
rawState.hasWallet = rawState.wallets.length > 0; rawState.hasWallet = rawState.wallets.length > 0;
// What this save compared and wrote, not the page's state now: a change baseline = structuredClone(snapshotPersisted());
// made during the save must still differ from the baseline, or the save
// queued after it finds nothing to store
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
baseline = current;
} }
// showView() calls saveState() on every navigation without awaiting it, so // showView() calls saveState() on every navigation without awaiting it, so
+2 -7
View File
@@ -20,10 +20,6 @@
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever // (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
// truncated. The ellipsis is what tells the user the name they are looking // truncated. The ellipsis is what tells the user the name they are looking
// at is not the whole name — worth knowing before they send to it. // at is not the whole name — worth knowing before they send to it.
//
// Characters are counted as code points, not UTF-16 units, so an emoji is
// one character and the cut never falls between the two halves of one: a
// half on its own renders as U+FFFD.
const MAX_SYMBOL_LENGTH = 12; const MAX_SYMBOL_LENGTH = 12;
@@ -36,9 +32,8 @@ const UNKNOWN_SYMBOL = "???";
function displaySymbol(symbol) { function displaySymbol(symbol) {
const s = symbol === null || symbol === undefined ? "" : String(symbol); const s = symbol === null || symbol === undefined ? "" : String(symbol);
if (s.length === 0) return UNKNOWN_SYMBOL; if (s.length === 0) return UNKNOWN_SYMBOL;
const chars = Array.from(s); if (s.length <= MAX_SYMBOL_LENGTH) return s;
if (chars.length <= MAX_SYMBOL_LENGTH) return s; return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
return chars.slice(0, MAX_SYMBOL_LENGTH - 1).join("") + "…";
} }
module.exports = { module.exports = {
+7 -30
View File
@@ -1,4 +1,4 @@
// Balance arithmetic for the Send and transaction confirmation screens. // Balance arithmetic for the transaction confirmation screen.
// //
// Pure: no DOM, no network, no state. Everything is exact integer math on // Pure: no DOM, no network, no state. Everything is exact integer math on
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly // 18-decimal fixed point (wei for ETH), so it can be unit tested directly
@@ -10,7 +10,7 @@
// the token balance arrive as human decimal strings, so comparing them at a // the token balance arrive as human decimal strings, so comparing them at a
// common scale is exact. // common scale is exact.
const { parseUnits, formatEther } = require("ethers"); const { parseUnits } = require("ethers");
const SCALE_DECIMALS = 18; const SCALE_DECIMALS = 18;
@@ -87,28 +87,6 @@ function toFixedPoint(value) {
} }
} }
// The most ETH a send can carry: the exact balance minus the fee reserve from
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
// exactly that reserve left behind. `ethBalance` is the exact decimal string
// balances.js stores, never a rounded one. Null when the balance does not
// leave anything to send once the fee is paid, or when either input is
// unusable.
function maxEthAmount(ethBalance, feeWei) {
const balanceWei = toFixedPoint(ethBalance);
if (balanceWei === null) return null;
if (typeof feeWei !== "bigint" || feeWei < 0n) return null;
const amountWei = balanceWei - feeWei;
if (amountWei <= 0n) return null;
return formatEther(amountWei);
}
// The most of a token a send can carry: its balance cut down, never rounded
// up, to the 18 places (SCALE_DECIMALS) an amount may have. A token can
// declare more than 18 decimals, and its balance is stored with all of them.
function maxTokenAmount(tokenBalance) {
return tokenBalance.replace(/(\.\d{18})\d+$/, "$1");
}
// Validate a pending transfer against the balances that must cover it. // Validate a pending transfer against the balances that must cover it.
// //
// isErc20 — token transfer rather than a native ETH transfer // isErc20 — token transfer rather than a native ETH transfer
@@ -161,12 +139,13 @@ function validateTransfer({
const feeFp = known ? feeWei : null; const feeFp = known ? feeWei : null;
if (isErc20) { if (isErc20) {
// Only the first 18 places of the balance are read: an amount with // A token can declare more than 18 decimals, and its balance is
// more was refused above, so the places after them cannot decide // stored with all of them. Only the first 18 places (SCALE_DECIMALS)
// whether the amount fits. // are read: an amount with more was refused above, so the places
// after them cannot decide whether the amount fits.
const tokenText = const tokenText =
typeof tokenBalance === "string" typeof tokenBalance === "string"
? maxTokenAmount(tokenBalance) ? tokenBalance.replace(/(\.\d{18})\d+$/, "$1")
: tokenBalance; : tokenBalance;
const tokenFp = toFixedPoint(tokenText) ?? 0n; const tokenFp = toFixedPoint(tokenText) ?? 0n;
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN); if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
@@ -195,8 +174,6 @@ module.exports = {
SCALE_DECIMALS, SCALE_DECIMALS,
feeReserveWei, feeReserveWei,
feeEstimateWei, feeEstimateWei,
maxEthAmount,
maxTokenAmount,
toFixedPoint, toFixedPoint,
validateTransfer, validateTransfer,
}; };
+13 -8
View File
@@ -22,6 +22,8 @@ const {
prices, prices,
clearPrices, clearPrices,
getAddressValue, getAddressValue,
getWalletValue,
getTotalValue,
formatAddressTotal, formatAddressTotal,
} = require("../src/shared/prices"); } = require("../src/shared/prices");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
@@ -134,6 +136,17 @@ describe("the value of an address, and whether it is the whole value", () => {
partial: false, partial: false,
}); });
}); });
test("one unpriced holding makes a wallet and the grand total partial", () => {
const wallet = { addresses: [FULLY_PRICED, UNPRICED_ONLY] };
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: true });
expect(getTotalValue([wallet])).toEqual({ usd: 5500, partial: true });
});
test("a wallet of fully priced addresses stays complete", () => {
const wallet = { addresses: [FULLY_PRICED, EMPTY] };
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: false });
});
}); });
describe("how that value is written on screen", () => { describe("how that value is written on screen", () => {
@@ -194,14 +207,6 @@ describe("the wallet list on Home", () => {
clearPrices(); clearPrices();
expect(walletListTotal(FULLY_PRICED)).toBe("&nbsp;"); expect(walletListTotal(FULLY_PRICED)).toBe("&nbsp;");
}); });
// A total under a cent is written "< $0.01", and the "<" is escaped
// here as the removal warning escapes it.
test("a total under a cent is escaped, as on the removal warning", () => {
const tiny = { ...EMPTY, balance: "0.000001" };
expect(walletListTotal(tiny)).toBe("Total: &lt; $0.01");
expect(removalWarningTotal(tiny)).toBe("Total: &lt; $0.01");
});
}); });
describe("the balance warning on the address-removal confirmation", () => { describe("the balance warning on the address-removal confirmation", () => {
-34
View File
@@ -31,15 +31,11 @@ const iface = new Interface(ERC20_ABI);
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e"; const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list, at 6 decimals. // In the bundled list, at 6 decimals.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// In the bundled list, at 0 decimals.
const SLP = "0xCC8Fa225D80b9c7D42F96e9570156c65D6cAAa25";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SPENDER = "0x1111111111111111111111111111111111111111"; const SPENDER = "0x1111111111111111111111111111111111111111";
// 5,000 units of a 6-decimal token, the amount from the issue. // 5,000 units of a 6-decimal token, the amount from the issue.
const FIVE_THOUSAND_AT_SIX = 5000000000n; const FIVE_THOUSAND_AT_SIX = 5000000000n;
// 5,000 units of a 0-decimal token, which are 5,000 tokens.
const FIVE_THOUSAND_AT_ZERO = 5000n;
const MAX_UINT256 = (1n << 256n) - 1n; const MAX_UINT256 = (1n << 256n) - 1n;
function transferData(amount) { function transferData(amount) {
@@ -117,21 +113,6 @@ describe("resolveTokenDecimals", () => {
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6); expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
}); });
// Zero decimals is a real scale, not a missing one, so a source that
// answers 0 is used rather than fallen past like the unusable entry above.
test("uses a bundled scale of zero", () => {
state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }];
expect(resolveTokenDecimals(SLP, state)).toBe(0);
});
test("uses a tracked scale of zero", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 0 },
];
state.wallets = walletsHolding(NOVEL_TOKEN, 18);
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(0);
});
test("refuses a scale the explorer's own entries disagree about", () => { test("refuses a scale the explorer's own entries disagree about", () => {
const wallets = walletsHolding(NOVEL_TOKEN, 6); const wallets = walletsHolding(NOVEL_TOKEN, 6);
wallets[0].addresses.push({ wallets[0].addresses.push({
@@ -232,21 +213,6 @@ describe("decodeCalldata amount", () => {
); );
}); });
test("a bundled token with zero decimals shows the true quantity", () => {
expect(amountLine(transferData(FIVE_THOUSAND_AT_ZERO), SLP)).toBe(
"5000.0000 SLP",
);
});
test("a tracked token with zero decimals shows the true quantity", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 0 },
];
expect(
amountLine(transferData(FIVE_THOUSAND_AT_ZERO), NOVEL_TOKEN),
).toBe("5000.0000 NOVEL");
});
test("the amount carried to the status screens is the same string", () => { test("the amount carried to the status screens is the same string", () => {
const decoded = decodeCalldata( const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX), transferData(FIVE_THOUSAND_AT_SIX),
+2 -89
View File
@@ -267,22 +267,9 @@ function loadBackground(options) {
lastError: null, lastError: null,
}, },
windows: { windows: {
getLastFocused: (cb) => cb(opts.lastFocused || null), getLastFocused: (cb) => cb(null),
create: (options2, cb) => { create: (options2, cb) => {
// A copy, as the browser takes it at the call: the background created.push(options2);
// reuses the object when it asks a second time.
created.push({ ...options2 });
// A browser that refuses any position it is given, as Chrome
// does for one it judges too far off screen.
if (opts.refusePosition && options2.left !== undefined) {
global.chrome.runtime.lastError = {
message:
"Invalid value for bounds. Bounds must be at least 50% within visible screen space.",
};
cb(undefined);
global.chrome.runtime.lastError = null;
return;
}
// A browser that answers with no window at all. The approval // A browser that answers with no window at all. The approval
// then has no window it can ever be answered in. // then has no window it can ever be answered in.
cb(opts.noWindow ? undefined : { id: created.length }); cb(opts.noWindow ? undefined : { id: created.length });
@@ -2729,77 +2716,3 @@ describe("removing a site in Settings disconnects it", () => {
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] }); expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
}); });
}); });
// An approval window still open is often the last focused window, and headless
// Chrome reports one as 1280x720. Centred on that, the next approval window
// lands where the browser refuses to create it, and its request failed with no
// window at all (https://git.eeqj.de/sneak/AutistMask/issues/290).
describe("where an approval window opens", () => {
test("centred on the browser window the user was last in", async () => {
const bg = loadBackground({
lastFocused: {
type: "normal",
left: 0,
top: 0,
width: 1280,
height: 720,
},
});
bg.requestSign();
await settle();
expect(bg.created).toHaveLength(1);
expect(bg.created[0]).toMatchObject({ left: 460, top: 60 });
});
test("not centred on an approval window the user was last in", async () => {
const bg = loadBackground({
lastFocused: {
type: "popup",
left: 440,
top: 0,
width: 1280,
height: 720,
},
});
bg.requestSign();
await settle();
// Centred, it would be at left 900, the position the browser refused.
expect(bg.created).toHaveLength(1);
expect(bg.created[0].left).toBeUndefined();
expect(bg.created[0].top).toBeUndefined();
});
test("placed by the browser when it refuses the centred position", async () => {
const bg = loadBackground({
refusePosition: true,
lastFocused: {
type: "normal",
left: 1500,
top: 900,
width: 400,
height: 300,
},
});
const sign = bg.requestSign();
await settle();
expect(bg.created).toHaveLength(2);
expect(bg.created[0]).toMatchObject({ left: 1520, top: 750 });
expect(bg.created[1].left).toBeUndefined();
expect(bg.created[1].top).toBeUndefined();
// The request waits on the second window rather than failing:
// closing that window is refusing the prompt.
expect(sign.result()).toBeNull();
bg.closeWindow(2);
await settle();
expect(sign.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
});
-7
View File
@@ -66,11 +66,4 @@ describe("balanceLine", () => {
expect(html).toContain("<span>1.5000</span>"); expect(html).toContain("<span>1.5000</span>");
expect(html).toContain('data-token="0xabc"'); expect(html).toContain('data-token="0xabc"');
}); });
// formatUsd() writes a value under a cent as "< $0.01".
test("escapes the USD value along with the symbol", () => {
const html = balanceLine("USDC", 0.001, 1, null);
expect(html).toContain("&lt; $0.01");
expect(html).not.toContain("< $0.01");
});
}); });
-69
View File
@@ -46,9 +46,6 @@ const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"; const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// U+200B, built from its code point so that it can be seen in this file.
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
// ------------------------------------------------------------ DOM stub // ------------------------------------------------------------ DOM stub
function makeElement(id) { function makeElement(id) {
@@ -345,72 +342,6 @@ describe("the typed confirmation", () => {
"secret-three", "secret-three",
]); ]);
}); });
// A name of only spaces compares as nothing, and so does an empty
// field. Typing nothing must still delete nothing.
test.each(["", " "])(
"typing %j deletes nothing when the name is only spaces",
async (typedValue) => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = " ";
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = typedValue;
await click("btn-delete-wallet-lost-confirm");
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
"visible",
);
expect(state.wallets).toHaveLength(3);
expect(await persistedWallets(storage)).toHaveLength(3);
},
);
// A name that shows nothing would leave nothing on screen to type
// back, so the screen names the wallet by its position instead, and
// that is what the user types.
test.each([
["spaces", " "],
["a zero-width space", ZERO_WIDTH_SPACE],
])(
"a name of only %s is shown and typed back as Wallet 2",
async (_label, storedName) => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = storedName;
await openLostPassword(deleteWallet, 1);
expect(node("delete-wallet-lost-name").textContent).toBe(
"Wallet 2",
);
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
const persisted = await persistedWallets(storage);
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-three",
]);
},
);
// A zero-width space paints nothing, so "My", a zero-width space and
// "Wallet" reads as "MyWallet", and that is all the user can type. HTML
// does not collapse it the way it collapses spaces, so it has to be
// removed explicitly.
test("a zero-width space inside the name is not part of it", async () => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = "My" + ZERO_WIDTH_SPACE + "Wallet";
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "MyWallet";
await click("btn-delete-wallet-lost-confirm");
const persisted = await persistedWallets(storage);
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-three",
]);
});
}); });
describe("deleting without the password", () => { describe("deleting without the password", () => {
-136
View File
@@ -46,7 +46,6 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { isDeepStrictEqual } = require("util");
const { const {
Transaction, Transaction,
@@ -63,10 +62,6 @@ const {
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver"); const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp"); const { startDappServer } = require("./dapp");
const { STUB_COUNTERPARTY } = require("../network"); const { STUB_COUNTERPARTY } = require("../network");
const {
STATE_SCHEMA_VERSION,
stateProblem,
} = require("../../../src/shared/stateSchema");
const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html"; const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
@@ -113,137 +108,6 @@ step("popup loads and reaches the welcome view", async (env) => {
assert(title === "AutistMask", "unexpected popup title: " + title); assert(title === "AutistMask", "unexpected popup title: " + title);
}); });
// The same check as the Chrome suite's (#418), so both browsers are held to
// the same font.
step("the popup is drawn in the monospace font it declares", async (env) => {
const font = await env.driver.execute(
"return getComputedStyle(document.body).fontFamily;",
);
// --font-mono in src/popup/styles/main.css, as the browser writes it out.
assert(
font ===
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
"the popup is drawn in " + font + ", not in --font-mono",
);
});
// The recovery screen (#361): the Chrome suite's four cases, run before any
// wallet exists for the same reason. With no wallet nothing saves on a timer,
// so no save can write a good record over the unreadable one. The last of them
// erases it, which leaves the popup on Welcome for wallet creation.
// A profile a newer build wrote: a wallet with its encrypted secret, under a
// schema version this build refuses to read.
const UNREADABLE_RECORD = {
schemaVersion: STATE_SCHEMA_VERSION + 1,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-written-by-a-newer-build",
encryptedSecret: "ciphertext-written-by-a-newer-build",
nextIndex: 1,
addresses: [{ address: STUB_COUNTERPARTY }],
},
],
};
// The whole stored record, read on the popup page.
function storedRecord(d) {
return d.executeAsync(
`const done = arguments[arguments.length - 1];
browser.storage.local.get("autistmask").then((r) => done(r.autistmask));`,
);
}
step(
"an unreadable stored record opens the popup on the recovery screen",
async (env) => {
const d = env.driver;
// The popup the first step opened saves once, as it shows Welcome.
// Stored before that save lands, the record would be written over.
const deadline = Date.now() + 15000;
for (;;) {
const stored = await storedRecord(d);
if (stored && stored.currentView === "welcome") break;
assert(
Date.now() < deadline,
"the Welcome screen's save never landed: " +
JSON.stringify(stored),
);
await sleep(100);
}
await d.executeAsync(
`const done = arguments[arguments.length - 1];
browser.storage.local.set({ autistmask: arguments[0] }).then(() => done());`,
[UNREADABLE_RECORD],
);
await d.navigate(POPUP_URL);
await d.waitVisible("#view-state-recovery");
const problem = await d.text("#state-recovery-problem");
assert(
problem === stateProblem(UNREADABLE_RECORD),
"the recovery screen names the problem as " +
JSON.stringify(problem),
);
},
);
step("Export Saved Data shows the stored record verbatim", async (env) => {
const d = env.driver;
await d.click("#btn-state-recovery-export");
await d.waitVisible("#state-recovery-blob");
const exported = await d.value("#state-recovery-blob");
assert(exported !== "", "Export Saved Data left the text box empty");
assert(
isDeepStrictEqual(JSON.parse(exported), UNREADABLE_RECORD),
"the text box does not hold the stored record: " + exported,
);
});
step("a near-miss confirmation phrase erases nothing", async (env) => {
const d = env.driver;
await d.fill("#state-recovery-reset-input", "ERASE MY WALLETS");
await d.click("#btn-state-recovery-reset");
await d.waitFor(
"the refusal on the error line",
`return document.getElementById("state-recovery-flash").textContent ===
"Type ERASE MY WALLET to confirm. Nothing was erased.";`,
);
const stored = await storedRecord(d);
assert(
isDeepStrictEqual(stored, UNREADABLE_RECORD),
"the stored record changed: " + JSON.stringify(stored),
);
});
step(
"the exact confirmation phrase erases the record and reloads into Welcome",
async (env) => {
const d = env.driver;
try {
await d.fill("#state-recovery-reset-input", "ERASE MY WALLET");
await d.click("#btn-state-recovery-reset");
// Welcome is the proof of the erase: the record still stored
// would put the recovery screen up again, and its wallet would
// open Home.
await d.waitVisible("#view-welcome");
} finally {
// Whatever failed in these four steps, wallet creation starts
// from Welcome. The record left stored would fail every step
// after this.
if (!(await d.isVisible("#view-welcome"))) {
await d.executeAsync(
`const done = arguments[arguments.length - 1];
browser.storage.local.remove("autistmask").then(() => done());`,
);
await d.navigate(POPUP_URL);
}
}
},
);
step("wallet creation through the UI reaches the main view", async (env) => { step("wallet creation through the UI reaches the main view", async (env) => {
const d = env.driver; const d = env.driver;
await d.click("#btn-welcome-add"); await d.click("#btn-welcome-add");
+5 -7
View File
@@ -256,15 +256,14 @@ const SELECTOR_DECIMALS = "0x313ce567";
// Blockscout, which is exactly the disagreement the wallet must refuse to // Blockscout, which is exactly the disagreement the wallet must refuse to
// sign over. It is read at request time, so a test flips it on the options // sign over. It is read at request time, so a test flips it on the options
// object the route was registered with — after the confirmation screen has // object the route was registered with — after the confirmation screen has
// been built — without re-registering anything. Only null or undefined means // been built — without re-registering anything.
// no override: 0 is a token with no decimal places, and is answered as one.
function ethCallResult(req, opts) { function ethCallResult(req, opts) {
const call = Array.isArray(req.params) ? req.params[0] : null; const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD; if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase(); const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase(); const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) { if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals); return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals);
} }
return ZERO_WORD; return ZERO_WORD;
} }
@@ -598,10 +597,9 @@ function traceEnabled(raw) {
* eth_estimateGas until this is cleared again. * eth_estimateGas until this is cleared again.
* @param {string[]} [opts.broadcastTransactions] every raw signed * @param {string[]} [opts.broadcastTransactions] every raw signed
* transaction handed to eth_sendRawTransaction, appended in order. * transaction handed to eth_sendRawTransaction, appended in order.
* @param {number|string|null} [opts.tokenDecimalsOverride] the scale * @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
* decimals() answers for the stub token, in place of the value Blockscout * the stub token, in place of the value Blockscout reports for it. This is
* reports for it; null for none, while 0 is a scale like any other. This * the token that lies about its scale; read at request time.
* is the token that lies about its scale; read at request time.
* @param {string} [opts.tokenSymbolOverride] what the explorer reports as * @param {string} [opts.tokenSymbolOverride] what the explorer reports as
* the stub token's symbol, in place of "E2E". This is the token whose * the stub token's symbol, in place of "E2E". This is the token whose
* symbol is markup; read at request time. * symbol is markup; read at request time.
+371 -728
View File
File diff suppressed because it is too large Load Diff
-11
View File
@@ -91,17 +91,6 @@ describe("displaySymbol", () => {
expect(displaySymbol(exact)).toBe(exact); expect(displaySymbol(exact)).toBe(exact);
}); });
// An emoji outside the Basic Multilingual Plane is two UTF-16 units.
// Cutting between them leaves half of one, which renders as U+FFFD.
test("counts an emoji as one character and never cuts one in half", () => {
expect(displaySymbol("🚀".repeat(MAX_SYMBOL_LENGTH))).toBe(
"🚀".repeat(MAX_SYMBOL_LENGTH),
);
expect(displaySymbol("🚀".repeat(20))).toBe(
"🚀".repeat(MAX_SYMBOL_LENGTH - 1) + "…",
);
});
test("substitutes a placeholder for an absent symbol", () => { test("substitutes a placeholder for an absent symbol", () => {
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL); expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL); expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);
-530
View File
@@ -1,530 +0,0 @@
// The Send screen's "Max" control
// (https://git.eeqj.de/sneak/AutistMask/issues/198).
//
// For ETH it fills in the exact balance minus the fee reserve the
// confirmation screen's balance check gates on, never the four-decimal balance
// the Send screen shows; the confirmation screen re-derives that amount from
// its own estimate, and signs with that estimate's fee fields. For a token it
// fills in the whole balance, and the check that ETH covers the fee still
// applies.
//
// Driven through the real refreshBalances(), Send screen and confirmation
// screen, Sign & Send included, with only the node, the explorer and the DOM
// stubbed.
"use strict";
// What the stub node answers, and the signed transactions it was handed.
const mockNode = {
balanceWei: 0n,
feeData: null,
broadcast: [],
};
// The token rows the stub explorer reports for the address.
const mockExplorer = { items: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
async getBalance() {
return mockNode.balanceWei;
}
async lookupAddress() {
return null;
}
async getFeeData() {
return mockNode.feeData;
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getNetwork() {
return { chainId: 1n };
}
async broadcastTransaction(signed) {
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
}
return {
...actual,
JsonRpcProvider: StubProvider,
Network: { from: () => ({}) },
};
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The explorer's token list, which refreshBalances() also fetches.
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => mockExplorer.items,
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The wait screen polls for a receipt; these tests stop at the broadcast.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
// The confirmation screen's Etherscan label lookup is the only fetch() these
// screens make; it fails, as it does offline.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// 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() {},
};
}
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 { Transaction, Wallet, formatEther } = require("ethers");
const { refreshBalances } = require("../src/shared/balances");
const { encryptWithPassword } = require("../src/shared/vault");
const { state } = require("../src/shared/state");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// A second address of the wallet, and a second recipient.
const OTHER = "0x" + "e".repeat(40);
const PASSWORD = "correct horse battery staple";
const GWEI = 1000000000n;
const GAS = 21000n;
// The Send screen shows this balance as 1.2345 ETH.
const BALANCE_WEI = 1234567890123456789n;
// A token the bundled list does not know, with enough holders to be listed.
const TOKEN = "0x" + "d".repeat(40);
// Fee data whose reserve is 21000 gas at `maxFeePerGas`. The expected cost,
// at gasPrice, is lower, as it is on mainnet.
function fees(maxFeePerGas) {
return {
maxFeePerGas,
maxPriorityFeePerGas: GWEI,
gasPrice: maxFeePerGas / 2n,
};
}
// The balance minus a reserve of 21000 gas at `maxFeePerGas`.
function maxAfter(maxFeePerGas) {
return formatEther(BALANCE_WEI - GAS * maxFeePerGas);
}
function el(id) {
return global.document.getElementById(id);
}
function text(id) {
return el(id).textContent;
}
// The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them.
async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei;
mockExplorer.items = tokenItems;
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret: await encryptWithPassword(PRIVATE_KEY, PASSWORD),
addresses: [{ address: HOLDER }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
await refreshBalances(
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
state.trackedTokens,
"mainnet",
);
}
function tokenRow(value, decimals = "18") {
return {
value: String(value),
token: {
type: "ERC-20",
address_hash: TOKEN,
symbol: "TOK",
name: "Token",
decimals,
holders_count: "50000",
},
};
}
// The confirmation screen Review leads to, once shown.
let confirmed = null;
// Open the Send screen for `token` ("ETH" or a token address), with the
// recipient entered.
function openSend(token = "ETH") {
send.init({ showConfirmTx: (info) => (confirmed = info) });
confirmTx.init({});
send.resetSendValidation();
state.currentView = "send";
state.selectedToken = token;
el("send-to").value = RECIPIENT;
el("send-amount").value = "";
}
async function pressMax() {
await el("btn-send-max").handlers.get("click")();
}
// Press Review and show the confirmation screen with its fee estimate settled.
async function review() {
await el("btn-send-review").handlers.get("click")();
confirmTx.show(confirmed);
await settle();
}
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
function canSend() {
return !el("btn-confirm-send").disabled;
}
beforeEach(() => {
elements.clear();
confirmed = null;
state.selectedToken = null;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
mockNode.feeData = fees(20n * GWEI);
mockNode.broadcast = [];
});
describe("Max on an ETH send", () => {
test("fills in the exact balance minus the fee reserve", async () => {
await refreshWith(BALANCE_WEI);
openSend();
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 1.2345 ETH");
await pressMax();
// 1.234567890123456789 - 21000 * 20 gwei.
expect(el("send-amount").value).toBe("1.234147890123456789");
});
test("leaves exactly the fee reserve behind, and the confirmation screen enables Send", async () => {
await refreshWith(BALANCE_WEI);
openSend();
await pressMax();
await review();
expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
expect(el("confirm-errors").innerHTML).toBe("");
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(canSend()).toBe(true);
});
test("re-derives the amount when the fee estimate changes", async () => {
await refreshWith(BALANCE_WEI);
openSend();
await pressMax();
expect(el("send-amount").value).toBe(maxAfter(20n * GWEI));
// The fee rises between Max and the confirmation screen's estimate.
// Kept, the Send screen's amount would be refused for want of funds.
mockNode.feeData = fees(30n * GWEI);
await review();
expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
expect(canSend()).toBe(true);
// And falls when the screen is shown again, as on reopening the popup.
mockNode.feeData = fees(10n * GWEI);
confirmTx.restore();
await settle();
expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
expect(canSend()).toBe(true);
});
test("is signed with the fee its amount leaves behind", async () => {
await refreshWith(BALANCE_WEI);
openSend();
await pressMax();
await review();
// The fee the node quotes rises after the estimate. Fetched afresh
// at signing, it would make amount plus fee more than the balance.
mockNode.feeData = fees(25n * GWEI);
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
expect(mockNode.broadcast).toHaveLength(1);
const tx = Transaction.from(mockNode.broadcast[0]);
expect(tx.to).toBe(RECIPIENT);
expect(tx.maxFeePerGas).toBe(20n * GWEI);
expect(tx.value + tx.gasLimit * tx.maxFeePerGas).toBe(BALANCE_WEI);
});
test("says so instead of filling in an amount when the balance does not cover the fee", async () => {
// 0.0001 ETH against a reserve of 0.00042 ETH.
await refreshWith(100000000000000n);
openSend();
await pressMax();
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe(
"Your balance does not cover the network fee.",
);
});
test("asks for the recipient first, since the fee depends on it", async () => {
await refreshWith(BALANCE_WEI);
openSend();
el("send-to").value = "";
await pressMax();
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe(
"Please enter a recipient address first.",
);
});
// Holds the node's fee answer, so Max's estimate is still running, until
// the returned function is called.
function holdFeeEstimate() {
let release;
mockNode.feeData = new Promise((resolve) => {
release = () => resolve(fees(20n * GWEI));
});
return release;
}
test.each([
["the same address", 0],
["another address", 1],
])(
"fills nothing in once Send was left and opened again for %s while the fee was estimated",
async (_, addressIndex) => {
await refreshWith(BALANCE_WEI);
state.wallets[0].addresses.push({
address: OTHER,
balance: "2.0",
tokenBalances: [],
});
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
// Back, then Send again as home.js opens it, with the same
// recipient typed in again.
state.selectedAddress = addressIndex;
el("send-to").value = "";
el("send-amount").value = "";
send.resetSendValidation();
el("send-to").value = RECIPIENT;
release();
await pressed;
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("");
},
);
test("fills nothing in and says nothing once Send was left while the fee was estimated", async () => {
// 0.0001 ETH, which does not cover the fee: a result that landed
// would say so on whichever screen is shown.
await refreshWith(100000000000000n);
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
state.currentView = "home";
release();
await pressed;
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("");
});
test("fills nothing in when the recipient changed while the fee was estimated", async () => {
await refreshWith(BALANCE_WEI);
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
el("send-to").value = OTHER;
release();
await pressed;
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("");
});
test("fills nothing in when the holding was changed while the fee was estimated", async () => {
await refreshWith(BALANCE_WEI, [tokenRow(10n ** 18n)]);
// Opened from the home screen, where the dropdown picks the holding.
openSend(null);
el("send-token").value = "ETH";
const release = holdFeeEstimate();
const pressed = pressMax();
el("send-token").value = TOKEN;
el("send-token").handlers.get("change")();
release();
await pressed;
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("");
});
test("keeps an amount typed while the fee was estimated", async () => {
await refreshWith(BALANCE_WEI);
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
el("send-amount").value = "0.5";
el("send-amount").handlers.get("input")();
release();
await pressed;
expect(el("send-amount").value).toBe("0.5");
expect(text("flash-msg")).toBe("");
});
test("fills in once the held fee estimate arrives with nothing changed", async () => {
await refreshWith(BALANCE_WEI);
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
release();
await pressed;
expect(el("send-amount").value).toBe(maxAfter(20n * GWEI));
});
test("typed over, is an ordinary amount the confirmation screen keeps", async () => {
await refreshWith(BALANCE_WEI);
openSend();
await pressMax();
el("send-amount").value = "0.5";
el("send-amount").handlers.get("input")();
mockNode.feeData = fees(30n * GWEI);
await review();
expect(text("confirm-amount")).toBe("0.5 ETH");
});
});
describe("Max on a token send", () => {
// 1234.567890123456789012 TOK; the Send screen shows 1234.5678.
const TOKEN_UNITS = 1234567890123456789012n;
test("fills in the whole token balance", async () => {
await refreshWith(BALANCE_WEI, [tokenRow(TOKEN_UNITS)]);
openSend(TOKEN);
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(canSend()).toBe(true);
});
test("of a token with more than 18 decimal places, fills in the balance cut down to the 18 the confirmation screen accepts", async () => {
// 1234.567890123456789012999999 TOK at 24 decimal places.
await refreshWith(BALANCE_WEI, [
tokenRow(1234567890123456789012999999n, "24"),
]);
openSend(TOKEN);
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(canSend()).toBe(true);
});
test("is still refused when ETH cannot cover the fee", async () => {
await refreshWith(0n, [tokenRow(TOKEN_UNITS)]);
openSend(TOKEN);
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(el("confirm-gas-error").style.visibility).toBe("visible");
expect(canSend()).toBe(false);
});
test("says so when the token balance is unknown", async () => {
// No scale from the explorer, the bundled list or a tracked token.
const row = tokenRow(TOKEN_UNITS);
delete row.token.decimals;
await refreshWith(BALANCE_WEI, [row]);
openSend(TOKEN);
await pressMax();
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("This token's balance is unknown.");
});
test("says so when the token balance is zero", async () => {
state.trackedTokens = [
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
];
await refreshWith(BALANCE_WEI, [tokenRow(0n)]);
openSend(TOKEN);
await pressMax();
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("This token's balance is zero.");
});
});
-77
View File
@@ -423,80 +423,3 @@ describe("two wallets independently created with a colliding identity", () => {
expect(secrets).toContain("secret-b"); expect(secrets).toContain("secret-b");
}); });
}); });
// showView() saves on every navigation without waiting, so the user can change
// something while that save is still waiting on storage. The change is followed
// by its own saveState(), which runs after the first save; it must be stored
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
describe("a change made while an earlier save from the same page is running", () => {
// Runs `change` inside the next call to `op` (the stub's get or set),
// before that call does its work.
function runInside(op, change) {
const real = op.getMockImplementation();
op.mockImplementationOnce(async (arg) => {
change();
return real(arg);
});
}
test("a network switched during the earlier save's read is stored", async () => {
const storage = makeStorageStub({
autistmask: { wallets: [W1], networkId: "sepolia" },
});
const { state, saveState, loadState } = loadPage(storage).state;
await loadState();
let queued;
runInside(storage.get, () => {
state.networkId = "mainnet";
queued = saveState();
});
state.theme = "dark";
await saveState();
await queued;
const stored = storage.read("autistmask");
expect(stored.theme).toBe("dark");
expect(stored.networkId).toBe("mainnet");
});
test("a wallet added during the earlier save's read is stored", async () => {
const storage = makeStorageStub({ autistmask: { wallets: [W1] } });
const { state, saveState, loadState } = loadPage(storage).state;
await loadState();
let queued;
runInside(storage.get, () => {
state.wallets.push(W2);
queued = saveState();
});
await saveState();
await queued;
const stored = storage.read("autistmask");
expect(stored.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
});
test("a wallet added during the earlier save's write is stored", async () => {
const storage = makeStorageStub({ autistmask: { wallets: [W1] } });
const { state, saveState, loadState } = loadPage(storage).state;
await loadState();
let queued;
runInside(storage.set, () => {
state.wallets.push(W2);
queued = saveState();
});
await saveState();
await queued;
const stored = storage.read("autistmask");
expect(stored.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
});
});
+2
View File
@@ -253,6 +253,8 @@ async function bootPopup(stored, options) {
formatUsd: () => "", formatUsd: () => "",
formatAddressTotal: () => "", formatAddressTotal: () => "",
getAddressValue: () => ({ usd: null, partial: false }), getAddressValue: () => ({ usd: null, partial: false }),
getWalletValue: () => ({ usd: null, partial: false }),
getTotalValue: () => ({ usd: null, partial: false }),
})); }));
jest.doMock("../../src/shared/balances", () => ({ jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []), fetchTokenBalances: jest.fn(async () => []),
-159
View File
@@ -1,159 +0,0 @@
// A transaction's time is written by isoDate() and timeAgo() in
// src/popup/views/helpers.js on every screen that shows one (README, Display
// Consistency; https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail
// and AddressToken used to define their own copies, so a fix to the shared pair
// would not have reached them.
//
// The pair is replaced before the views are loaded, because a view takes it
// when it loads. A view that writes the time with a copy of its own shows the
// real time instead of the replacement.
//
// Driven against a minimal DOM stub in the shape
// tests/contractCreation.test.js uses.
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The transaction detail view fetches on-chain details after drawing; an
// answer that is not ok leaves the drawn lines as they are.
debugFetch: async () => ({ ok: false }),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The history lists ask the explorer for their transactions and resolve ENS
// names for them; here the explorer answers with mockHistory and no name
// resolves.
let mockHistory = [];
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: async () => mockHistory,
}));
jest.mock("../src/shared/ens", () => ({
...jest.requireActual("../src/shared/ens"),
resolveEnsNames: async () => new Map(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const helpers = require("../src/popup/views/helpers");
jest.spyOn(helpers, "isoDate").mockReturnValue("SHARED-ISO-DATE");
jest.spyOn(helpers, "timeAgo").mockReturnValue("SHARED-TIME-AGO");
const { state } = require("../src/shared/state");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const transactionDetail = require("../src/popup/views/transactionDetail");
const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
function makeElement(id) {
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// A transaction FROM sent, as the history lists hold it.
function historyTx() {
return {
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
from: FROM,
to: RECIPIENT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
};
}
beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.wallets = [
{
name: "Main",
type: "key",
addresses: [{ address: FROM, balance: "0.0000" }],
},
];
state.trackedTokens = [];
state.viewData = {};
state.viewStack = [];
state.currentView = null;
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
});
describe.each([
["AddressDetail", "tx-list", () => addressDetail.show()],
["AddressToken", "address-token-tx-list", () => addressToken.show()],
])("a transaction history row on %s", (_name, listId, open) => {
test("shows the time written by the shared isoDate() and timeAgo()", async () => {
mockHistory = [historyTx()];
open();
// The list is drawn once the history has been fetched.
await new Promise((resolve) => setTimeout(resolve, 0));
const html = node(listId).innerHTML;
expect(html).toContain('title="SHARED-ISO-DATE"');
expect(html).toContain(">SHARED-TIME-AGO<");
});
});
test("the transaction detail view shows the time written by the shared isoDate() and timeAgo()", () => {
transactionDetail.show(historyTx());
const html = node("tx-detail-time").innerHTML;
expect(html).toContain("SHARED-ISO-DATE");
expect(html).toContain("(SHARED-TIME-AGO)");
});
-68
View File
@@ -6,8 +6,6 @@ const {
FEE_UNAVAILABLE, FEE_UNAVAILABLE,
feeReserveWei, feeReserveWei,
feeEstimateWei, feeEstimateWei,
maxEthAmount,
maxTokenAmount,
toFixedPoint, toFixedPoint,
validateTransfer, validateTransfer,
} = require("../src/shared/txValidation"); } = require("../src/shared/txValidation");
@@ -308,72 +306,6 @@ describe("feeEstimateWei", () => {
}); });
}); });
// The amount the Send screen's Max fills in for ETH: the exact balance, as
// balances.js stores it, minus the fee reserve. Never the four-decimal balance
// the Send screen shows.
describe("maxEthAmount", () => {
// The Send screen shows this balance as 1.2345.
const BALANCE = "1.234567890123456789";
test("is the exact balance minus the fee, to the wei", () => {
expect(maxEthAmount(BALANCE, FEE)).toBe("1.234147890123456789");
expect(
parseEther(BALANCE) - parseEther(maxEthAmount(BALANCE, FEE)),
).toBe(FEE);
});
test("passes validateTransfer with exactly the fee left behind", () => {
const r = validateTransfer({
isErc20: false,
amount: maxEthAmount(BALANCE, FEE),
ethBalance: BALANCE,
feeStatus: FEE_KNOWN,
feeWei: FEE,
});
expect(r).toEqual({ canSend: true, codes: [] });
});
test("is one wei when the balance is one wei more than the fee", () => {
expect(maxEthAmount("0.000420000000000001", FEE)).toBe(
"0.000000000000000001",
);
});
test("is null when the balance does not cover the fee", () => {
expect(maxEthAmount("0.0001", FEE)).toBe(null);
expect(maxEthAmount("0.0", FEE)).toBe(null);
});
test("is null when the balance covers the fee and nothing more", () => {
expect(maxEthAmount("0.00042", FEE)).toBe(null);
});
test("is null on a balance or fee it cannot do exact arithmetic on", () => {
expect(maxEthAmount(undefined, FEE)).toBe(null);
expect(maxEthAmount("not a number", FEE)).toBe(null);
expect(maxEthAmount(BALANCE, null)).toBe(null);
expect(maxEthAmount(BALANCE, -1n)).toBe(null);
expect(maxEthAmount(BALANCE, 420000000000000)).toBe(null);
});
});
// The amount the Send screen's Max fills in for a token.
describe("maxTokenAmount", () => {
test("cuts a balance with more than 18 places down, never up", () => {
const amount = maxTokenAmount("1234.567890123456789012999999");
expect(amount).toBe("1234.567890123456789012");
expect(toFixedPoint(amount)).not.toBe(null);
});
test("leaves a balance with 18 places or fewer as it is", () => {
expect(maxTokenAmount("0.123456789012345678")).toBe(
"0.123456789012345678",
);
expect(maxTokenAmount("1.5")).toBe("1.5");
expect(maxTokenAmount("100")).toBe("100");
});
});
// Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does. // Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does.
// Each of these previously returned { canSend: true, codes: [] } — counting no // Each of these previously returned { canSend: true, codes: [] } — counting no
// fee at all, on a full-balance send, in the direction that lets money out. // fee at all, on a full-balance send, in the direction that lets money out.