Compare commits
13
Commits
a4cc127cbc
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e590b83df0 | ||
|
|
a0360a7874 | ||
|
|
9776f62f28 | ||
|
|
9bd607b411 | ||
|
|
5d26283cd0 | ||
|
|
d0bbb3d9eb | ||
|
|
35125db6d1 | ||
|
|
eec3e23099 | ||
|
|
0af8b09305 | ||
|
|
83b169d991 | ||
|
|
6fece80afd | ||
|
|
6613a1f6bc | ||
|
|
a207ac70bd |
@@ -2,4 +2,3 @@ node_modules/
|
||||
yarn.lock
|
||||
dist/
|
||||
release/
|
||||
.claude/
|
||||
|
||||
@@ -342,6 +342,11 @@ fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
|
||||
offline; unrecognised outbound requests are reported as failures rather than
|
||||
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
|
||||
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
|
||||
@@ -371,6 +376,12 @@ reserve while sitting on the same side of the estimate, so swapping the two in
|
||||
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
|
||||
previously correct by reading only.
|
||||
|
||||
It also covers both ways the wait for a sent transaction's receipt ends on the
|
||||
error screen: lookups that still find no receipt 60 seconds after the broadcast,
|
||||
and six lookups in a row that fail. Each must show its own message, and Done
|
||||
must lead back to the address screen. Both wait in real time, about a minute
|
||||
each.
|
||||
|
||||
It also covers the **dApp approval round trips** — the one place where the
|
||||
content script, the inpage provider, the background worker and the approval
|
||||
popup all have to work together. A local test page is served by the route
|
||||
@@ -381,11 +392,13 @@ handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
|
||||
the runner and compared against the active address, the transaction assertions
|
||||
run against the raw signed transaction captured at `eth_sendRawTransaction`
|
||||
rather than against anything the extension reported, rejecting each prompt is
|
||||
required to return a rejection to the page rather than hang or resolve, and the
|
||||
password is required to be absent from every message the approval window sends
|
||||
to the background — with the message that would carry it required to be present,
|
||||
so that check cannot pass by observing nothing. That last one is the standing
|
||||
floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
|
||||
required to return a rejection to the page rather than hang or resolve, a prompt
|
||||
raised while another approval window has focus is required to open a window of
|
||||
its own, and the password is required to be absent from every message the
|
||||
approval window sends to the background — with the message that would carry it
|
||||
required to be present, so that check cannot pass by observing nothing. That
|
||||
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
|
||||
throughout, so this is **not** a real dApp against a real network with real
|
||||
@@ -462,10 +475,11 @@ 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
|
||||
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
|
||||
It covers popup load, wallet creation through the UI, the Add Token screen, and
|
||||
the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
|
||||
`eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
|
||||
— driven through the real content script, background page and approval windows.
|
||||
It covers popup load, the StateRecovery screen (the same cases as the Chrome
|
||||
suite), wallet creation through the UI, the Add Token screen, and the four dApp
|
||||
round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and
|
||||
a closed approval window rejecting with EIP-1193 4001 — driven through the real
|
||||
content script, background page and approval windows.
|
||||
|
||||
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
|
||||
@@ -619,12 +633,9 @@ 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
|
||||
blocks a merge is Gitea branch protection, which this repo does not configure.
|
||||
|
||||
That is not only a statement about configuration. A report of the Chrome suite
|
||||
**failing under load** is still open:
|
||||
[#290](https://git.eeqj.de/sneak/AutistMask/issues/290), runs on a busy machine
|
||||
failing with `the extension opened no approval window within 30000ms`. 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
|
||||
That is not only a statement about configuration. No report of the Chrome suite
|
||||
**failing under load** is open now, but it has failed that way before, so a red
|
||||
`e2e-chrome` is read before it is believed. Do not answer one 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
|
||||
@@ -635,7 +646,9 @@ suite outright if its network interception is not in effect.
|
||||
Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it
|
||||
the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
|
||||
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
|
||||
Firefox and geckodriver downloads the same way.
|
||||
Firefox and geckodriver downloads the same way. The `e2e-chrome` figures predate
|
||||
the two cases that wait for a receipt to end in error, which add about two
|
||||
minutes of real waiting.
|
||||
|
||||
### Element id guard (part of `make check`)
|
||||
|
||||
@@ -801,13 +814,15 @@ discoverable.
|
||||
on critical screens and when space is available to allow users to disambiguate
|
||||
addresses visually, as a security feature.
|
||||
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
|
||||
styling. Tailwind is configured with a minimal monochrome palette. This keeps
|
||||
the styling co-located with the markup and eliminates CSS file management. The
|
||||
handful of classes in `styles/main.css` are not styling: `.copy-flash-*`
|
||||
carries the copy feedback animation, and `.am-address` carries the rule that
|
||||
an address never wraps. Both are invariants that hold in every place they
|
||||
appear, and spelling either out as repeated utilities is how one of those
|
||||
places drifts away from the rest.
|
||||
styling, and no `style="..."` attributes, which the
|
||||
[Content Security Policy](#content-security-policy) refuses. Tailwind is
|
||||
configured with a minimal monochrome palette. This keeps the styling
|
||||
co-located with the markup and eliminates CSS file management. The handful of
|
||||
classes in `styles/main.css` are not styling: `.copy-flash-*` carries the copy
|
||||
feedback animation, and `.am-address` carries the rule that an address never
|
||||
wraps. Both are invariants that hold in every place they appear, and spelling
|
||||
either out as repeated utilities is how one of those places drifts away from
|
||||
the rest.
|
||||
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
|
||||
enough that vanilla JS with simple view switching is sufficient. A framework
|
||||
would add bundle size, build complexity, and attack surface for no benefit at
|
||||
@@ -841,10 +856,12 @@ something when you click it.
|
||||
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
|
||||
USDT") in balance lists, transaction lists, send confirmations, and approval
|
||||
screens. Timestamps include both an ISO datetime and a humanized relative age
|
||||
wherever shown. If a formatting rule applies in one place, it applies in every
|
||||
place. Users should never see the same value rendered differently on two
|
||||
screens.
|
||||
screens. A transaction's time includes both an ISO datetime and a humanized
|
||||
relative age, written by `isoDate()` and `timeAgo()` in
|
||||
`src/popup/views/helpers.js` on every screen that shows one; the ISO datetime is
|
||||
in UTC when the UTC Timestamps setting is on. If a formatting rule applies in
|
||||
one place, it applies in every place. Users should never see the same value
|
||||
rendered differently on two screens.
|
||||
|
||||
The native token's label is a network's `nativeCurrency` in
|
||||
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
|
||||
@@ -1467,6 +1484,17 @@ view would leave a wallet one click from deletion.
|
||||
- Amount input with current balance display, which reads
|
||||
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
|
||||
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
|
||||
- **Transitions**:
|
||||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||||
@@ -1483,7 +1511,14 @@ view would leave a wallet one click from deletion.
|
||||
- Token contract: full address + etherscan link (ERC-20 only)
|
||||
- From: blockie + color dot + full address + etherscan link + wallet title
|
||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||
- Amount: value + symbol (USD in parentheses)
|
||||
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
|
||||
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)`
|
||||
for a token whose scale is unknown
|
||||
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
||||
@@ -1940,7 +1975,9 @@ view would leave a wallet one click from deletion.
|
||||
`eth_sendTransaction` arriving while one is unanswered is refused with
|
||||
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
|
||||
one is answered.
|
||||
one is answered. The window is centred on the browser window the user was last
|
||||
in; if that was another approval window, or the browser refuses the centred
|
||||
position, the browser picks the position.
|
||||
- **Elements**:
|
||||
- "Transaction Request" heading
|
||||
- Phishing warning banner (shown when the hostname is on the phishing
|
||||
@@ -2178,7 +2215,7 @@ a bare string in `manifest/firefox.json` (MV2):
|
||||
|
||||
```
|
||||
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self';
|
||||
style-src 'self' 'unsafe-inline'; img-src 'self' data:;
|
||||
style-src 'self'; img-src 'self' data:;
|
||||
connect-src 'self' https: http:; frame-src 'none'; form-action 'none';
|
||||
base-uri 'none'
|
||||
```
|
||||
@@ -2190,15 +2227,17 @@ wallet's own UI. Escaping is the primary fix for that (see
|
||||
`src/shared/html.js`); this is the second line, so an escape that does slip
|
||||
cannot reach the network.
|
||||
|
||||
Four directives are looser than `'self'`, each for a reason that does not
|
||||
`style-src 'self'` admits the stylesheet and nothing inline: both browsers
|
||||
refuse a `style="..."` attribute and a `<style>` block. So the popup's markup,
|
||||
in `src/popup/index.html` and in the HTML the view helpers build, carries
|
||||
Tailwind classes and never a `style` attribute. Script that sets `element.style`
|
||||
is not affected; that is how the views show and hide their error lines. An
|
||||
inline style that slips in anyway is refused with a console error, which fails
|
||||
both end-to-end suites.
|
||||
|
||||
These directives differ from a plain `'self'`, each for a reason that does not
|
||||
generalise:
|
||||
|
||||
- `style-src 'unsafe-inline'` — `src/popup/index.html` and the view helpers set
|
||||
presentation through `style="..."` attributes, which CSP blocks without this.
|
||||
Chrome enforces `style-src` on attributes, not only on `<style>` blocks, and
|
||||
Firefox has never implemented `style-src-attr`, so there is no narrower
|
||||
spelling that works on both targets. It permits inline **style**; script stays
|
||||
under `script-src`, which does not allow `'unsafe-inline'`.
|
||||
- `img-src data:` — identicons are generated in the popup by
|
||||
`ethereum-blockies-base64` and assigned to `img.src` as `data:` PNGs.
|
||||
- `connect-src https: http:` — the RPC endpoint is user-configurable and a local
|
||||
|
||||
@@ -45,6 +45,147 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-05: The popup's Content Security Policy no longer allows inline style
|
||||
([#328](https://git.eeqj.de/sneak/AutistMask/issues/328)): `style-src` is
|
||||
`'self'` in both manifests, pinned in `tests/manifest.test.js`. The 42
|
||||
`style="..."` attributes in `src/popup/index.html` and in the markup the view
|
||||
helpers build are now Tailwind classes, each computing to the value it
|
||||
replaced. The 16 address dot colours are written out as whole classes, because
|
||||
Tailwind builds only the classes it finds in the source. The Settings debug
|
||||
well is shown and hidden with the `hidden` class, since clearing an inline
|
||||
`display` no longer uncovers it. Script that sets `element.style` is
|
||||
unaffected.
|
||||
|
||||
- 2026-10-05: `.prettierignore` no longer lists an AI vendor's tool directory
|
||||
([#363](https://git.eeqj.de/sneak/AutistMask/issues/363)). The directory is
|
||||
not tracked, so the line ignored nothing.
|
||||
|
||||
- 2026-10-05: The Chrome end-to-end suite drives both ways the wait for a
|
||||
transaction's receipt ends on the error screen
|
||||
([#315](https://git.eeqj.de/sneak/AutistMask/issues/315)): lookups that still
|
||||
find no receipt 60 seconds after the broadcast end it with the timeout
|
||||
message, and six lookups that fail in a row end it with the message naming the
|
||||
unreachable network. Done then returns to the address screen. Both cases wait
|
||||
in real time, about a minute each. Playwright's clock would apply to every
|
||||
later test in the run and cannot be removed, and moving the stored broadcast
|
||||
time back can be undone by the save the popup makes every ten seconds.
|
||||
|
||||
- 2026-10-05: The Chrome end-to-end suite covers the last of the
|
||||
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
|
||||
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) items
|
||||
([#295](https://git.eeqj.de/sneak/AutistMask/issues/295)): a token added on
|
||||
Add Token by its contract address is listed on the address screen;
|
||||
TransactionDetail opened from the token screen leaves that screen on the
|
||||
persisted navigation stack, and Back returns to it; and the token contract row
|
||||
links to the explorer's token page, read off the link rather than followed.
|
||||
The network stub answers `symbol()` and `name()` for the stub token, which
|
||||
adding it reads.
|
||||
|
||||
- 2026-10-05: Each control that leads to a signature or to the private key has a
|
||||
test that it refuses a defective wallet before asking for a password
|
||||
([#254](https://git.eeqj.de/sneak/AutistMask/issues/254)): Send on the main,
|
||||
address and token screens, Export Private Key, and both approval screens, as
|
||||
drawn and as clicked. Send on the confirmation screen refuses it too now,
|
||||
because the popup reopens onto that screen from a saved view. The comments
|
||||
that said the wallet's key cannot be derived now say that
|
||||
`getSignerForAddress` refuses it, and the module comment in
|
||||
`src/shared/walletDefects.js` names both earlier import paths.
|
||||
|
||||
- 2026-10-05: The Chrome end-to-end suite drives the private key export screen
|
||||
as it drives the recovery phrase screen
|
||||
([#253](https://git.eeqj.de/sneak/AutistMask/issues/253)): the correct
|
||||
password shows the key, leaving by the settings gear empties the screen, and
|
||||
leaving while the password is still being checked never puts the key on it.
|
||||
The cases use the imported key wallet rather than the HD one. Leaving drops
|
||||
the address the screen was showing, and an HD wallet's key cannot be derived
|
||||
without it, so on an HD wallet a late decrypt fails by itself and would never
|
||||
exercise the check that discards it. The screen cannot yet be opened twice in
|
||||
one popup session ([#460](https://git.eeqj.de/sneak/AutistMask/issues/460)),
|
||||
so the cases reopen the popup before the second open.
|
||||
|
||||
- 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
|
||||
|
||||
+3
-1
@@ -240,7 +240,9 @@ 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
|
||||
that survives the spam filters).
|
||||
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
|
||||
4. Enter the amount.
|
||||
4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18
|
||||
decimal places, or your ETH balance minus the amount reserved for the network
|
||||
fee.
|
||||
5. Click "Review" to see the confirmation screen.
|
||||
|
||||
The confirmation screen shows:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"permissions": ["storage", "activeTab", "alarms"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'"
|
||||
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Minimal Ethereum wallet for Firefox",
|
||||
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
||||
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
|
||||
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
|
||||
+22
-3
@@ -437,7 +437,13 @@ async function openApprovalWindow(id) {
|
||||
width: popupWidth,
|
||||
height: popupHeight,
|
||||
};
|
||||
if (currentWin) {
|
||||
// Centred on a browser window only. The last focused window can be
|
||||
// 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(
|
||||
currentWin.left + (currentWin.width - popupWidth) / 2,
|
||||
);
|
||||
@@ -455,10 +461,23 @@ async function openApprovalWindow(id) {
|
||||
win = await windowsCreate(opts);
|
||||
} catch (e) {
|
||||
// The promise namespace reports the failure by rejecting where the
|
||||
// callback namespace reported it by handing back no window; both land
|
||||
// on the !win branch below, which settles the approval.
|
||||
// callback namespace reported it by handing back no window; both
|
||||
// leave win null.
|
||||
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];
|
||||
if (!approval) {
|
||||
|
||||
+50
-90
@@ -6,7 +6,10 @@
|
||||
<title>AutistMask</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body class="bg-bg text-fg font-mono text-sm">
|
||||
<!-- Chrome gives extension pages a stylesheet of its own that sets the
|
||||
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">
|
||||
<!-- ============ GLOBAL TITLE BAR ============ -->
|
||||
<div
|
||||
@@ -107,8 +110,7 @@
|
||||
</div>
|
||||
<div
|
||||
id="add-wallet-phrase-warning"
|
||||
class="text-xs mb-2 border border-border border-dashed p-2"
|
||||
style="visibility: hidden"
|
||||
class="text-xs mb-2 border border-border border-dashed p-2 invisible"
|
||||
>
|
||||
Write these words down and keep them safe. Anyone with
|
||||
them can take your funds; if you lose them, your wallet
|
||||
@@ -259,10 +261,7 @@
|
||||
|
||||
<!-- recent transactions across all addresses -->
|
||||
<div>
|
||||
<div
|
||||
class="font-bold bg-section py-1 px-2"
|
||||
style="margin-left: -0.5rem; margin-right: -0.5rem"
|
||||
>
|
||||
<div class="font-bold bg-section py-1 px-2 -mx-2">
|
||||
Recent Transactions
|
||||
</div>
|
||||
<div id="home-tx-list">
|
||||
@@ -270,7 +269,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-1" style="margin: 0 -0.5rem"> </div>
|
||||
<div class="py-1 -mx-2"> </div>
|
||||
|
||||
<div class="text-xs text-muted">
|
||||
<span
|
||||
@@ -406,8 +405,7 @@
|
||||
</p>
|
||||
<div
|
||||
id="export-privkey-flash"
|
||||
class="text-xs mb-2 min-h-[1.25rem]"
|
||||
style="visibility: hidden"
|
||||
class="text-xs mb-2 min-h-[1.25rem] invisible"
|
||||
></div>
|
||||
<div id="export-privkey-password-section" class="mb-2">
|
||||
<label class="block mb-1">Password</label>
|
||||
@@ -539,8 +537,7 @@
|
||||
/>
|
||||
<div
|
||||
id="send-to-error"
|
||||
class="text-xs"
|
||||
style="min-height: 1.25rem; color: #cc0000"
|
||||
class="text-xs min-h-[1.25rem] text-[#cc0000]"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
@@ -551,12 +548,20 @@
|
||||
class="text-xs text-muted"
|
||||
></span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="send-amount"
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||
placeholder="0.0"
|
||||
/>
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
id="send-amount"
|
||||
class="border border-border p-1 flex-1 min-w-0 font-mono text-sm bg-bg text-fg"
|
||||
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>
|
||||
<button
|
||||
id="btn-send-review"
|
||||
@@ -608,7 +613,7 @@
|
||||
<div class="text-xs text-muted mb-1">Your balance</div>
|
||||
<div id="confirm-balance" class="text-xs"></div>
|
||||
</div>
|
||||
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
|
||||
<div id="confirm-fee" class="mb-3 invisible">
|
||||
<div class="text-xs text-muted mb-1">Network fee</div>
|
||||
<div id="confirm-fee-amount" class="text-xs"></div>
|
||||
<!-- Holds its one line of space from the first paint, so
|
||||
@@ -616,22 +621,13 @@
|
||||
nothing. The placeholder is never seen. -->
|
||||
<div
|
||||
id="confirm-fee-reserve"
|
||||
class="text-xs text-muted"
|
||||
style="visibility: hidden"
|
||||
class="text-xs text-muted invisible"
|
||||
>
|
||||
reserve pending
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="confirm-warnings"
|
||||
class="mb-2"
|
||||
style="visibility: hidden"
|
||||
></div>
|
||||
<div
|
||||
id="confirm-recipient-warning"
|
||||
class="mb-2"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
<div id="confirm-warnings" class="mb-2 invisible"></div>
|
||||
<div id="confirm-recipient-warning" class="mb-2 invisible">
|
||||
<div
|
||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||
>
|
||||
@@ -644,14 +640,9 @@
|
||||
in confirmTx.js sets it. -->
|
||||
<div
|
||||
id="confirm-contract-warning"
|
||||
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||
style="visibility: hidden"
|
||||
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
|
||||
></div>
|
||||
<div
|
||||
id="confirm-burn-warning"
|
||||
class="mb-2"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
<div id="confirm-burn-warning" class="mb-2 invisible">
|
||||
<div
|
||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||
>
|
||||
@@ -659,11 +650,7 @@
|
||||
here are permanently destroyed and cannot be recovered.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="confirm-etherscan-warning"
|
||||
class="mb-2"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
<div id="confirm-etherscan-warning" class="mb-2 invisible">
|
||||
<div
|
||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||
>
|
||||
@@ -673,13 +660,11 @@
|
||||
</div>
|
||||
<div
|
||||
id="confirm-errors"
|
||||
class="mb-2 border border-border border-dashed p-2"
|
||||
style="visibility: hidden; min-height: 1.25rem"
|
||||
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
|
||||
></div>
|
||||
<div
|
||||
id="confirm-amount-fee-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||
style="visibility: hidden"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||
>
|
||||
Your balance does not cover this amount plus the network
|
||||
fee. Please go back and send a smaller amount.
|
||||
@@ -688,15 +673,13 @@
|
||||
in confirmTx.js sets it. -->
|
||||
<div
|
||||
id="confirm-gas-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||
style="visibility: hidden"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||
></div>
|
||||
<!-- Its sentence names why the fee could not be estimated,
|
||||
so show() in confirmTx.js sets it. -->
|
||||
<div
|
||||
id="confirm-fee-unknown-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||
style="visibility: hidden"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||
></div>
|
||||
<div class="mb-2">
|
||||
<label class="block mb-1 text-xs">Password</label>
|
||||
@@ -708,8 +691,7 @@
|
||||
</div>
|
||||
<div
|
||||
id="confirm-tx-password-error"
|
||||
class="text-xs mb-2 min-h-[1.25rem]"
|
||||
style="visibility: hidden"
|
||||
class="text-xs mb-2 min-h-[1.25rem] invisible"
|
||||
></div>
|
||||
<button
|
||||
id="btn-confirm-send"
|
||||
@@ -824,8 +806,7 @@
|
||||
</button>
|
||||
<div
|
||||
id="receive-erc20-warning"
|
||||
class="text-xs border border-border border-dashed p-2 mt-3"
|
||||
style="visibility: hidden"
|
||||
class="text-xs border border-border border-dashed p-2 mt-3 invisible"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@@ -853,8 +834,7 @@
|
||||
</div>
|
||||
<div
|
||||
id="add-token-info"
|
||||
class="text-xs text-muted mb-2 min-h-[1.25rem]"
|
||||
style="visibility: hidden"
|
||||
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
|
||||
></div>
|
||||
<div class="mb-2">
|
||||
<label class="block mb-1 text-xs text-muted"
|
||||
@@ -1040,8 +1020,7 @@
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
id="settings-dust-threshold"
|
||||
class="border border-border p-1 text-xs bg-bg text-fg"
|
||||
style="width: 10ch"
|
||||
class="border border-border p-1 text-xs bg-bg text-fg w-[10ch]"
|
||||
/>
|
||||
<span class="text-xs text-muted">gwei</span>
|
||||
</div>
|
||||
@@ -1118,8 +1097,7 @@
|
||||
|
||||
<div
|
||||
id="settings-debug-well"
|
||||
class="bg-well p-3 mx-1 mb-3"
|
||||
style="display: none"
|
||||
class="bg-well p-3 mx-1 mb-3 hidden"
|
||||
>
|
||||
<h3 class="font-bold mb-1">Debug</h3>
|
||||
<label
|
||||
@@ -1147,8 +1125,7 @@
|
||||
</p>
|
||||
<div
|
||||
id="delete-wallet-flash"
|
||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
||||
style="visibility: hidden"
|
||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
||||
></div>
|
||||
<div class="mb-2">
|
||||
<label class="block mb-1">Password</label>
|
||||
@@ -1221,8 +1198,7 @@
|
||||
</div>
|
||||
<div
|
||||
id="delete-wallet-lost-flash"
|
||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
||||
style="visibility: hidden"
|
||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
||||
></div>
|
||||
<button
|
||||
id="btn-delete-wallet-lost-confirm"
|
||||
@@ -1277,8 +1253,7 @@
|
||||
</p>
|
||||
<div
|
||||
id="delete-address-flash"
|
||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
||||
style="visibility: hidden"
|
||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
||||
></div>
|
||||
<button
|
||||
id="btn-delete-address-confirm"
|
||||
@@ -1307,8 +1282,7 @@
|
||||
</div>
|
||||
<div
|
||||
id="show-phrase-flash"
|
||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
||||
style="visibility: hidden"
|
||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
||||
></div>
|
||||
<div id="show-phrase-password-section" class="mb-2">
|
||||
<label class="block mb-1">Password</label>
|
||||
@@ -1390,8 +1364,7 @@
|
||||
/>
|
||||
<div
|
||||
id="settings-addtoken-info"
|
||||
class="text-xs text-muted mt-1 min-h-[1.25rem]"
|
||||
style="visibility: hidden"
|
||||
class="text-xs text-muted mt-1 min-h-[1.25rem] invisible"
|
||||
></div>
|
||||
<button
|
||||
id="btn-settings-addtoken-manual"
|
||||
@@ -1624,8 +1597,7 @@
|
||||
</div>
|
||||
<div
|
||||
id="approve-tx-error"
|
||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
|
||||
style="visibility: hidden"
|
||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem] invisible"
|
||||
></div>
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
@@ -1661,15 +1633,7 @@
|
||||
|
||||
<div
|
||||
id="approve-sign-danger-warning"
|
||||
class="mb-3 p-2 text-xs font-bold"
|
||||
style="
|
||||
visibility: hidden;
|
||||
min-height: 1.25rem;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 2px solid #dc2626;
|
||||
border-radius: 6px;
|
||||
"
|
||||
class="mb-3 p-2 text-xs font-bold invisible min-h-[1.25rem] bg-[#fee2e2] text-[#991b1b] border-2 border-[#dc2626] rounded-[6px]"
|
||||
></div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -1686,8 +1650,7 @@
|
||||
<div class="text-xs text-muted mb-1">Message</div>
|
||||
<div
|
||||
id="approve-sign-message"
|
||||
class="text-xs break-all"
|
||||
style="max-height: 12rem; overflow-y: auto"
|
||||
class="text-xs break-all max-h-48 overflow-y-auto"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@@ -1695,8 +1658,7 @@
|
||||
<div class="text-xs text-muted mb-1">Raw data</div>
|
||||
<div
|
||||
id="approve-sign-hex"
|
||||
class="text-xs break-all"
|
||||
style="max-height: 6rem; overflow-y: auto"
|
||||
class="text-xs break-all max-h-24 overflow-y-auto"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@@ -1710,8 +1672,7 @@
|
||||
</div>
|
||||
<div
|
||||
id="approve-sign-error"
|
||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
|
||||
style="visibility: hidden"
|
||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem] invisible"
|
||||
></div>
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
@@ -1835,8 +1796,7 @@
|
||||
</div>
|
||||
<div
|
||||
id="state-recovery-flash"
|
||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
||||
style="visibility: hidden"
|
||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
||||
></div>
|
||||
<button
|
||||
id="btn-state-recovery-reset"
|
||||
|
||||
@@ -11,6 +11,8 @@ const {
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
pushCurrentView,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
||||
@@ -31,8 +33,8 @@ const { walletDefect } = require("../../shared/walletDefects");
|
||||
|
||||
// The defect of the wallet the selected address belongs to, or null. Both the
|
||||
// send and the private-key export path check it before asking for a password,
|
||||
// so a wallet that cannot derive its keys says so instead of failing after the
|
||||
// user has typed one in.
|
||||
// so a wallet whose key getSignerForAddress refuses says so instead of failing
|
||||
// after the user has typed one in.
|
||||
function selectedWalletDefect() {
|
||||
if (state.selectedWallet === null) return null;
|
||||
return walletDefect(state.wallets[state.selectedWallet]);
|
||||
@@ -64,7 +66,7 @@ function show() {
|
||||
$("address-line").dataset.full = addr.address;
|
||||
attachCopyHandlers($("address-line"));
|
||||
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
||||
$("address-usd-total").innerHTML = usdTotal || " ";
|
||||
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
|
||||
const ensEl = $("address-ens");
|
||||
// ENS is now shown inside renderAddressHtml, hide the separate element
|
||||
ensEl.classList.add("hidden");
|
||||
@@ -88,62 +90,6 @@ function show() {
|
||||
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 ensNameMap = new Map();
|
||||
@@ -235,10 +181,10 @@ function renderTransactions(txs) {
|
||||
// it on the line above rather than replacing it.
|
||||
const nameStr = escapeHtml(title || ensName || "");
|
||||
const err = tx.isError ? " (failed)" : "";
|
||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||
const opacity = tx.isError ? " opacity-50" : "";
|
||||
const ago = escapeHtml(timeAgo(tx.timestamp));
|
||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
||||
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
|
||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
||||
html += `</div>`;
|
||||
@@ -313,9 +259,10 @@ function init(_ctx) {
|
||||
$("btn-export-privkey").addEventListener("click", () => {
|
||||
moreDropdown.classList.add("hidden");
|
||||
moreBtn.classList.remove("bg-fg", "text-bg");
|
||||
// There is no private key to export for an address this wallet
|
||||
// cannot derive. Without this the export screen would take a
|
||||
// password and then report it as wrong.
|
||||
// This address's private key can be derived from the stored key,
|
||||
// but export goes through getSignerForAddress, which refuses a key
|
||||
// that is not a master key. Without this the export screen would
|
||||
// take a password and then report that refusal as a wrong password.
|
||||
const defect = selectedWalletDefect();
|
||||
if (defect) {
|
||||
showFlash(defect.shortMessage);
|
||||
|
||||
@@ -17,6 +17,8 @@ const {
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
pushCurrentView,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
|
||||
@@ -37,62 +39,6 @@ const { walletDefect } = require("../../shared/walletDefects");
|
||||
|
||||
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 ensNameMap = new Map();
|
||||
let currentSymbol = null;
|
||||
@@ -157,7 +103,7 @@ function show() {
|
||||
// USD total for this token only
|
||||
const usdVal = price && amount !== null ? amount * price : null;
|
||||
const usdStr = formatUsd(usdVal);
|
||||
$("address-token-usd-total").innerHTML = usdStr || " ";
|
||||
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " ";
|
||||
|
||||
// Single token balance line (no tokenId — not clickable here)
|
||||
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
|
||||
@@ -202,9 +148,9 @@ function show() {
|
||||
if (tokenSymbol)
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
|
||||
if (tokenDecimals != null)
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${escapeHtml(tokenDecimals)}</div>`;
|
||||
if (tokenHolders != null)
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`;
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${escapeHtml(Number(tokenHolders).toLocaleString())}</div>`;
|
||||
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>`;
|
||||
contractInfo.innerHTML = infoHtml;
|
||||
@@ -312,10 +258,10 @@ function renderTransactions(txs) {
|
||||
// it on the line above rather than replacing it.
|
||||
const nameStr = escapeHtml(title || ensName || "");
|
||||
const err = tx.isError ? " (failed)" : "";
|
||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||
const opacity = tx.isError ? " opacity-50" : "";
|
||||
const ago = escapeHtml(timeAgo(tx.timestamp));
|
||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
||||
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
|
||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
||||
html += `</div>`;
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
attachCopyHandlers,
|
||||
onViewLeave,
|
||||
formatFee,
|
||||
tokenLabel,
|
||||
} = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const {
|
||||
@@ -73,17 +74,6 @@ 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.
|
||||
// Returns { name, description, details } or null.
|
||||
function decodeCalldata(data, toAddress) {
|
||||
@@ -832,9 +822,10 @@ function setSignButtonBusy(busy) {
|
||||
}
|
||||
|
||||
// Say so on the approval screen itself, and disable the approve button, when
|
||||
// the address the approval was raised for belongs to a wallet whose keys
|
||||
// cannot be derived. Without this the screen would take a password and fail
|
||||
// after deriving it. Reject stays available; the wallet is not touched.
|
||||
// the address the approval was raised for belongs to a wallet whose key
|
||||
// getSignerForAddress refuses. Without this the screen would take a password
|
||||
// and fail after deriving it. Reject stays available; the wallet is not
|
||||
// touched.
|
||||
// Returns true when it gated.
|
||||
function gateOnWalletDefect(errorId, buttonId, address) {
|
||||
const owner = findWalletFor(address);
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
displaySymbol,
|
||||
nativeCurrency,
|
||||
renderAddressHtml,
|
||||
blockieHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
onViewLeave,
|
||||
@@ -20,6 +21,7 @@ const {
|
||||
} = require("./helpers");
|
||||
const { state, currentNetwork } = require("../../shared/state");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
const { walletDefect } = require("../../shared/walletDefects");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
@@ -43,10 +45,10 @@ const {
|
||||
FEE_UNAVAILABLE,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
maxEthAmount,
|
||||
validateTransfer,
|
||||
} = require("../../shared/txValidation");
|
||||
const { log } = require("../../shared/log");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const txStatus = require("./txStatus");
|
||||
|
||||
let pendingTx = null;
|
||||
@@ -54,6 +56,10 @@ let pendingTx = null;
|
||||
// filled in by estimateGas() when the estimate resolves or fails.
|
||||
let feeStatus = FEE_PENDING;
|
||||
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() {
|
||||
const d = state.viewData;
|
||||
@@ -62,11 +68,6 @@ 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) {
|
||||
const blockie = blockieHtml(address);
|
||||
return (
|
||||
@@ -82,10 +83,24 @@ function valueWithUsd(text, usdAmount) {
|
||||
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) {
|
||||
pendingTx = txInfo;
|
||||
feeStatus = FEE_PENDING;
|
||||
feeWei = null;
|
||||
maxSendFees = null;
|
||||
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
// The raw symbol is the price-table key; the capped one is what the
|
||||
@@ -132,18 +147,11 @@ function show(txInfo) {
|
||||
);
|
||||
$("confirm-to-ens").classList.add("hidden");
|
||||
|
||||
// Amount (with inline USD)
|
||||
const ethPrice = getPrice("ETH");
|
||||
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,
|
||||
);
|
||||
renderAmount(txInfo);
|
||||
|
||||
// Balance (with inline USD)
|
||||
const ethPrice = getPrice("ETH");
|
||||
const tokenPrice = getPrice(rawSymbol);
|
||||
if (isErc20) {
|
||||
// null is a balance whose scale nothing knows, not a balance of zero
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
||||
@@ -380,8 +388,8 @@ async function estimateGas(txInfo) {
|
||||
}
|
||||
|
||||
// What the node will require to be reserved, which is what the gate
|
||||
// must be: the send pins no fee fields, so it is broadcast as a
|
||||
// type-2 transaction priced at maxFeePerGas.
|
||||
// must be: the send is broadcast as a type-2 transaction priced at
|
||||
// maxFeePerGas, which only a max ETH send pins (see below).
|
||||
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
||||
if (gasCostWei === null) {
|
||||
throw new Error("no usable gas price from the provider");
|
||||
@@ -419,6 +427,30 @@ async function estimateGas(txInfo) {
|
||||
}
|
||||
feeStatus = FEE_KNOWN;
|
||||
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);
|
||||
} catch (e) {
|
||||
log.errorf("gas estimation failed:", e.shortMessage || e.message);
|
||||
@@ -432,18 +464,19 @@ async function estimateGas(txInfo) {
|
||||
}
|
||||
|
||||
// Populate the transaction this send describes, enforce the fee bound against
|
||||
// the fees that were actually filled in, then sign and broadcast it. The send
|
||||
// pins no fee fields, so ethers fills maxFeePerGas and the gas limit from what
|
||||
// the configured RPC node answers, with nothing otherwise bounding what a
|
||||
// the fees that were actually filled in, then sign and broadcast it. Apart
|
||||
// from a max ETH send, which passes its estimate's fee fields as `fees`, the
|
||||
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
|
||||
// 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.
|
||||
// Populating before the check is what makes assertWithinCeilings() see the
|
||||
// same numbers that would be signed; it throws an ApprovalMismatchError when
|
||||
// the product gasLimit × maxFeePerGas is over the bound, which the caller
|
||||
// shows in the reserved error area rather than sending.
|
||||
async function populateVerifyAndSend(connectedSigner, tx) {
|
||||
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
|
||||
let request;
|
||||
if (tx.token === "ETH") {
|
||||
request = { to: tx.to, value: parseEther(tx.amount) };
|
||||
request = { to: tx.to, value: parseEther(tx.amount), ...fees };
|
||||
} else {
|
||||
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
|
||||
// The contract's decimals() is read to be COMPARED with the scale the
|
||||
@@ -505,6 +538,15 @@ function init(_ctx) {
|
||||
onViewLeave("confirm-tx", clearPassword);
|
||||
|
||||
$("btn-confirm-send").addEventListener("click", async () => {
|
||||
const wallet = state.wallets[state.selectedWallet];
|
||||
// Every Send button refuses a defective wallet before this screen,
|
||||
// but the popup also reopens onto it from a saved view.
|
||||
const defect = walletDefect(wallet);
|
||||
if (defect) {
|
||||
showError("confirm-tx-password-error", defect.shortMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const password = $("confirm-tx-password").value;
|
||||
if (!password) {
|
||||
showError(
|
||||
@@ -514,7 +556,6 @@ function init(_ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wallet = state.wallets[state.selectedWallet];
|
||||
let decryptedSecret;
|
||||
hideError("confirm-tx-password-error");
|
||||
|
||||
@@ -548,7 +589,11 @@ function init(_ctx) {
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const connectedSigner = signer.connect(provider);
|
||||
|
||||
tx = await populateVerifyAndSend(connectedSigner, pendingTx);
|
||||
tx = await populateVerifyAndSend(
|
||||
connectedSigner,
|
||||
pendingTx,
|
||||
maxSendFees,
|
||||
);
|
||||
|
||||
// Best-effort: clear decrypted secret after use.
|
||||
// Note: JS strings are immutable; this nulls the reference but
|
||||
|
||||
+46
-22
@@ -13,10 +13,12 @@
|
||||
// reasoning behind it are; it is re-exported below so views keep importing
|
||||
// it from here.
|
||||
const { formatEther } = require("ethers");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const {
|
||||
truncateAmountNeverZero,
|
||||
isBelowOneMillionth,
|
||||
} = require("../../shared/amountDisplay");
|
||||
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
||||
const { DEBUG } = require("../../shared/constants");
|
||||
const { escapeHtml } = require("../../shared/html");
|
||||
const { isDebug } = require("../../shared/log");
|
||||
@@ -278,6 +280,17 @@ function 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:
|
||||
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
|
||||
// native currency of the network the fee is paid on, then its USD value when
|
||||
@@ -312,7 +325,7 @@ function balanceLine(symbol, amount, price, tokenId) {
|
||||
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||
const usd =
|
||||
price && amount !== null
|
||||
? formatUsd(amount * price) || " "
|
||||
? escapeHtml(formatUsd(amount * price)) || " "
|
||||
: " ";
|
||||
// tokenId is a contract address out of the same explorer JSON, and it
|
||||
// lands inside a quoted attribute.
|
||||
@@ -322,7 +335,7 @@ function balanceLine(symbol, amount, price, tokenId) {
|
||||
: "";
|
||||
return (
|
||||
`<div class="flex text-xs${clickClass}"${tokenAttr}>` +
|
||||
`<span class="flex justify-between" style="width:42ch;max-width:100%">` +
|
||||
`<span class="flex justify-between w-[42ch] max-w-full">` +
|
||||
`<span>${escapeHtml(displaySymbol(symbol))}</span>` +
|
||||
`<span>${qty}</span>` +
|
||||
`</span>` +
|
||||
@@ -417,23 +430,26 @@ function truncateMiddle(str, maxLen) {
|
||||
|
||||
// 16 colors evenly spaced around the hue wheel (22.5° apart),
|
||||
// all at HSL saturation 70%, lightness 50% for uniform vibrancy.
|
||||
// Each is a whole Tailwind class: Tailwind builds only the classes it finds
|
||||
// written out in the source, so the class name cannot be put together at
|
||||
// runtime.
|
||||
const ADDRESS_COLORS = [
|
||||
"#d92626",
|
||||
"#d96926",
|
||||
"#d9ac26",
|
||||
"#c2d926",
|
||||
"#80d926",
|
||||
"#3dd926",
|
||||
"#26d953",
|
||||
"#26d996",
|
||||
"#26d9d9",
|
||||
"#2696d9",
|
||||
"#2653d9",
|
||||
"#3d26d9",
|
||||
"#8026d9",
|
||||
"#c226d9",
|
||||
"#d926ac",
|
||||
"#d92669",
|
||||
"bg-[#d92626]",
|
||||
"bg-[#d96926]",
|
||||
"bg-[#d9ac26]",
|
||||
"bg-[#c2d926]",
|
||||
"bg-[#80d926]",
|
||||
"bg-[#3dd926]",
|
||||
"bg-[#26d953]",
|
||||
"bg-[#26d996]",
|
||||
"bg-[#26d9d9]",
|
||||
"bg-[#2696d9]",
|
||||
"bg-[#2653d9]",
|
||||
"bg-[#3d26d9]",
|
||||
"bg-[#8026d9]",
|
||||
"bg-[#c226d9]",
|
||||
"bg-[#d926ac]",
|
||||
"bg-[#d92669]",
|
||||
];
|
||||
|
||||
function addressColor(address) {
|
||||
@@ -443,7 +459,12 @@ function addressColor(address) {
|
||||
|
||||
function addressDotHtml(address) {
|
||||
const color = addressColor(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 class="inline-block w-[8px] h-[8px] rounded-[50%] ${color} mr-[4px] align-middle shrink-0"></span>`;
|
||||
}
|
||||
|
||||
function blockieHtml(address) {
|
||||
const src = makeBlockie(address);
|
||||
return `<img src="${escapeHtml(src)}" width="48" height="48" class="inline-block rounded-[50%] [image-rendering:pixelated]">`;
|
||||
}
|
||||
|
||||
// Look up an address across all wallets and return its title
|
||||
@@ -492,6 +513,9 @@ function formatAddressHtml(address, ensName, maxLen, title) {
|
||||
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) {
|
||||
const d = new Date(timestamp * 1000);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
@@ -550,7 +574,7 @@ function timeAgo(timestamp) {
|
||||
|
||||
// Shared external-link icon SVG used across all views.
|
||||
const EXT_ICON =
|
||||
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
||||
`<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` +
|
||||
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
|
||||
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
|
||||
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
||||
@@ -691,9 +715,10 @@ module.exports = {
|
||||
addressHoldsFunds,
|
||||
unknownableAmount,
|
||||
nativeCurrency,
|
||||
tokenLabel,
|
||||
formatFee,
|
||||
addressColor,
|
||||
addressDotHtml,
|
||||
blockieHtml,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
addressTitle,
|
||||
@@ -703,7 +728,6 @@ module.exports = {
|
||||
renderAddressHtml,
|
||||
copyableHtml,
|
||||
attachCopyHandlers,
|
||||
etherscanAddressUrl,
|
||||
etherscanLinkHtml,
|
||||
explorerUrl,
|
||||
EXT_ICON,
|
||||
|
||||
@@ -63,7 +63,7 @@ function renderTotalValue() {
|
||||
const ethPrice = getPrice("ETH");
|
||||
if (priceEl) {
|
||||
priceEl.innerHTML = ethPrice
|
||||
? formatUsd(ethPrice) + " USD/ETH"
|
||||
? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
|
||||
: " ";
|
||||
}
|
||||
|
||||
@@ -79,7 +79,8 @@ function renderTotalValue() {
|
||||
el.textContent = ethStr + ethUsd;
|
||||
|
||||
if (subEl) {
|
||||
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || " ";
|
||||
subEl.innerHTML =
|
||||
escapeHtml(formatAddressTotal(getAddressValue(addr))) || " ";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,10 +131,10 @@ function renderHomeTxList(ctx) {
|
||||
const title = addressTitle(counterparty, state.wallets);
|
||||
const titleStr = title ? escapeHtml(title) : "";
|
||||
const err = tx.isError ? " (failed)" : "";
|
||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||
const opacity = tx.isError ? " opacity-50" : "";
|
||||
const ago = escapeHtml(timeAgo(tx.timestamp));
|
||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
||||
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
|
||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
|
||||
html += `</div>`;
|
||||
@@ -240,7 +241,7 @@ function walletListHtml() {
|
||||
state.wallets.forEach((wallet, wi) => {
|
||||
const defect = walletDefect(wallet);
|
||||
html += `<div>`;
|
||||
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
|
||||
html += `<div class="flex justify-between items-center bg-section py-1 px-2 -mx-2">`;
|
||||
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`;
|
||||
// No "+" on a defective wallet: deriving another address from that
|
||||
// xpub would only add one more address the key does not produce
|
||||
@@ -254,12 +255,12 @@ function walletListHtml() {
|
||||
wallet.addresses.forEach((addr, ai) => {
|
||||
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
|
||||
const isActive = state.activeAddress === addr.address;
|
||||
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg" style="padding:0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
|
||||
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg p-0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
|
||||
// Only where a wallet can spare the address: a wallet holding a
|
||||
// single address has no remove control, because its last address
|
||||
// is never removable.
|
||||
const removeBtn = canRemoveAddress(wallet)
|
||||
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1" style="padding:0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
|
||||
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1 p-0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
|
||||
: "";
|
||||
const dot = addressDotHtml(addr.address);
|
||||
const titleBold = isActive ? "font-bold" : "";
|
||||
@@ -280,7 +281,7 @@ function walletListHtml() {
|
||||
}
|
||||
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
|
||||
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${escapeHtml(addrTotal) || " "}</div>`;
|
||||
html += balanceLinesForAddress(
|
||||
addr,
|
||||
state.trackedTokens,
|
||||
|
||||
+114
-2
@@ -22,10 +22,25 @@ const {
|
||||
truncateAmountNeverZero,
|
||||
isBelowOneMillionth,
|
||||
} = require("../../shared/amountDisplay");
|
||||
const { getAddress } = require("ethers");
|
||||
const {
|
||||
feeReserveWei,
|
||||
maxEthAmount,
|
||||
maxTokenAmount,
|
||||
} = require("../../shared/txValidation");
|
||||
const { log } = require("../../shared/log");
|
||||
const { getAddress, parseEther } = require("ethers");
|
||||
|
||||
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.
|
||||
* Returns { valid: true } or { valid: false, error: "..." }.
|
||||
@@ -229,9 +244,102 @@ 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) {
|
||||
ctx = _ctx;
|
||||
$("send-token").addEventListener("change", updateSendBalance);
|
||||
$("send-token").addEventListener("change", () => {
|
||||
// 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
|
||||
$("btn-send-review").disabled = true;
|
||||
@@ -308,6 +416,7 @@ function init(_ctx) {
|
||||
tokenSymbol: tokenSymbol,
|
||||
tokenBalance: tokenBalance,
|
||||
tokenDecimals: tokenDecimals,
|
||||
max: amountIsMax,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -318,7 +427,10 @@ function init(_ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
// Called each time the Send screen opens, with its fields cleared.
|
||||
function resetSendValidation() {
|
||||
sendScreenOpenings++;
|
||||
amountIsMax = false;
|
||||
const errorEl = $("send-to-error");
|
||||
const btn = $("btn-send-review");
|
||||
if (errorEl) errorEl.textContent = "";
|
||||
|
||||
@@ -213,12 +213,7 @@ function show() {
|
||||
versionClickCount = 0;
|
||||
|
||||
// Show debug well if debug mode is already enabled
|
||||
const debugWell = $("settings-debug-well");
|
||||
if (state.debugMode) {
|
||||
debugWell.style.display = "";
|
||||
} else {
|
||||
debugWell.style.display = "none";
|
||||
}
|
||||
$("settings-debug-well").classList.toggle("hidden", !state.debugMode);
|
||||
$("settings-debug-mode").checked = state.debugMode;
|
||||
|
||||
showView("settings");
|
||||
@@ -434,7 +429,7 @@ function init(ctx) {
|
||||
if (versionClickCount >= 10) {
|
||||
versionClickCount = 0;
|
||||
clearTimeout(versionClickTimer);
|
||||
$("settings-debug-well").style.display = "";
|
||||
$("settings-debug-well").classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ function isTracked(address) {
|
||||
return state.trackedTokens.some((t) => t.address.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
function tokenLabel(t) {
|
||||
function nameAndSymbol(t) {
|
||||
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ function renderDropdown() {
|
||||
let html = '<option value="">-- select --</option>';
|
||||
for (const t of tokens) {
|
||||
const tracked = isTracked(t.address);
|
||||
const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
|
||||
const label = nameAndSymbol(t) + (tracked ? " (tracked)" : "");
|
||||
html +=
|
||||
`<option value="${escapeHtml(t.address)}"` +
|
||||
` data-symbol="${escapeHtml(t.symbol)}"` +
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
isoDate,
|
||||
timeAgo,
|
||||
renderAddressHtml,
|
||||
blockieHtml,
|
||||
attachCopyHandlers,
|
||||
copyableHtml,
|
||||
etherscanLinkHtml,
|
||||
@@ -23,7 +24,6 @@ const {
|
||||
const { state } = require("../../shared/state");
|
||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
||||
const { formatEther, formatUnits } = require("ethers");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const { log, debugFetch } = require("../../shared/log");
|
||||
const { decodeCalldata } = require("./approval");
|
||||
|
||||
@@ -48,11 +48,6 @@ function getTransactionType(tx) {
|
||||
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) {
|
||||
const blockie = blockieHtml(address);
|
||||
return (
|
||||
|
||||
@@ -13,8 +13,8 @@ const {
|
||||
explorerUrl,
|
||||
displaySymbol,
|
||||
clearViewStack,
|
||||
tokenLabel,
|
||||
} = require("./helpers");
|
||||
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
||||
const { state } = require("../../shared/state");
|
||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
@@ -243,17 +243,6 @@ function showSuccess(txInfo, txHash, blockNumber) {
|
||||
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) {
|
||||
if (!decoded || !decoded.details) return "";
|
||||
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
||||
|
||||
@@ -33,7 +33,6 @@ const DEBUG_MNEMONIC = DEBUG
|
||||
: null;
|
||||
|
||||
const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
|
||||
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
|
||||
|
||||
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
|
||||
|
||||
@@ -69,7 +68,6 @@ module.exports = {
|
||||
BUILD_DEBUG_MARKER,
|
||||
DEBUG_MNEMONIC,
|
||||
ETHEREUM_MAINNET_CHAIN_ID,
|
||||
ETHEREUM_SEPOLIA_CHAIN_ID,
|
||||
DEFAULT_RPC_URL,
|
||||
DEFAULT_BLOCKSCOUT_URL,
|
||||
BIP44_ETH_PATH,
|
||||
|
||||
@@ -85,12 +85,6 @@ function nativeCurrencyByChainId(chainId) {
|
||||
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 = {
|
||||
NETWORKS,
|
||||
SUPPORTED_CHAIN_IDS,
|
||||
@@ -99,5 +93,4 @@ module.exports = {
|
||||
networkById,
|
||||
networkByChainId,
|
||||
nativeCurrencyByChainId,
|
||||
explorerLink,
|
||||
};
|
||||
|
||||
@@ -104,27 +104,6 @@ function getAddressValue(addr) {
|
||||
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.
|
||||
//
|
||||
// A partial total is shown and named as partial: the figure is the ETH and
|
||||
@@ -149,6 +128,4 @@ module.exports = {
|
||||
formatUsd,
|
||||
formatAddressTotal,
|
||||
getAddressValue,
|
||||
getWalletValue,
|
||||
getTotalValue,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
// (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
|
||||
// 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;
|
||||
|
||||
@@ -32,8 +36,9 @@ const UNKNOWN_SYMBOL = "???";
|
||||
function displaySymbol(symbol) {
|
||||
const s = symbol === null || symbol === undefined ? "" : String(symbol);
|
||||
if (s.length === 0) return UNKNOWN_SYMBOL;
|
||||
if (s.length <= MAX_SYMBOL_LENGTH) return s;
|
||||
return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
|
||||
const chars = Array.from(s);
|
||||
if (chars.length <= MAX_SYMBOL_LENGTH) return s;
|
||||
return chars.slice(0, MAX_SYMBOL_LENGTH - 1).join("") + "…";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Balance arithmetic for the transaction confirmation screen.
|
||||
// Balance arithmetic for the Send and transaction confirmation screens.
|
||||
//
|
||||
// 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
|
||||
@@ -10,7 +10,7 @@
|
||||
// the token balance arrive as human decimal strings, so comparing them at a
|
||||
// common scale is exact.
|
||||
|
||||
const { parseUnits } = require("ethers");
|
||||
const { parseUnits, formatEther } = require("ethers");
|
||||
|
||||
const SCALE_DECIMALS = 18;
|
||||
|
||||
@@ -87,6 +87,28 @@ 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.
|
||||
//
|
||||
// isErc20 — token transfer rather than a native ETH transfer
|
||||
@@ -139,13 +161,12 @@ function validateTransfer({
|
||||
const feeFp = known ? feeWei : null;
|
||||
|
||||
if (isErc20) {
|
||||
// A token can declare more than 18 decimals, and its balance is
|
||||
// stored with all of them. Only the first 18 places (SCALE_DECIMALS)
|
||||
// are read: an amount with more was refused above, so the places
|
||||
// after them cannot decide whether the amount fits.
|
||||
// Only the first 18 places of the balance are read: an amount with
|
||||
// more was refused above, so the places after them cannot decide
|
||||
// whether the amount fits.
|
||||
const tokenText =
|
||||
typeof tokenBalance === "string"
|
||||
? tokenBalance.replace(/(\.\d{18})\d+$/, "$1")
|
||||
? maxTokenAmount(tokenBalance)
|
||||
: tokenBalance;
|
||||
const tokenFp = toFixedPoint(tokenText) ?? 0n;
|
||||
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
|
||||
@@ -174,6 +195,8 @@ module.exports = {
|
||||
SCALE_DECIMALS,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
maxEthAmount,
|
||||
maxTokenAmount,
|
||||
toFixedPoint,
|
||||
validateTransfer,
|
||||
};
|
||||
|
||||
@@ -13,11 +13,17 @@ const NON_MASTER_XPRV = "non-master-xprv";
|
||||
|
||||
// An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below
|
||||
// the key that was imported: the current import path derives the absolute
|
||||
// m/44'/60'/0'/0 from a depth-0 key, and the pre-#210 path derived the same
|
||||
// four levels as a relative path beneath whatever depth it was given. A master
|
||||
// import therefore stores a depth-4 xpub and a depth-d import stores depth
|
||||
// d + 4, which makes the stored xpub an exact read on the imported key's
|
||||
// depth — and it is readable without the password, unlike the key itself.
|
||||
// m/44'/60'/0'/0 from a depth-0 key, and the path before #210 (57959b7)
|
||||
// derived the same four levels as a relative path beneath whatever depth it
|
||||
// was given. A master import therefore stores a depth-4 xpub and a depth-d
|
||||
// import stores depth d + 4, which makes the stored xpub an exact read on the
|
||||
// imported key's depth — and it is readable without the password, unlike the
|
||||
// key itself.
|
||||
//
|
||||
// The first import path (7a7f9c5) does not fit: it stored the imported key's
|
||||
// own xpub with no derivation, so a wallet it wrote is judged wrongly here (a
|
||||
// master import as defective, a depth-4 import as sound). 57959b7 replaced it
|
||||
// in the same push, and no tag contains it.
|
||||
const BIP44_ETH_XPUB_DEPTH = 4;
|
||||
|
||||
const DEFECTS = {
|
||||
|
||||
@@ -22,8 +22,6 @@ const {
|
||||
prices,
|
||||
clearPrices,
|
||||
getAddressValue,
|
||||
getWalletValue,
|
||||
getTotalValue,
|
||||
formatAddressTotal,
|
||||
} = require("../src/shared/prices");
|
||||
const { state } = require("../src/shared/state");
|
||||
@@ -136,17 +134,6 @@ describe("the value of an address, and whether it is the whole value", () => {
|
||||
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", () => {
|
||||
@@ -207,6 +194,14 @@ describe("the wallet list on Home", () => {
|
||||
clearPrices();
|
||||
expect(walletListTotal(FULLY_PRICED)).toBe(" ");
|
||||
});
|
||||
|
||||
// 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: < $0.01");
|
||||
expect(removalWarningTotal(tiny)).toBe("Total: < $0.01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the balance warning on the address-removal confirmation", () => {
|
||||
|
||||
@@ -267,9 +267,22 @@ function loadBackground(options) {
|
||||
lastError: null,
|
||||
},
|
||||
windows: {
|
||||
getLastFocused: (cb) => cb(null),
|
||||
getLastFocused: (cb) => cb(opts.lastFocused || null),
|
||||
create: (options2, cb) => {
|
||||
created.push(options2);
|
||||
// A copy, as the browser takes it at the call: the background
|
||||
// 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
|
||||
// then has no window it can ever be answered in.
|
||||
cb(opts.noWindow ? undefined : { id: created.length });
|
||||
@@ -2716,3 +2729,77 @@ describe("removing a site in Settings disconnects it", () => {
|
||||
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." },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,4 +66,11 @@ describe("balanceLine", () => {
|
||||
expect(html).toContain("<span>1.5000</span>");
|
||||
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("< $0.01");
|
||||
expect(html).not.toContain("< $0.01");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,7 +301,7 @@ describe.each([
|
||||
test("a contract creation's row says so, with no colour dot and no address line", async () => {
|
||||
const html = await rowsFor(historyTx(""));
|
||||
expect(html).toContain(SENTENCE);
|
||||
expect(html).not.toContain("background:");
|
||||
expect(html).not.toContain("bg-[#");
|
||||
expect(html).not.toContain("am-address");
|
||||
expect(html).not.toContain("undefined");
|
||||
});
|
||||
@@ -309,7 +309,7 @@ describe.each([
|
||||
test("a transaction with a recipient shows its colour dot and address", async () => {
|
||||
const html = await rowsFor(historyTx(RECIPIENT));
|
||||
expectAddressLine(html);
|
||||
expect(html).toContain("background:#");
|
||||
expect(html).toContain("bg-[#");
|
||||
expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { isDeepStrictEqual } = require("util");
|
||||
|
||||
const {
|
||||
Transaction,
|
||||
@@ -62,6 +63,10 @@ const {
|
||||
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
|
||||
const { startDappServer } = require("./dapp");
|
||||
const { STUB_COUNTERPARTY } = require("../network");
|
||||
const {
|
||||
STATE_SCHEMA_VERSION,
|
||||
stateProblem,
|
||||
} = require("../../../src/shared/stateSchema");
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
|
||||
@@ -108,6 +113,137 @@ step("popup loads and reaches the welcome view", async (env) => {
|
||||
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) => {
|
||||
const d = env.driver;
|
||||
await d.click("#btn-welcome-add");
|
||||
|
||||
+37
-11
@@ -22,7 +22,7 @@
|
||||
|
||||
"use strict";
|
||||
|
||||
const { Transaction } = require("ethers");
|
||||
const { AbiCoder, Transaction } = require("ethers");
|
||||
|
||||
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
||||
// must not collide with any entry in src/shared/tokenList.js, or
|
||||
@@ -244,26 +244,39 @@ function latestBlock() {
|
||||
};
|
||||
}
|
||||
|
||||
// keccak("decimals()")[0:4].
|
||||
// keccak("decimals()")[0:4], and the same for symbol() and name().
|
||||
const SELECTOR_DECIMALS = "0x313ce567";
|
||||
const SELECTOR_SYMBOL = "0x95d89b41";
|
||||
const SELECTOR_NAME = "0x06fdde03";
|
||||
|
||||
// Every eth_call still answers with a zero word except decimals() on the
|
||||
// stub token, which the wallet reads back at signing time to compare with
|
||||
// the scale the confirmation screen rendered (issue #305).
|
||||
// Every eth_call still answers with a zero word except decimals(), symbol()
|
||||
// and name() on the stub token. The wallet reads decimals() back at signing
|
||||
// time to compare with the scale the confirmation screen rendered (issue
|
||||
// #305). Adding the token by its contract address reads all three (issue
|
||||
// #295); symbol() and name() answer what the explorer reports for it.
|
||||
//
|
||||
// opts.tokenDecimalsOverride is the lying contract: set it and decimals()
|
||||
// answers something other than the value this same fixture reports through
|
||||
// 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
|
||||
// object the route was registered with — after the confirmation screen has
|
||||
// been built — without re-registering anything.
|
||||
// been built — without re-registering anything. Only null or undefined means
|
||||
// no override: 0 is a token with no decimal places, and is answered as one.
|
||||
function ethCallResult(req, opts) {
|
||||
const call = Array.isArray(req.params) ? req.params[0] : null;
|
||||
if (!call || typeof call !== "object") return ZERO_WORD;
|
||||
const data = String(call.data || call.input || "").toLowerCase();
|
||||
const to = String(call.to || "").toLowerCase();
|
||||
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
|
||||
return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals);
|
||||
if (to !== STUB_TOKEN.address) return ZERO_WORD;
|
||||
if (data.startsWith(SELECTOR_DECIMALS)) {
|
||||
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
|
||||
}
|
||||
const abi = AbiCoder.defaultAbiCoder();
|
||||
if (data.startsWith(SELECTOR_SYMBOL)) {
|
||||
return abi.encode(["string"], [tokenObject(opts).symbol]);
|
||||
}
|
||||
if (data.startsWith(SELECTOR_NAME)) {
|
||||
return abi.encode(["string"], [tokenObject(opts).name]);
|
||||
}
|
||||
return ZERO_WORD;
|
||||
}
|
||||
@@ -447,6 +460,16 @@ function rpcReply(req, opts, report) {
|
||||
return Object.assign(envelope, { result: ethCallResult(req, opts) });
|
||||
}
|
||||
if (req.method === "eth_getTransactionReceipt") {
|
||||
// A lookup that fails, which the wait screen counts differently from
|
||||
// one that answers "not mined yet" (README.md, WaitTx).
|
||||
if (opts.failReceiptLookup) {
|
||||
return Object.assign(envelope, {
|
||||
error: {
|
||||
code: -32000,
|
||||
message: "e2e fixture: receipt lookup failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
const hash = Array.isArray(req.params) ? req.params[0] : null;
|
||||
return Object.assign(envelope, {
|
||||
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
|
||||
@@ -597,14 +620,17 @@ function traceEnabled(raw) {
|
||||
* eth_estimateGas until this is cleared again.
|
||||
* @param {string[]} [opts.broadcastTransactions] every raw signed
|
||||
* transaction handed to eth_sendRawTransaction, appended in order.
|
||||
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
|
||||
* the stub token, in place of the value Blockscout reports for it. This is
|
||||
* the token that lies about its scale; read at request time.
|
||||
* @param {number|string|null} [opts.tokenDecimalsOverride] the scale
|
||||
* decimals() answers for the stub token, in place of the value Blockscout
|
||||
* reports for it; null for none, while 0 is a scale like any other. This
|
||||
* is the token that lies about its scale; read at request time.
|
||||
* @param {string} [opts.tokenSymbolOverride] what the explorer reports as
|
||||
* the stub token's symbol, in place of "E2E". This is the token whose
|
||||
* symbol is markup; read at request time.
|
||||
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
|
||||
* confirmed receipt instead of null, so a wait screen resolves.
|
||||
* @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt
|
||||
* with an error, so every receipt lookup fails; read at request time.
|
||||
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
|
||||
* Promise<string|null>}>}
|
||||
*/
|
||||
|
||||
+969
-370
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,17 @@ describe("displaySymbol", () => {
|
||||
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", () => {
|
||||
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
|
||||
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);
|
||||
|
||||
@@ -21,15 +21,14 @@
|
||||
// escaping in src/shared/html.js is the primary fix; default-src is what
|
||||
// stops the next escape that slips from reaching the network.
|
||||
//
|
||||
// Every directive below is pinned exactly, because each of the four
|
||||
// And for #328: style-src is 'self' alone, so the browser refuses every
|
||||
// style="..." attribute in the popup's markup, including one an escape lets
|
||||
// through. The popup styles with classes; script setting element.style is
|
||||
// not affected.
|
||||
//
|
||||
// Every directive below is pinned exactly, because each of the three
|
||||
// loosenings is load-bearing and none of them may grow:
|
||||
//
|
||||
// style-src 'unsafe-inline' src/popup/index.html and the view helpers
|
||||
// use style="..." attributes throughout, which
|
||||
// CSP blocks without it. Chrome enforces this
|
||||
// on attributes, not just <style> blocks, and
|
||||
// Firefox has never implemented style-src-attr,
|
||||
// so there is no narrower spelling available.
|
||||
// img-src data: blockies are data: PNGs assigned to img.src.
|
||||
// connect-src https: http: the RPC endpoint is user-configurable, and a
|
||||
// local node over http://127.0.0.1 is a
|
||||
@@ -58,7 +57,7 @@ const EXPECTED_DIRECTIVES = {
|
||||
"default-src": ["'self'"],
|
||||
"script-src": ["'self'", "'wasm-unsafe-eval'"],
|
||||
"object-src": ["'self'"],
|
||||
"style-src": ["'self'", "'unsafe-inline'"],
|
||||
"style-src": ["'self'"],
|
||||
"img-src": ["'self'", "data:"],
|
||||
"connect-src": ["'self'", "http:", "https:"],
|
||||
"frame-src": ["'none'"],
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
// 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.");
|
||||
});
|
||||
});
|
||||
@@ -253,8 +253,6 @@ async function bootPopup(stored, options) {
|
||||
formatUsd: () => "",
|
||||
formatAddressTotal: () => "",
|
||||
getAddressValue: () => ({ usd: null, partial: false }),
|
||||
getWalletValue: () => ({ usd: null, partial: false }),
|
||||
getTotalValue: () => ({ usd: null, partial: false }),
|
||||
}));
|
||||
jest.doMock("../../src/shared/balances", () => ({
|
||||
fetchTokenBalances: jest.fn(async () => []),
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// 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)");
|
||||
});
|
||||
@@ -6,6 +6,8 @@ const {
|
||||
FEE_UNAVAILABLE,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
maxEthAmount,
|
||||
maxTokenAmount,
|
||||
toFixedPoint,
|
||||
validateTransfer,
|
||||
} = require("../src/shared/txValidation");
|
||||
@@ -306,6 +308,72 @@ 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.
|
||||
// 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.
|
||||
|
||||
+294
-2
@@ -4,8 +4,16 @@
|
||||
// already in storage: the import that created it ran before the refusal
|
||||
// existed. Such a wallet used to sign for the wrong tree and now throws on the
|
||||
// send screen instead. These tests pin down that it is named and explained in
|
||||
// the wallet list, that nothing on the way there throws, and that a wallet
|
||||
// imported from a real master key is untouched by any of it.
|
||||
// the wallet list, that every control leading to a signature or to the private
|
||||
// key refuses it before asking for a password, that nothing on the way there
|
||||
// throws, and that a wallet imported from a real master key is untouched by
|
||||
// any of it.
|
||||
|
||||
// Mocked so that no password has to be hashed: the controls below are checked
|
||||
// for whether they decrypt at all.
|
||||
jest.mock("../src/shared/vault", () => ({
|
||||
decryptWithPassword: jest.fn(),
|
||||
}));
|
||||
|
||||
const { HDNodeWallet, Mnemonic } = require("ethers");
|
||||
|
||||
@@ -237,6 +245,290 @@ describe("the wallet list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A minimal DOM for driving the popup views: any element exists on first
|
||||
// lookup, and click() runs the listeners a view attached to it.
|
||||
function makeElement(id) {
|
||||
const classes = new Set();
|
||||
const el = {
|
||||
id,
|
||||
textContent: "",
|
||||
title: "",
|
||||
value: "",
|
||||
innerHTML: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
listeners: {},
|
||||
classList: {
|
||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
||||
contains: (n) => classes.has(n),
|
||||
toggle: (n, force) => {
|
||||
const on = force === undefined ? !classes.has(n) : force;
|
||||
if (on) classes.add(n);
|
||||
else classes.delete(n);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
addEventListener: (name, fn) => {
|
||||
el.listeners[name] = el.listeners[name] || [];
|
||||
el.listeners[name].push(fn);
|
||||
},
|
||||
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"),
|
||||
addEventListener: () => {},
|
||||
body: { prepend: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
function node(id) {
|
||||
return globalThis.document.getElementById(id);
|
||||
}
|
||||
|
||||
function click(id) {
|
||||
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
|
||||
}
|
||||
|
||||
// getSignerForAddress refuses this wallet's key, but only once the password
|
||||
// has been typed and spent, and the screens report that refusal as a wrong
|
||||
// password or a failed send. So every control that leads to it refuses first.
|
||||
describe("every way to a signature or the private key refuses a defective wallet first", () => {
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
let state;
|
||||
let decryptWithPassword;
|
||||
let home;
|
||||
let addressDetail;
|
||||
let addressToken;
|
||||
let approval;
|
||||
let confirmTx;
|
||||
|
||||
let address;
|
||||
let shortMessage;
|
||||
// What the background answers when the approval window asks which
|
||||
// approval it was opened for, and every message the popup sent it.
|
||||
let approvalDetails;
|
||||
let sent;
|
||||
|
||||
beforeAll(() => {
|
||||
state = require("../src/shared/state").state;
|
||||
decryptWithPassword =
|
||||
require("../src/shared/vault").decryptWithPassword;
|
||||
home = require("../src/popup/views/home");
|
||||
addressDetail = require("../src/popup/views/addressDetail");
|
||||
addressToken = require("../src/popup/views/addressToken");
|
||||
approval = require("../src/popup/views/approval");
|
||||
confirmTx = require("../src/popup/views/confirmTx");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const broken = brokenXprvWallet();
|
||||
// A balance, so that no Send button's zero-balance refusal can stand
|
||||
// in for the defect check.
|
||||
broken.addresses[0].balance = "1.0000";
|
||||
broken.addresses[0].tokenBalances = [];
|
||||
address = broken.addresses[0].address;
|
||||
shortMessage = walletDefect(broken).shortMessage;
|
||||
|
||||
approvalDetails = null;
|
||||
sent = [];
|
||||
globalThis.document = makeDocument();
|
||||
globalThis.window = { close: () => {} };
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
runtime: {
|
||||
connect: () => ({ postMessage: () => {} }),
|
||||
sendMessage: (msg, reply) => {
|
||||
sent.push(msg);
|
||||
if (!reply) return;
|
||||
reply(
|
||||
msg.type === "AUTISTMASK_GET_APPROVAL"
|
||||
? approvalDetails
|
||||
: null,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
// What the wallet's stored secret decrypts to: the account-level key
|
||||
// it was imported from.
|
||||
decryptWithPassword.mockReset();
|
||||
decryptWithPassword.mockResolvedValue(accountXprv(VECTOR_PHRASE));
|
||||
|
||||
state.wallets = [broken];
|
||||
state.activeAddress = address;
|
||||
state.selectedWallet = 0;
|
||||
state.selectedAddress = 0;
|
||||
state.selectedToken = "ETH";
|
||||
state.viewStack = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.wallets = [];
|
||||
state.activeAddress = null;
|
||||
state.selectedWallet = null;
|
||||
state.selectedAddress = null;
|
||||
state.selectedToken = null;
|
||||
});
|
||||
|
||||
test("Send on the main screen", async () => {
|
||||
state.currentView = "main";
|
||||
home.init({});
|
||||
|
||||
await click("btn-main-send");
|
||||
|
||||
expect(node("flash-msg").textContent).toBe(shortMessage);
|
||||
expect(state.currentView).toBe("main");
|
||||
});
|
||||
|
||||
test("Send on the address screen", async () => {
|
||||
state.currentView = "address";
|
||||
addressDetail.init({});
|
||||
|
||||
await click("btn-send");
|
||||
|
||||
expect(node("flash-msg").textContent).toBe(shortMessage);
|
||||
expect(state.currentView).toBe("address");
|
||||
});
|
||||
|
||||
test("Export Private Key on the address screen", async () => {
|
||||
state.currentView = "address";
|
||||
addressDetail.init({});
|
||||
|
||||
await click("btn-export-privkey");
|
||||
|
||||
expect(node("flash-msg").textContent).toBe(shortMessage);
|
||||
expect(state.currentView).toBe("address");
|
||||
});
|
||||
|
||||
test("Send on a token's screen", async () => {
|
||||
state.currentView = "address-token";
|
||||
addressToken.init({});
|
||||
|
||||
await click("btn-address-token-send");
|
||||
|
||||
expect(node("flash-msg").textContent).toBe(shortMessage);
|
||||
expect(state.currentView).toBe("address-token");
|
||||
});
|
||||
|
||||
// The popup reopens onto this screen from a saved view, so the Send
|
||||
// buttons above are not the only way onto it. The screen is not drawn,
|
||||
// because drawing it starts a fee estimate against the network; with a
|
||||
// decrypt that fails, a handler without the check stops at the password
|
||||
// instead of going on to a transaction that was never set up.
|
||||
test("Send on the confirmation screen", async () => {
|
||||
decryptWithPassword.mockRejectedValue(new Error("wrong password"));
|
||||
state.currentView = "confirm-tx";
|
||||
confirmTx.init({});
|
||||
node("confirm-tx-password").value = "any password";
|
||||
|
||||
await click("btn-confirm-send");
|
||||
|
||||
expect(decryptWithPassword).not.toHaveBeenCalled();
|
||||
expect(node("confirm-tx-password-error").textContent).toBe(
|
||||
shortMessage,
|
||||
);
|
||||
});
|
||||
|
||||
async function openTxApproval() {
|
||||
approvalDetails = {
|
||||
type: "tx",
|
||||
origin: "https://dapp.example",
|
||||
isPhishingDomain: false,
|
||||
approvedFrom: address,
|
||||
approvedTx: {
|
||||
from: address,
|
||||
to: RECIPIENT,
|
||||
value: "0x0",
|
||||
data: "0x",
|
||||
chainId: 1,
|
||||
nonce: 0,
|
||||
gasLimit: "21000",
|
||||
maxFeePerGas: "1000000000",
|
||||
},
|
||||
};
|
||||
approval.init({});
|
||||
await approval.show(1);
|
||||
}
|
||||
|
||||
async function openSignApproval() {
|
||||
approvalDetails = {
|
||||
type: "sign",
|
||||
origin: "https://dapp.example",
|
||||
isPhishingDomain: false,
|
||||
approvedFrom: address,
|
||||
// "Hello", as the hex a page sends.
|
||||
signParams: {
|
||||
method: "personal_sign",
|
||||
message: "0x48656c6c6f",
|
||||
from: address,
|
||||
},
|
||||
};
|
||||
approval.init({});
|
||||
await approval.show(1);
|
||||
}
|
||||
|
||||
test("the transaction approval screen says so and disables Approve", async () => {
|
||||
await openTxApproval();
|
||||
|
||||
expect(node("approve-tx-error").textContent).toBe(shortMessage);
|
||||
expect(node("btn-approve-tx").disabled).toBe(true);
|
||||
});
|
||||
|
||||
// The stub runs a disabled button's listener, which a browser would not:
|
||||
// what is asked here is whether the handler refuses on its own.
|
||||
test("Approve on the transaction approval screen does not decrypt", async () => {
|
||||
await openTxApproval();
|
||||
node("approve-tx-password").value = "any password";
|
||||
|
||||
await click("btn-approve-tx");
|
||||
|
||||
expect(decryptWithPassword).not.toHaveBeenCalled();
|
||||
expect(sent.map((msg) => msg.type)).not.toContain(
|
||||
"AUTISTMASK_TX_RESPONSE",
|
||||
);
|
||||
expect(node("approve-tx-error").textContent).toBe(shortMessage);
|
||||
});
|
||||
|
||||
test("the signature approval screen says so and disables Approve", async () => {
|
||||
await openSignApproval();
|
||||
|
||||
expect(node("approve-sign-error").textContent).toBe(shortMessage);
|
||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("Approve on the signature approval screen does not decrypt", async () => {
|
||||
await openSignApproval();
|
||||
node("approve-sign-password").value = "any password";
|
||||
|
||||
await click("btn-approve-sign");
|
||||
|
||||
expect(decryptWithPassword).not.toHaveBeenCalled();
|
||||
expect(sent.map((msg) => msg.type)).not.toContain(
|
||||
"AUTISTMASK_SIGN_RESPONSE",
|
||||
);
|
||||
expect(node("approve-sign-error").textContent).toBe(shortMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no path throws an unhandled error for a defective wallet", () => {
|
||||
test("address derivation from the stored xpub still works", () => {
|
||||
// The stored xpub is at a non-standard depth but is a valid extended
|
||||
|
||||
Reference in New Issue
Block a user