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