Compare commits
1
Commits
next
..
657ba3b059
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
657ba3b059 |
@@ -22,10 +22,11 @@ on: [push]
|
||||
# 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
|
||||
# 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
|
||||
# Chrome suite failing under load are still open; the "In CI" section of
|
||||
# README.md names them. A gate that fails at random teaches people to merge
|
||||
# past red.
|
||||
# block. Making e2e-chrome a required check is blocked on the measured
|
||||
# flake in the dApp signing wait -- two of six runs of unmutated code on a
|
||||
# loaded machine -- tracked as
|
||||
# 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
|
||||
# `|| true`. Both scripts exit non-zero when docker is missing, when the
|
||||
|
||||
@@ -381,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
|
||||
run against the raw signed transaction captured at `eth_sendRawTransaction`
|
||||
rather than against anything the extension reported, rejecting each prompt is
|
||||
required to return a rejection to the page rather than hang or resolve, a prompt
|
||||
raised while another approval window has focus is required to open a window of
|
||||
its own, and the password is required to be absent from every message the
|
||||
approval window sends to the background — with the message that would carry it
|
||||
required to be present, so that check cannot pass by observing nothing. That
|
||||
last one is the standing floor under
|
||||
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
|
||||
required to return a rejection to the page rather than hang or resolve, and the
|
||||
password is required to be absent from every message the approval window sends
|
||||
to the background — with the message that would carry it required to be present,
|
||||
so that check cannot pass by observing nothing. That last one is the standing
|
||||
floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
|
||||
|
||||
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
|
||||
@@ -621,10 +619,14 @@ The jobs **report, they do not gate.** A failure is a red mark against the
|
||||
commit that a reviewer has to account for, not a hard block: whether a check
|
||||
blocks a merge is Gitea branch protection, which this repo does not configure.
|
||||
|
||||
That is not only a statement about configuration. No report of the Chrome suite
|
||||
**failing under load** is open now, but it has failed that way before, so a red
|
||||
`e2e-chrome` is read before it is believed. Do not answer one with a retry
|
||||
wrapper: a suite that reruns until it is green stops being evidence.
|
||||
That is not only a statement about configuration. The Chrome suite is
|
||||
**measurably flaky under load** — two of six runs of unmutated code on a busy
|
||||
machine lost the approval popup out from under the dApp signing wait, always in
|
||||
the `#183` section, tracked as
|
||||
[#287](https://git.eeqj.de/sneak/AutistMask/issues/287). So a red `e2e-chrome`
|
||||
has to be read before it is believed, and that flake is the blocker to ever
|
||||
making this a required check. Do not answer it 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
|
||||
`|| true`; both scripts exit non-zero when docker is missing, when the image
|
||||
@@ -840,12 +842,10 @@ something when you click it.
|
||||
The same data must be formatted identically everywhere it appears. Token and ETH
|
||||
amounts are displayed with exactly 4 decimal places (e.g. "1.0500 ETH", "17.1900
|
||||
USDT") in balance lists, transaction lists, send confirmations, and approval
|
||||
screens. A transaction's time includes both an ISO datetime and a humanized
|
||||
relative age, written by `isoDate()` and `timeAgo()` in
|
||||
`src/popup/views/helpers.js` on every screen that shows one; the ISO datetime is
|
||||
in UTC when the UTC Timestamps setting is on. If a formatting rule applies in
|
||||
one place, it applies in every place. Users should never see the same value
|
||||
rendered differently on two screens.
|
||||
screens. Timestamps include both an ISO datetime and a humanized relative age
|
||||
wherever shown. If a formatting rule applies in one place, it applies in every
|
||||
place. Users should never see the same value rendered differently on two
|
||||
screens.
|
||||
|
||||
The native token's label is a network's `nativeCurrency` in
|
||||
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
|
||||
@@ -1468,17 +1468,6 @@ view would leave a wallet one click from deletion.
|
||||
- Amount input with current balance display, which reads
|
||||
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
|
||||
ConfirmTx's balance line does (see Unknown token scale)
|
||||
- "Max" button beside the amount input, always in place. It fills in a
|
||||
token's balance, cut down to the 18 decimal places ConfirmTx accepts for a
|
||||
token that has more, or for ETH the exact balance minus the network fee
|
||||
reserve that ConfirmTx's balance check gates on, never the rounded balance
|
||||
shown above it. The ETH fee is estimated for the recipient entered, so it
|
||||
asks for a recipient first; an estimate that finishes after the screen was
|
||||
left or the address, holding or recipient changed fills nothing in. Where
|
||||
there is nothing to fill in, a flash message says why: the balance does
|
||||
not cover the fee, the fee could not be estimated, or the token's balance
|
||||
is unknown or zero. Typing in the amount makes it an ordinary amount;
|
||||
changing what to send clears an amount Max filled in
|
||||
- "Review" button, disabled until the recipient validates
|
||||
- **Transitions**:
|
||||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||||
@@ -1495,14 +1484,7 @@ view would leave a wallet one click from deletion.
|
||||
- Token contract: full address + etherscan link (ERC-20 only)
|
||||
- From: blockie + color dot + full address + etherscan link + wallet title
|
||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
|
||||
filled in is worked out again from this screen's own fee estimate when it
|
||||
arrives, as the balance minus the reserve, and the transaction is signed
|
||||
with that estimate's fee fields, so a fee fetched again at signing cannot
|
||||
exceed what the amount leaves behind. The address keeps whatever part of
|
||||
the reserve the transaction does not use. If the balance no longer covers
|
||||
the fee, the amount is left as it was and the amount-plus-fee error below
|
||||
blocks the send
|
||||
- Amount: value + symbol (USD in parentheses)
|
||||
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
|
||||
for a token whose scale is unknown
|
||||
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
||||
@@ -1806,10 +1788,7 @@ view would leave a wallet one click from deletion.
|
||||
new password — and, in bold, that without that phrase written down the
|
||||
deletion loses everything the wallet holds, forever
|
||||
- That the other wallets are not touched
|
||||
- The wallet's name, and a text input asking for it to be typed back. A name
|
||||
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.
|
||||
- The wallet's name, and a text input asking for it to be typed back
|
||||
- Error line
|
||||
- "Delete This Wallet Forever" button
|
||||
- **Transitions**:
|
||||
@@ -1817,9 +1796,9 @@ view would leave a wallet one click from deletion.
|
||||
outcomes as "Confirm Delete" above, through the same `finishDelete()`, so
|
||||
the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED`
|
||||
broadcast are identical on both routes
|
||||
- "Delete This Wallet Forever" (name does not match, or the field is empty)
|
||||
→ "That is not the name of this wallet. Type <name> to confirm." on
|
||||
the error line, nothing deleted
|
||||
- "Delete This Wallet Forever" (name does not match) → "That is not the name
|
||||
of this wallet. Type <name> to confirm." on the error line, nothing
|
||||
deleted
|
||||
- "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet
|
||||
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
|
||||
@@ -1828,13 +1807,8 @@ view would leave a wallet one click from deletion.
|
||||
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
|
||||
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,
|
||||
surrounding spaces and repeated inner spaces are ignored, and characters that
|
||||
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.
|
||||
on, not a secret, so it is matched with surrounding spaces and letter case
|
||||
ignored.
|
||||
- Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened
|
||||
by accident must not land on a screen whose button erases key material.
|
||||
|
||||
@@ -1959,9 +1933,7 @@ view would leave a wallet one click from deletion.
|
||||
`eth_sendTransaction` arriving while one is unanswered is refused with
|
||||
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens
|
||||
no window and takes no nonce, and the site can send it again once the pending
|
||||
one is answered. The window is centred on the browser window the user was last
|
||||
in; if that was another approval window, or the browser refuses the centred
|
||||
position, the browser picks the position.
|
||||
one is answered.
|
||||
- **Elements**:
|
||||
- "Transaction Request" heading
|
||||
- Phishing warning banner (shown when the hostname is on the phishing
|
||||
|
||||
@@ -45,142 +45,11 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 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
|
||||
prompt already answered
|
||||
([#287](https://git.eeqj.de/sneak/AutistMask/issues/287)). When the prompt was
|
||||
decided before the toolbar popup raised for it had loaded, that popup was torn
|
||||
down, `chrome.action.openPopup()` rejected, and the background opened its
|
||||
fallback window for the answered approval and then removed it. In the Chrome
|
||||
end-to-end suite the next test could take that window for its own prompt and
|
||||
lose it under its wait. `openApprovalWindow()` now opens nothing for an
|
||||
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
|
||||
witnessed. Making `e2e-chrome` a required check is still blocked: other
|
||||
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
|
||||
unknown, not read in part
|
||||
([#251](https://git.eeqj.de/sneak/AutistMask/issues/251)). `parseInt` read
|
||||
`1,000` as 1, `0x10` as 0 and `1e3` as 1, a reported low count that hides the
|
||||
token in the transaction history and the send-screen token selector. A count
|
||||
above `Number.MAX_SAFE_INTEGER` is unknown too, not rounded or `Infinity`. The
|
||||
token in the transaction history and the send-screen token selector. The
|
||||
balance list's `holders !== null` check, which did nothing, is dropped.
|
||||
`README.md` and `docs/README.md` now say how each filter treats an unknown
|
||||
count and that the token screen leaves out its "Holders:" row then, and
|
||||
|
||||
+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
|
||||
that survives the spam filters).
|
||||
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
|
||||
decimal places, or your ETH balance minus the amount reserved for the network
|
||||
fee.
|
||||
4. Enter the amount.
|
||||
5. Click "Review" to see the confirmation screen.
|
||||
|
||||
The confirmation screen shows:
|
||||
|
||||
+4
-27
@@ -413,7 +413,7 @@ function releaseApproval(approval) {
|
||||
}
|
||||
}
|
||||
|
||||
// Open approval in a separate popup window, unless it is no longer pending.
|
||||
// Open approval in a separate popup window.
|
||||
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
|
||||
// not from a user gesture) and the fallback for site-connection approvals.
|
||||
// Never rejects. Its callers raise it from inside a Promise executor and drop
|
||||
@@ -437,13 +437,7 @@ async function openApprovalWindow(id) {
|
||||
width: popupWidth,
|
||||
height: popupHeight,
|
||||
};
|
||||
// Centred on a browser window only. The last focused window can be
|
||||
// another approval window still open, and centring on one can give a
|
||||
// position the browser refuses ("Bounds must be at least 50% within
|
||||
// visible screen space"): headless Chrome reports this 360x600 popup as
|
||||
// 1280x720. The request then failed with no window at all. Over a popup,
|
||||
// the browser picks the position.
|
||||
if (currentWin && currentWin.type === "normal") {
|
||||
if (currentWin) {
|
||||
opts.left = Math.round(
|
||||
currentWin.left + (currentWin.width - popupWidth) / 2,
|
||||
);
|
||||
@@ -452,32 +446,15 @@ async function openApprovalWindow(id) {
|
||||
);
|
||||
}
|
||||
|
||||
// Already answered: a site-connection prompt decided before the toolbar
|
||||
// popup raised for it had loaded, whose openPopup() rejects only now.
|
||||
if (!pendingApprovals[id]) return;
|
||||
|
||||
let win = null;
|
||||
try {
|
||||
win = await windowsCreate(opts);
|
||||
} catch (e) {
|
||||
// The promise namespace reports the failure by rejecting where the
|
||||
// callback namespace reported it by handing back no window; both
|
||||
// leave win null.
|
||||
// callback namespace reported it by handing back no window; both land
|
||||
// on the !win branch below, which settles the approval.
|
||||
log.errorf("could not open the approval window:", e);
|
||||
}
|
||||
// The browser also refuses a centred position that is too far off screen,
|
||||
// as it is over a browser window near the screen edge. Asked again
|
||||
// without a position, it places the window itself. If that fails too,
|
||||
// the !win branch below settles the approval.
|
||||
if (!win && opts.left !== undefined) {
|
||||
delete opts.left;
|
||||
delete opts.top;
|
||||
try {
|
||||
win = await windowsCreate(opts);
|
||||
} catch (e) {
|
||||
log.errorf("could not open the approval window:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const approval = pendingApprovals[id];
|
||||
if (!approval) {
|
||||
|
||||
+2
-13
@@ -6,10 +6,7 @@
|
||||
<title>AutistMask</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<!-- Chrome gives extension pages a stylesheet of its own that sets the
|
||||
font on body, and a Tailwind class beats it only when marked
|
||||
important: hence font-mono! rather than font-mono. -->
|
||||
<body class="bg-bg text-fg font-mono! text-sm">
|
||||
<body class="bg-bg text-fg font-mono text-sm">
|
||||
<div id="app" class="p-2 pr-5 overflow-x-hidden">
|
||||
<!-- ============ GLOBAL TITLE BAR ============ -->
|
||||
<div
|
||||
@@ -554,20 +551,12 @@
|
||||
class="text-xs text-muted"
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
id="send-amount"
|
||||
class="border border-border p-1 flex-1 min-w-0 font-mono text-sm bg-bg text-fg"
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||
placeholder="0.0"
|
||||
/>
|
||||
<button
|
||||
id="btn-send-max"
|
||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||
>
|
||||
Max
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="btn-send-review"
|
||||
|
||||
@@ -11,8 +11,6 @@ const {
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
pushCurrentView,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
||||
@@ -66,7 +64,7 @@ function show() {
|
||||
$("address-line").dataset.full = addr.address;
|
||||
attachCopyHandlers($("address-line"));
|
||||
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
||||
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
|
||||
$("address-usd-total").innerHTML = usdTotal || " ";
|
||||
const ensEl = $("address-ens");
|
||||
// ENS is now shown inside renderAddressHtml, hide the separate element
|
||||
ensEl.classList.add("hidden");
|
||||
@@ -90,6 +88,62 @@ function show() {
|
||||
loadTransactions(addr.address);
|
||||
}
|
||||
|
||||
function isoDate(timestamp) {
|
||||
const d = new Date(timestamp * 1000);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
if (state.utcTimestamps) {
|
||||
return (
|
||||
d.getUTCFullYear() +
|
||||
"-" +
|
||||
pad(d.getUTCMonth() + 1) +
|
||||
"-" +
|
||||
pad(d.getUTCDate()) +
|
||||
"T" +
|
||||
pad(d.getUTCHours()) +
|
||||
":" +
|
||||
pad(d.getUTCMinutes()) +
|
||||
":" +
|
||||
pad(d.getUTCSeconds()) +
|
||||
"Z"
|
||||
);
|
||||
}
|
||||
const offsetMin = -d.getTimezoneOffset();
|
||||
const sign = offsetMin >= 0 ? "+" : "-";
|
||||
const absOff = Math.abs(offsetMin);
|
||||
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
|
||||
return (
|
||||
d.getFullYear() +
|
||||
"-" +
|
||||
pad(d.getMonth() + 1) +
|
||||
"-" +
|
||||
pad(d.getDate()) +
|
||||
"T" +
|
||||
pad(d.getHours()) +
|
||||
":" +
|
||||
pad(d.getMinutes()) +
|
||||
":" +
|
||||
pad(d.getSeconds()) +
|
||||
tzStr
|
||||
);
|
||||
}
|
||||
|
||||
function timeAgo(timestamp) {
|
||||
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||||
if (seconds < 60) return seconds + " seconds ago";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60)
|
||||
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12)
|
||||
return months + " month" + (months !== 1 ? "s" : "") + " ago";
|
||||
const years = Math.floor(days / 365);
|
||||
return years + " year" + (years !== 1 ? "s" : "") + " ago";
|
||||
}
|
||||
|
||||
let loadedTxs = [];
|
||||
|
||||
let ensNameMap = new Map();
|
||||
|
||||
@@ -17,8 +17,6 @@ const {
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
pushCurrentView,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
|
||||
@@ -39,6 +37,62 @@ const { walletDefect } = require("../../shared/walletDefects");
|
||||
|
||||
let ctx;
|
||||
|
||||
function isoDate(timestamp) {
|
||||
const d = new Date(timestamp * 1000);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
if (state.utcTimestamps) {
|
||||
return (
|
||||
d.getUTCFullYear() +
|
||||
"-" +
|
||||
pad(d.getUTCMonth() + 1) +
|
||||
"-" +
|
||||
pad(d.getUTCDate()) +
|
||||
"T" +
|
||||
pad(d.getUTCHours()) +
|
||||
":" +
|
||||
pad(d.getUTCMinutes()) +
|
||||
":" +
|
||||
pad(d.getUTCSeconds()) +
|
||||
"Z"
|
||||
);
|
||||
}
|
||||
const offsetMin = -d.getTimezoneOffset();
|
||||
const sign = offsetMin >= 0 ? "+" : "-";
|
||||
const absOff = Math.abs(offsetMin);
|
||||
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
|
||||
return (
|
||||
d.getFullYear() +
|
||||
"-" +
|
||||
pad(d.getMonth() + 1) +
|
||||
"-" +
|
||||
pad(d.getDate()) +
|
||||
"T" +
|
||||
pad(d.getHours()) +
|
||||
":" +
|
||||
pad(d.getMinutes()) +
|
||||
":" +
|
||||
pad(d.getSeconds()) +
|
||||
tzStr
|
||||
);
|
||||
}
|
||||
|
||||
function timeAgo(timestamp) {
|
||||
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||||
if (seconds < 60) return seconds + " seconds ago";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60)
|
||||
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12)
|
||||
return months + " month" + (months !== 1 ? "s" : "") + " ago";
|
||||
const years = Math.floor(days / 365);
|
||||
return years + " year" + (years !== 1 ? "s" : "") + " ago";
|
||||
}
|
||||
|
||||
let loadedTxs = [];
|
||||
let ensNameMap = new Map();
|
||||
let currentSymbol = null;
|
||||
@@ -103,7 +157,7 @@ function show() {
|
||||
// USD total for this token only
|
||||
const usdVal = price && amount !== null ? amount * price : null;
|
||||
const usdStr = formatUsd(usdVal);
|
||||
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " ";
|
||||
$("address-token-usd-total").innerHTML = usdStr || " ";
|
||||
|
||||
// Single token balance line (no tokenId — not clickable here)
|
||||
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
|
||||
@@ -148,9 +202,9 @@ function show() {
|
||||
if (tokenSymbol)
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
|
||||
if (tokenDecimals != null)
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${escapeHtml(tokenDecimals)}</div>`;
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
|
||||
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)
|
||||
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;
|
||||
|
||||
@@ -10,7 +10,6 @@ const {
|
||||
attachCopyHandlers,
|
||||
onViewLeave,
|
||||
formatFee,
|
||||
tokenLabel,
|
||||
} = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
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.
|
||||
// Returns { name, description, details } or null.
|
||||
function decodeCalldata(data, toAddress) {
|
||||
|
||||
@@ -13,7 +13,6 @@ const {
|
||||
displaySymbol,
|
||||
nativeCurrency,
|
||||
renderAddressHtml,
|
||||
blockieHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
onViewLeave,
|
||||
@@ -44,10 +43,10 @@ const {
|
||||
FEE_UNAVAILABLE,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
maxEthAmount,
|
||||
validateTransfer,
|
||||
} = require("../../shared/txValidation");
|
||||
const { log } = require("../../shared/log");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const txStatus = require("./txStatus");
|
||||
|
||||
let pendingTx = null;
|
||||
@@ -55,10 +54,6 @@ let pendingTx = null;
|
||||
// filled in by estimateGas() when the estimate resolves or fails.
|
||||
let feeStatus = FEE_PENDING;
|
||||
let feeWei = null;
|
||||
// The fee fields a max ETH send is signed with: those of the estimate its
|
||||
// amount was derived from. Null for any other send, which ethers prices from
|
||||
// the node at signing time.
|
||||
let maxSendFees = null;
|
||||
|
||||
function restore() {
|
||||
const d = state.viewData;
|
||||
@@ -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) {
|
||||
const blockie = blockieHtml(address);
|
||||
return (
|
||||
@@ -82,24 +82,10 @@ function valueWithUsd(text, usdAmount) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// The Amount line, with its USD value. A max ETH send's line is drawn again
|
||||
// once its amount is re-derived from the fee estimate.
|
||||
function renderAmount(txInfo) {
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
|
||||
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
|
||||
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
||||
$("confirm-amount").textContent = valueWithUsd(
|
||||
txInfo.amount + " " + displaySymbol(rawSymbol),
|
||||
amountUsd,
|
||||
);
|
||||
}
|
||||
|
||||
function show(txInfo) {
|
||||
pendingTx = txInfo;
|
||||
feeStatus = FEE_PENDING;
|
||||
feeWei = null;
|
||||
maxSendFees = null;
|
||||
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
// The raw symbol is the price-table key; the capped one is what the
|
||||
@@ -146,11 +132,18 @@ function show(txInfo) {
|
||||
);
|
||||
$("confirm-to-ens").classList.add("hidden");
|
||||
|
||||
renderAmount(txInfo);
|
||||
|
||||
// Balance (with inline USD)
|
||||
// Amount (with inline USD)
|
||||
const ethPrice = getPrice("ETH");
|
||||
const tokenPrice = getPrice(rawSymbol);
|
||||
const amountNum = parseFloat(txInfo.amount);
|
||||
const price = isErc20 ? tokenPrice : ethPrice;
|
||||
const amountUsd = price ? amountNum * price : null;
|
||||
$("confirm-amount").textContent = valueWithUsd(
|
||||
txInfo.amount + " " + symbol,
|
||||
amountUsd,
|
||||
);
|
||||
|
||||
// Balance (with inline USD)
|
||||
if (isErc20) {
|
||||
// null is a balance whose scale nothing knows, not a balance of zero
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
||||
@@ -387,8 +380,8 @@ async function estimateGas(txInfo) {
|
||||
}
|
||||
|
||||
// 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
|
||||
// maxFeePerGas, which only a max ETH send pins (see below).
|
||||
// must be: the send pins no fee fields, so it is broadcast as a
|
||||
// type-2 transaction priced at maxFeePerGas.
|
||||
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
||||
if (gasCostWei === null) {
|
||||
throw new Error("no usable gas price from the provider");
|
||||
@@ -426,30 +419,6 @@ async function estimateGas(txInfo) {
|
||||
}
|
||||
feeStatus = FEE_KNOWN;
|
||||
feeWei = gasCostWei;
|
||||
// A max ETH send is the balance minus this estimate's reserve, not the
|
||||
// Send screen's, and is signed with this estimate's fee fields: fees
|
||||
// fetched again at signing could exceed the reserve it leaves, and the
|
||||
// node would refuse it for want of funds. Where the balance no longer
|
||||
// covers the fee, the amount is left as it is and the balance check
|
||||
// below says so.
|
||||
if (txInfo.max && txInfo.token === "ETH") {
|
||||
const amount = maxEthAmount(txInfo.balance, gasCostWei);
|
||||
if (amount !== null) {
|
||||
txInfo.amount = amount;
|
||||
renderAmount(txInfo);
|
||||
// Priced as feeReserveWei() priced the reserve: maxFeePerGas,
|
||||
// or gasPrice on a network with no type-2 pricing.
|
||||
if (feeData.maxFeePerGas != null) {
|
||||
maxSendFees = {
|
||||
gasLimit,
|
||||
maxFeePerGas: feeData.maxFeePerGas,
|
||||
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
|
||||
};
|
||||
} else {
|
||||
maxSendFees = { gasLimit, gasPrice: feeData.gasPrice };
|
||||
}
|
||||
}
|
||||
}
|
||||
renderValidation(txInfo);
|
||||
} catch (e) {
|
||||
log.errorf("gas estimation failed:", e.shortMessage || e.message);
|
||||
@@ -463,19 +432,18 @@ async function estimateGas(txInfo) {
|
||||
}
|
||||
|
||||
// Populate the transaction this send describes, enforce the fee bound against
|
||||
// the fees that were actually filled in, then sign and broadcast it. Apart
|
||||
// from a max ETH send, which passes its estimate's fee fields as `fees`, the
|
||||
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
|
||||
// what the configured RPC node answers, with nothing otherwise bounding what a
|
||||
// the fees that were actually filled in, then sign and broadcast it. The send
|
||||
// pins no fee fields, so ethers fills maxFeePerGas and the gas limit from what
|
||||
// the configured RPC node answers, with nothing otherwise bounding what a
|
||||
// hostile node can set — the dApp path's ceilings never reached this one.
|
||||
// Populating before the check is what makes assertWithinCeilings() see the
|
||||
// same numbers that would be signed; it throws an ApprovalMismatchError when
|
||||
// the product gasLimit × maxFeePerGas is over the bound, which the caller
|
||||
// shows in the reserved error area rather than sending.
|
||||
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
|
||||
async function populateVerifyAndSend(connectedSigner, tx) {
|
||||
let request;
|
||||
if (tx.token === "ETH") {
|
||||
request = { to: tx.to, value: parseEther(tx.amount), ...fees };
|
||||
request = { to: tx.to, value: parseEther(tx.amount) };
|
||||
} else {
|
||||
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
|
||||
// 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 connectedSigner = signer.connect(provider);
|
||||
|
||||
tx = await populateVerifyAndSend(
|
||||
connectedSigner,
|
||||
pendingTx,
|
||||
maxSendFees,
|
||||
);
|
||||
tx = await populateVerifyAndSend(connectedSigner, pendingTx);
|
||||
|
||||
// Best-effort: clear decrypted secret after use.
|
||||
// Note: JS strings are immutable; this nulls the reference but
|
||||
|
||||
@@ -12,7 +12,6 @@ const {
|
||||
removeWalletFromState,
|
||||
broadcastActiveChanged,
|
||||
} = require("../../shared/walletDelete");
|
||||
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
||||
|
||||
let deleteWalletIndex = 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 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
|
||||
// unusable. A name that shows nothing at all (only spaces, or only
|
||||
// zero-width characters) is replaced by "Wallet N" for the same reason:
|
||||
// there would be nothing on screen to type back.
|
||||
// unusable.
|
||||
function displayName(walletIdx) {
|
||||
const wallet = state.wallets[walletIdx];
|
||||
const name = wallet && wallet.name;
|
||||
if (name && confirmKey(name)) return name;
|
||||
return "Wallet " + (walletIdx + 1);
|
||||
return (wallet && wallet.name) || "Wallet " + (walletIdx + 1);
|
||||
}
|
||||
|
||||
// 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
|
||||
// "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
|
||||
// name. Characters that paint nothing, such as a zero-width space, are
|
||||
// invisible the same way and are removed first. Comparing this form on
|
||||
// both sides is what keeps the confirmation satisfiable, on the one screen
|
||||
// whose whole purpose is unwedging a user who is already stuck. Case and
|
||||
// surrounding space go the same way.
|
||||
// name. Comparing collapsed on both sides is what keeps the confirmation
|
||||
// satisfiable, on the one screen whose whole purpose is unwedging a user
|
||||
// who is already stuck. Case and surrounding space go the same way.
|
||||
function confirmKey(name) {
|
||||
return name
|
||||
.replace(INVISIBLE_CHARACTERS, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase();
|
||||
return name.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
// Drop the password from the DOM and the wallet selection from the
|
||||
@@ -185,16 +174,14 @@ function init(_ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Case, surrounding spaces, repeated inner spaces and invisible
|
||||
// characters are not part of the confirmation; see confirmKey().
|
||||
// This asks whether the user knows which wallet they are on; it is
|
||||
// not a secret, and refusing "wallet 2" for "Wallet 2" would only
|
||||
// teach the user to distrust the control. An empty field is
|
||||
// refused whatever the wallet is called, so no stored name can
|
||||
// ever be confirmed by typing nothing.
|
||||
const typed = confirmKey($("delete-wallet-lost-name-input").value);
|
||||
// Case, surrounding spaces and repeated inner spaces are not part
|
||||
// of the confirmation; see confirmKey(). This asks whether the
|
||||
// user knows which wallet they are on; it is not a secret, and
|
||||
// refusing "wallet 2" for "Wallet 2" would only teach the user to
|
||||
// distrust the control.
|
||||
const typed = $("delete-wallet-lost-name-input").value;
|
||||
const expected = displayName(lostPasswordIndex);
|
||||
if (typed === "" || typed !== confirmKey(expected)) {
|
||||
if (confirmKey(typed) !== confirmKey(expected)) {
|
||||
$("delete-wallet-lost-flash").textContent =
|
||||
"That is not the name of this wallet. Type " +
|
||||
expected +
|
||||
|
||||
@@ -13,12 +13,10 @@
|
||||
// reasoning behind it are; it is re-exported below so views keep importing
|
||||
// it from here.
|
||||
const { formatEther } = require("ethers");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const {
|
||||
truncateAmountNeverZero,
|
||||
isBelowOneMillionth,
|
||||
} = require("../../shared/amountDisplay");
|
||||
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
||||
const { DEBUG } = require("../../shared/constants");
|
||||
const { escapeHtml } = require("../../shared/html");
|
||||
const { isDebug } = require("../../shared/log");
|
||||
@@ -280,17 +278,6 @@ function nativeCurrency() {
|
||||
return currentNetwork().nativeCurrency;
|
||||
}
|
||||
|
||||
// The symbol shown for a token line, resolved from the bundled list, the
|
||||
// tokens the user tracks, and the explorer's report — the same chain the
|
||||
// amount line's scale comes from. Null when no source names one, so the token
|
||||
// lines keep saying `Unknown token` for a token nothing knows.
|
||||
function tokenLabel(address) {
|
||||
return resolveTokenSymbol(address, {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
});
|
||||
}
|
||||
|
||||
// A network fee in wei as the confirmation and approval screens both show it:
|
||||
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
|
||||
// native currency of the network the fee is paid on, then its USD value when
|
||||
@@ -325,7 +312,7 @@ function balanceLine(symbol, amount, price, tokenId) {
|
||||
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||
const usd =
|
||||
price && amount !== null
|
||||
? escapeHtml(formatUsd(amount * price)) || " "
|
||||
? formatUsd(amount * price) || " "
|
||||
: " ";
|
||||
// tokenId is a contract address out of the same explorer JSON, and it
|
||||
// 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>`;
|
||||
}
|
||||
|
||||
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
|
||||
// (e.g. "Address 1.2") or null if it's not one of ours.
|
||||
function addressTitle(address, wallets) {
|
||||
@@ -510,9 +492,6 @@ function formatAddressHtml(address, ensName, maxLen, title) {
|
||||
return renderAddressHtml(address, { title, ensName, maxLen });
|
||||
}
|
||||
|
||||
// A transaction's time as every screen shows it (README, Display
|
||||
// Consistency): the ISO datetime, in UTC when the UTC Timestamps setting is
|
||||
// on, and the relative age. Views import these two; they keep no copies.
|
||||
function isoDate(timestamp) {
|
||||
const d = new Date(timestamp * 1000);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
@@ -712,10 +691,9 @@ module.exports = {
|
||||
addressHoldsFunds,
|
||||
unknownableAmount,
|
||||
nativeCurrency,
|
||||
tokenLabel,
|
||||
formatFee,
|
||||
addressColor,
|
||||
addressDotHtml,
|
||||
blockieHtml,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
addressTitle,
|
||||
@@ -725,6 +703,7 @@ module.exports = {
|
||||
renderAddressHtml,
|
||||
copyableHtml,
|
||||
attachCopyHandlers,
|
||||
etherscanAddressUrl,
|
||||
etherscanLinkHtml,
|
||||
explorerUrl,
|
||||
EXT_ICON,
|
||||
|
||||
@@ -63,7 +63,7 @@ function renderTotalValue() {
|
||||
const ethPrice = getPrice("ETH");
|
||||
if (priceEl) {
|
||||
priceEl.innerHTML = ethPrice
|
||||
? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
|
||||
? formatUsd(ethPrice) + " USD/ETH"
|
||||
: " ";
|
||||
}
|
||||
|
||||
@@ -79,8 +79,7 @@ function renderTotalValue() {
|
||||
el.textContent = ethStr + ethUsd;
|
||||
|
||||
if (subEl) {
|
||||
subEl.innerHTML =
|
||||
escapeHtml(formatAddressTotal(getAddressValue(addr))) || " ";
|
||||
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || " ";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +280,7 @@ function walletListHtml() {
|
||||
}
|
||||
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
|
||||
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${escapeHtml(addrTotal) || " "}</div>`;
|
||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
||||
html += balanceLinesForAddress(
|
||||
addr,
|
||||
state.trackedTokens,
|
||||
|
||||
+2
-114
@@ -22,25 +22,10 @@ const {
|
||||
truncateAmountNeverZero,
|
||||
isBelowOneMillionth,
|
||||
} = require("../../shared/amountDisplay");
|
||||
const {
|
||||
feeReserveWei,
|
||||
maxEthAmount,
|
||||
maxTokenAmount,
|
||||
} = require("../../shared/txValidation");
|
||||
const { log } = require("../../shared/log");
|
||||
const { getAddress, parseEther } = require("ethers");
|
||||
const { getAddress } = require("ethers");
|
||||
|
||||
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
||||
|
||||
// Whether the amount field holds what Max filled in. The confirmation screen
|
||||
// re-derives a max ETH amount from its own fee estimate; typing in the field
|
||||
// makes it an ordinary amount again.
|
||||
let amountIsMax = false;
|
||||
|
||||
// Counts the times the Send screen has opened, so a Max fee estimate started
|
||||
// before it was last opened fills nothing in.
|
||||
let sendScreenOpenings = 0;
|
||||
|
||||
/**
|
||||
* Validate a destination address string.
|
||||
* Returns { valid: true } or { valid: false, error: "..." }.
|
||||
@@ -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) {
|
||||
ctx = _ctx;
|
||||
$("send-token").addEventListener("change", () => {
|
||||
// A filled-in maximum is the maximum of the holding it was filled in
|
||||
// for.
|
||||
if (amountIsMax) {
|
||||
$("send-amount").value = "";
|
||||
amountIsMax = false;
|
||||
}
|
||||
updateSendBalance();
|
||||
});
|
||||
|
||||
$("btn-send-max").addEventListener("click", fillMaxAmount);
|
||||
$("send-amount").addEventListener("input", () => {
|
||||
amountIsMax = false;
|
||||
});
|
||||
$("send-token").addEventListener("change", updateSendBalance);
|
||||
|
||||
// Initial state: disable review button until address is entered
|
||||
$("btn-send-review").disabled = true;
|
||||
@@ -416,7 +308,6 @@ function init(_ctx) {
|
||||
tokenSymbol: tokenSymbol,
|
||||
tokenBalance: tokenBalance,
|
||||
tokenDecimals: tokenDecimals,
|
||||
max: amountIsMax,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -427,10 +318,7 @@ function init(_ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
// Called each time the Send screen opens, with its fields cleared.
|
||||
function resetSendValidation() {
|
||||
sendScreenOpenings++;
|
||||
amountIsMax = false;
|
||||
const errorEl = $("send-to-error");
|
||||
const btn = $("btn-send-review");
|
||||
if (errorEl) errorEl.textContent = "";
|
||||
|
||||
@@ -12,7 +12,7 @@ function isTracked(address) {
|
||||
return state.trackedTokens.some((t) => t.address.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
function nameAndSymbol(t) {
|
||||
function tokenLabel(t) {
|
||||
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ function renderDropdown() {
|
||||
let html = '<option value="">-- select --</option>';
|
||||
for (const t of tokens) {
|
||||
const tracked = isTracked(t.address);
|
||||
const label = nameAndSymbol(t) + (tracked ? " (tracked)" : "");
|
||||
const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
|
||||
html +=
|
||||
`<option value="${escapeHtml(t.address)}"` +
|
||||
` data-symbol="${escapeHtml(t.symbol)}"` +
|
||||
|
||||
@@ -13,7 +13,6 @@ const {
|
||||
isoDate,
|
||||
timeAgo,
|
||||
renderAddressHtml,
|
||||
blockieHtml,
|
||||
attachCopyHandlers,
|
||||
copyableHtml,
|
||||
etherscanLinkHtml,
|
||||
@@ -24,6 +23,7 @@ const {
|
||||
const { state } = require("../../shared/state");
|
||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
||||
const { formatEther, formatUnits } = require("ethers");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const { log, debugFetch } = require("../../shared/log");
|
||||
const { decodeCalldata } = require("./approval");
|
||||
|
||||
@@ -48,6 +48,11 @@ function getTransactionType(tx) {
|
||||
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
|
||||
}
|
||||
|
||||
function blockieHtml(address) {
|
||||
const src = makeBlockie(address);
|
||||
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
|
||||
}
|
||||
|
||||
function txAddressHtml(address, ensName, title) {
|
||||
const blockie = blockieHtml(address);
|
||||
return (
|
||||
|
||||
@@ -13,8 +13,8 @@ const {
|
||||
explorerUrl,
|
||||
displaySymbol,
|
||||
clearViewStack,
|
||||
tokenLabel,
|
||||
} = require("./helpers");
|
||||
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
||||
const { state } = require("../../shared/state");
|
||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
@@ -243,6 +243,17 @@ function showSuccess(txInfo, txHash, blockNumber) {
|
||||
ctx.doRefreshAndRender();
|
||||
}
|
||||
|
||||
// The symbol shown for a decoded token line, resolved from the bundled list,
|
||||
// the tokens the user tracks, and the explorer's report — the same chain the
|
||||
// approval screen uses. Null when no source names one, so the line keeps
|
||||
// saying `Unknown token`.
|
||||
function tokenLabel(address) {
|
||||
return resolveTokenSymbol(address, {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
});
|
||||
}
|
||||
|
||||
function decodedDetailsHtml(decoded) {
|
||||
if (!decoded || !decoded.details) return "";
|
||||
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
||||
|
||||
@@ -33,6 +33,7 @@ const DEBUG_MNEMONIC = DEBUG
|
||||
: null;
|
||||
|
||||
const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
|
||||
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
|
||||
|
||||
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
|
||||
|
||||
@@ -68,6 +69,7 @@ module.exports = {
|
||||
BUILD_DEBUG_MARKER,
|
||||
DEBUG_MNEMONIC,
|
||||
ETHEREUM_MAINNET_CHAIN_ID,
|
||||
ETHEREUM_SEPOLIA_CHAIN_ID,
|
||||
DEFAULT_RPC_URL,
|
||||
DEFAULT_BLOCKSCOUT_URL,
|
||||
BIP44_ETH_PATH,
|
||||
|
||||
@@ -13,17 +13,12 @@ const LOW_HOLDER_THRESHOLD = 1000;
|
||||
// not one. Only a whole number of zero or more, or a string made of nothing
|
||||
// but the digits 0-9, is a count. Anything else is null, never read in part:
|
||||
// "1,000", "0x10" and "1e3" are unknown, not 1, 0 and 1, because a count we
|
||||
// cannot read is not a low count. A count above Number.MAX_SAFE_INTEGER is
|
||||
// null too: a number cannot hold it exactly, so it would come back rounded,
|
||||
// or as Infinity.
|
||||
// cannot read is not a low count.
|
||||
function parseHoldersCount(raw) {
|
||||
if (typeof raw === "number") {
|
||||
return Number.isSafeInteger(raw) && raw >= 0 ? raw : null;
|
||||
}
|
||||
if (typeof raw === "string" && /^[0-9]+$/.test(raw)) {
|
||||
const count = Number(raw);
|
||||
return Number.isSafeInteger(count) ? count : null;
|
||||
return Number.isInteger(raw) && raw >= 0 ? raw : null;
|
||||
}
|
||||
if (typeof raw === "string" && /^[0-9]+$/.test(raw)) return Number(raw);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,12 @@ function nativeCurrencyByChainId(chainId) {
|
||||
return network ? network.nativeCurrency : "ETH";
|
||||
}
|
||||
|
||||
// Build a block explorer link for the given path type and value.
|
||||
// type: "address" | "tx" | "token" | "block"
|
||||
function explorerLink(network, type, value) {
|
||||
return `${network.explorerUrl}/${type}/${value}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NETWORKS,
|
||||
SUPPORTED_CHAIN_IDS,
|
||||
@@ -93,4 +99,5 @@ module.exports = {
|
||||
networkById,
|
||||
networkByChainId,
|
||||
nativeCurrencyByChainId,
|
||||
explorerLink,
|
||||
};
|
||||
|
||||
@@ -104,6 +104,27 @@ function getAddressValue(addr) {
|
||||
return { usd, partial };
|
||||
}
|
||||
|
||||
// The same pair for a whole wallet, and for every wallet at once. One
|
||||
// unpriced holding anywhere makes the sum a floor, so partial carries up.
|
||||
function getWalletValue(wallet) {
|
||||
return sumValues(wallet.addresses.map(getAddressValue));
|
||||
}
|
||||
|
||||
function getTotalValue(wallets) {
|
||||
return sumValues(wallets.map(getWalletValue));
|
||||
}
|
||||
|
||||
function sumValues(values) {
|
||||
let usd = null;
|
||||
let partial = false;
|
||||
for (const value of values) {
|
||||
if (value.usd === null) continue;
|
||||
usd = (usd === null ? 0 : usd) + value.usd;
|
||||
partial = partial || value.partial;
|
||||
}
|
||||
return { usd, partial };
|
||||
}
|
||||
|
||||
// The one rendering of an address total, so no screen says it differently.
|
||||
//
|
||||
// A partial total is shown and named as partial: the figure is the ETH and
|
||||
@@ -128,4 +149,6 @@ module.exports = {
|
||||
formatUsd,
|
||||
formatAddressTotal,
|
||||
getAddressValue,
|
||||
getWalletValue,
|
||||
getTotalValue,
|
||||
};
|
||||
|
||||
+5
-12
@@ -122,9 +122,9 @@ function currentNetwork() {
|
||||
return networkById(state.networkId);
|
||||
}
|
||||
|
||||
// The persisted fields as this page held them when its last loadState()
|
||||
// finished, or when its last successful saveState() began. saveState() diffs
|
||||
// the live state against this to find only the fields THIS page changed since.
|
||||
// The persisted fields as they stood at the end of this page's last
|
||||
// loadState() or saveState(). saveState() diffs the live state against this
|
||||
// to find only the fields THIS page actually changed.
|
||||
//
|
||||
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
||||
// 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
|
||||
// before this fix; only the persisted record is guaranteed current.
|
||||
async function saveStateOnce() {
|
||||
// A copy, so what this save compares and writes is the page's state as it
|
||||
// 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 current = snapshotPersisted();
|
||||
const result = await storageGet("autistmask");
|
||||
// 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,
|
||||
@@ -524,11 +521,7 @@ async function saveStateOnce() {
|
||||
// exactly as it stood; see the note above.
|
||||
rawState.hasWallet = rawState.wallets.length > 0;
|
||||
|
||||
// What this save compared and wrote, not the page's state now: a change
|
||||
// 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;
|
||||
baseline = structuredClone(snapshotPersisted());
|
||||
}
|
||||
|
||||
// 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
|
||||
// truncated. The ellipsis is what tells the user the name they are looking
|
||||
// at is not the whole name — worth knowing before they send to it.
|
||||
//
|
||||
// Characters are counted as code points, not UTF-16 units, so an emoji is
|
||||
// one character and the cut never falls between the two halves of one: a
|
||||
// half on its own renders as U+FFFD.
|
||||
|
||||
const MAX_SYMBOL_LENGTH = 12;
|
||||
|
||||
@@ -36,9 +32,8 @@ const UNKNOWN_SYMBOL = "???";
|
||||
function displaySymbol(symbol) {
|
||||
const s = symbol === null || symbol === undefined ? "" : String(symbol);
|
||||
if (s.length === 0) return UNKNOWN_SYMBOL;
|
||||
const chars = Array.from(s);
|
||||
if (chars.length <= MAX_SYMBOL_LENGTH) return s;
|
||||
return chars.slice(0, MAX_SYMBOL_LENGTH - 1).join("") + "…";
|
||||
if (s.length <= MAX_SYMBOL_LENGTH) return s;
|
||||
return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
|
||||
}
|
||||
|
||||
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
|
||||
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
|
||||
@@ -10,7 +10,7 @@
|
||||
// the token balance arrive as human decimal strings, so comparing them at a
|
||||
// common scale is exact.
|
||||
|
||||
const { parseUnits, formatEther } = require("ethers");
|
||||
const { parseUnits } = require("ethers");
|
||||
|
||||
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.
|
||||
//
|
||||
// isErc20 — token transfer rather than a native ETH transfer
|
||||
@@ -161,12 +139,13 @@ function validateTransfer({
|
||||
const feeFp = known ? feeWei : null;
|
||||
|
||||
if (isErc20) {
|
||||
// Only the first 18 places of the balance are read: an amount with
|
||||
// more was refused above, so the places after them cannot decide
|
||||
// whether the amount fits.
|
||||
// A token can declare more than 18 decimals, and its balance is
|
||||
// stored with all of them. Only the first 18 places (SCALE_DECIMALS)
|
||||
// are read: an amount with more was refused above, so the places
|
||||
// after them cannot decide whether the amount fits.
|
||||
const tokenText =
|
||||
typeof tokenBalance === "string"
|
||||
? maxTokenAmount(tokenBalance)
|
||||
? tokenBalance.replace(/(\.\d{18})\d+$/, "$1")
|
||||
: tokenBalance;
|
||||
const tokenFp = toFixedPoint(tokenText) ?? 0n;
|
||||
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
|
||||
@@ -195,8 +174,6 @@ module.exports = {
|
||||
SCALE_DECIMALS,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
maxEthAmount,
|
||||
maxTokenAmount,
|
||||
toFixedPoint,
|
||||
validateTransfer,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ const {
|
||||
prices,
|
||||
clearPrices,
|
||||
getAddressValue,
|
||||
getWalletValue,
|
||||
getTotalValue,
|
||||
formatAddressTotal,
|
||||
} = require("../src/shared/prices");
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -194,14 +207,6 @@ describe("the wallet list on Home", () => {
|
||||
clearPrices();
|
||||
expect(walletListTotal(FULLY_PRICED)).toBe(" ");
|
||||
});
|
||||
|
||||
// A total under a cent is written "< $0.01", and the "<" is escaped
|
||||
// here as the removal warning escapes it.
|
||||
test("a total under a cent is escaped, as on the removal warning", () => {
|
||||
const tiny = { ...EMPTY, balance: "0.000001" };
|
||||
expect(walletListTotal(tiny)).toBe("Total: < $0.01");
|
||||
expect(removalWarningTotal(tiny)).toBe("Total: < $0.01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the balance warning on the address-removal confirmation", () => {
|
||||
|
||||
@@ -31,15 +31,11 @@ const iface = new Interface(ERC20_ABI);
|
||||
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
|
||||
// In the bundled list, at 6 decimals.
|
||||
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
// In the bundled list, at 0 decimals.
|
||||
const SLP = "0xCC8Fa225D80b9c7D42F96e9570156c65D6cAAa25";
|
||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
const SPENDER = "0x1111111111111111111111111111111111111111";
|
||||
|
||||
// 5,000 units of a 6-decimal token, the amount from the issue.
|
||||
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;
|
||||
|
||||
function transferData(amount) {
|
||||
@@ -117,21 +113,6 @@ describe("resolveTokenDecimals", () => {
|
||||
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", () => {
|
||||
const wallets = walletsHolding(NOVEL_TOKEN, 6);
|
||||
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", () => {
|
||||
const decoded = decodeCalldata(
|
||||
transferData(FIVE_THOUSAND_AT_SIX),
|
||||
|
||||
@@ -267,22 +267,9 @@ function loadBackground(options) {
|
||||
lastError: null,
|
||||
},
|
||||
windows: {
|
||||
getLastFocused: (cb) => cb(opts.lastFocused || null),
|
||||
getLastFocused: (cb) => cb(null),
|
||||
create: (options2, cb) => {
|
||||
// A copy, as the browser takes it at the call: the background
|
||||
// reuses the object when it asks a second time.
|
||||
created.push({ ...options2 });
|
||||
// A browser that refuses any position it is given, as Chrome
|
||||
// does for one it judges too far off screen.
|
||||
if (opts.refusePosition && options2.left !== undefined) {
|
||||
global.chrome.runtime.lastError = {
|
||||
message:
|
||||
"Invalid value for bounds. Bounds must be at least 50% within visible screen space.",
|
||||
};
|
||||
cb(undefined);
|
||||
global.chrome.runtime.lastError = null;
|
||||
return;
|
||||
}
|
||||
created.push(options2);
|
||||
// A browser that answers with no window at all. The approval
|
||||
// then has no window it can ever be answered in.
|
||||
cb(opts.noWindow ? undefined : { id: created.length });
|
||||
@@ -2248,27 +2235,6 @@ describe("a site connection decided as the popup closes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The prompt is decided before the toolbar popup raised for it has
|
||||
// loaded; that popup is torn down and openPopup() rejects only after.
|
||||
test("a toolbar prompt already decided opens no window when openPopup() rejects", async () => {
|
||||
const bg = loadBackground({ actionPopup: true });
|
||||
const opening = deferred();
|
||||
bg.openPopup.mockImplementation(() => opening.promise);
|
||||
const pending = bg.requestSite();
|
||||
await settle();
|
||||
|
||||
const port = bg.connectApproval(pending.id());
|
||||
port.decide(true, false);
|
||||
port.disconnect();
|
||||
await settle();
|
||||
expect(pending.result()).toEqual({ result: [signer.address] });
|
||||
|
||||
opening.reject(new Error("the toolbar popup closed before it loaded"));
|
||||
await settle();
|
||||
|
||||
expect(bg.created).toHaveLength(0);
|
||||
});
|
||||
|
||||
// The port carries a decision now, so it carries the sender check the
|
||||
// one-off message used to carry. A content script that guessed an
|
||||
// approval id must not be able to connect the site it is running on.
|
||||
@@ -2729,77 +2695,3 @@ describe("removing a site in Settings disconnects it", () => {
|
||||
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
|
||||
});
|
||||
});
|
||||
|
||||
// An approval window still open is often the last focused window, and headless
|
||||
// Chrome reports one as 1280x720. Centred on that, the next approval window
|
||||
// lands where the browser refuses to create it, and its request failed with no
|
||||
// window at all (https://git.eeqj.de/sneak/AutistMask/issues/290).
|
||||
describe("where an approval window opens", () => {
|
||||
test("centred on the browser window the user was last in", async () => {
|
||||
const bg = loadBackground({
|
||||
lastFocused: {
|
||||
type: "normal",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
});
|
||||
|
||||
bg.requestSign();
|
||||
await settle();
|
||||
|
||||
expect(bg.created).toHaveLength(1);
|
||||
expect(bg.created[0]).toMatchObject({ left: 460, top: 60 });
|
||||
});
|
||||
|
||||
test("not centred on an approval window the user was last in", async () => {
|
||||
const bg = loadBackground({
|
||||
lastFocused: {
|
||||
type: "popup",
|
||||
left: 440,
|
||||
top: 0,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
});
|
||||
|
||||
bg.requestSign();
|
||||
await settle();
|
||||
|
||||
// Centred, it would be at left 900, the position the browser refused.
|
||||
expect(bg.created).toHaveLength(1);
|
||||
expect(bg.created[0].left).toBeUndefined();
|
||||
expect(bg.created[0].top).toBeUndefined();
|
||||
});
|
||||
|
||||
test("placed by the browser when it refuses the centred position", async () => {
|
||||
const bg = loadBackground({
|
||||
refusePosition: true,
|
||||
lastFocused: {
|
||||
type: "normal",
|
||||
left: 1500,
|
||||
top: 900,
|
||||
width: 400,
|
||||
height: 300,
|
||||
},
|
||||
});
|
||||
|
||||
const sign = bg.requestSign();
|
||||
await settle();
|
||||
|
||||
expect(bg.created).toHaveLength(2);
|
||||
expect(bg.created[0]).toMatchObject({ left: 1520, top: 750 });
|
||||
expect(bg.created[1].left).toBeUndefined();
|
||||
expect(bg.created[1].top).toBeUndefined();
|
||||
|
||||
// The request waits on the second window rather than failing:
|
||||
// closing that window is refusing the prompt.
|
||||
expect(sign.result()).toBeNull();
|
||||
bg.closeWindow(2);
|
||||
await settle();
|
||||
expect(sign.result()).toEqual({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,11 +66,4 @@ describe("balanceLine", () => {
|
||||
expect(html).toContain("<span>1.5000</span>");
|
||||
expect(html).toContain('data-token="0xabc"');
|
||||
});
|
||||
|
||||
// formatUsd() writes a value under a cent as "< $0.01".
|
||||
test("escapes the USD value along with the symbol", () => {
|
||||
const html = balanceLine("USDC", 0.001, 1, null);
|
||||
expect(html).toContain("< $0.01");
|
||||
expect(html).not.toContain("< $0.01");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,9 +46,6 @@ const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
|
||||
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
|
||||
|
||||
function makeElement(id) {
|
||||
@@ -345,72 +342,6 @@ describe("the typed confirmation", () => {
|
||||
"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", () => {
|
||||
|
||||
@@ -108,20 +108,6 @@ step("popup loads and reaches the welcome view", async (env) => {
|
||||
assert(title === "AutistMask", "unexpected popup title: " + title);
|
||||
});
|
||||
|
||||
// The same check as the Chrome suite's (#418), so both browsers are held to
|
||||
// the same font.
|
||||
step("the popup is drawn in the monospace font it declares", async (env) => {
|
||||
const font = await env.driver.execute(
|
||||
"return getComputedStyle(document.body).fontFamily;",
|
||||
);
|
||||
// --font-mono in src/popup/styles/main.css, as the browser writes it out.
|
||||
assert(
|
||||
font ===
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
"the popup is drawn in " + font + ", not in --font-mono",
|
||||
);
|
||||
});
|
||||
|
||||
step("wallet creation through the UI reaches the main view", async (env) => {
|
||||
const d = env.driver;
|
||||
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
|
||||
// sign over. It is read at request time, so a test flips it on the options
|
||||
// object the route was registered with — after the confirmation screen has
|
||||
// been built — without re-registering anything. Only null or undefined means
|
||||
// no override: 0 is a token with no decimal places, and is answered as one.
|
||||
// been built — without re-registering anything.
|
||||
function ethCallResult(req, opts) {
|
||||
const call = Array.isArray(req.params) ? req.params[0] : null;
|
||||
if (!call || typeof call !== "object") return ZERO_WORD;
|
||||
const data = String(call.data || call.input || "").toLowerCase();
|
||||
const to = String(call.to || "").toLowerCase();
|
||||
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
|
||||
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
|
||||
return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals);
|
||||
}
|
||||
return ZERO_WORD;
|
||||
}
|
||||
@@ -598,10 +597,9 @@ function traceEnabled(raw) {
|
||||
* eth_estimateGas until this is cleared again.
|
||||
* @param {string[]} [opts.broadcastTransactions] every raw signed
|
||||
* transaction handed to eth_sendRawTransaction, appended in order.
|
||||
* @param {number|string|null} [opts.tokenDecimalsOverride] the scale
|
||||
* decimals() answers for the stub token, in place of the value Blockscout
|
||||
* reports for it; null for none, while 0 is a scale like any other. This
|
||||
* is the token that lies about its scale; read at request time.
|
||||
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
|
||||
* the stub token, in place of the value Blockscout reports for it. This is
|
||||
* the token that lies about its scale; read at request time.
|
||||
* @param {string} [opts.tokenSymbolOverride] what the explorer reports as
|
||||
* the stub token's symbol, in place of "E2E". This is the token whose
|
||||
* symbol is markup; read at request time.
|
||||
|
||||
+68
-306
@@ -93,20 +93,6 @@ test("popup loads and reaches the welcome view", async (env) => {
|
||||
assert(title === "AutistMask", "unexpected popup title: " + title);
|
||||
});
|
||||
|
||||
// Chrome's own stylesheet for extension pages sets the font on body; see the
|
||||
// comment above <body> in src/popup/index.html.
|
||||
test("the popup is drawn in the monospace font it declares (#418)", async (env) => {
|
||||
const font = await env.page.evaluate(
|
||||
() => 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 empirical half of #182. The manifest change is only a claim about
|
||||
// what the CSP permits; this is the observation. Two things have to hold
|
||||
// together, and the run covers both: the popup realm compiles WASM (here),
|
||||
@@ -218,51 +204,40 @@ async function goHome(page) {
|
||||
await visible(page, "#view-main");
|
||||
}
|
||||
|
||||
// One field of the popup's state as it was actually persisted, read out of
|
||||
// extension storage rather than inferred from what is on screen.
|
||||
function persistedField(page, field) {
|
||||
// The navigation stack as it was actually persisted, read out of extension
|
||||
// storage rather than inferred from which screen is showing. A stale entry
|
||||
// left behind by a forward navigation that threw is invisible on screen
|
||||
// until the user presses Back one time too many — which is exactly the
|
||||
// second-order damage #150 did — so the stack itself is what gets asserted.
|
||||
function persistedViewStack(page) {
|
||||
return page.evaluate(
|
||||
(key) =>
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
chrome.storage.local.get("autistmask", (r) => {
|
||||
resolve(r.autistmask ? r.autistmask[key] : undefined);
|
||||
resolve((r.autistmask && r.autistmask.viewStack) || []);
|
||||
});
|
||||
}),
|
||||
field,
|
||||
);
|
||||
}
|
||||
|
||||
// The navigation stack as it was actually persisted. A stale entry left
|
||||
// behind by a forward navigation that threw is invisible on screen until
|
||||
// the user presses Back one time too many — which is exactly the
|
||||
// second-order damage #150 did — so the stack itself is what gets asserted.
|
||||
async function persistedViewStack(page) {
|
||||
return (await persistedField(page, "viewStack")) || [];
|
||||
}
|
||||
// saveState() is fired from showView() without being awaited, so the write
|
||||
// lands shortly after the screen does. Polling for the expected stack keeps
|
||||
// that race out of the assertion; a stack that never becomes the expected
|
||||
// one fails with what it actually was.
|
||||
const VIEW_STACK_SETTLE_MS = 5000;
|
||||
|
||||
// Nothing here can await the popup's saves. showView() fires saveState()
|
||||
// without awaiting it, and a Settings control's "change" handler awaits its
|
||||
// save only after click() or selectOption() has already returned. So the
|
||||
// write lands shortly after the screen or the control changes, and a popup
|
||||
// closed before then loses it. Polling for the expected value keeps that
|
||||
// race out of the assertion and out of the close; a value that never
|
||||
// arrives fails with what it actually was.
|
||||
const SAVE_SETTLE_MS = 5000;
|
||||
|
||||
async function waitForPersisted(page, field, expected, where) {
|
||||
async function waitForViewStack(page, expected, where) {
|
||||
const want = JSON.stringify(expected);
|
||||
const deadline = Date.now() + SAVE_SETTLE_MS;
|
||||
const deadline = Date.now() + VIEW_STACK_SETTLE_MS;
|
||||
let seen;
|
||||
for (;;) {
|
||||
seen = await persistedField(page, field);
|
||||
seen = await persistedViewStack(page);
|
||||
if (JSON.stringify(seen) === want) return;
|
||||
if (Date.now() >= deadline) break;
|
||||
await sleep(50);
|
||||
}
|
||||
throw new Error(
|
||||
"the persisted " +
|
||||
field +
|
||||
" " +
|
||||
"navigation stack " +
|
||||
where +
|
||||
" is " +
|
||||
JSON.stringify(seen) +
|
||||
@@ -282,18 +257,12 @@ test("Back from Add Token unwinds the stack exactly once (#150)", async (env) =>
|
||||
|
||||
await env.page.locator("#wallet-list .btn-addr-info").first().click();
|
||||
await visible(env.page, "#view-address");
|
||||
await waitForPersisted(
|
||||
env.page,
|
||||
"viewStack",
|
||||
base.concat("main"),
|
||||
"on address detail",
|
||||
);
|
||||
await waitForViewStack(env.page, base.concat("main"), "on address detail");
|
||||
|
||||
await env.page.click("#btn-add-token");
|
||||
await visible(env.page, "#view-add-token");
|
||||
await waitForPersisted(
|
||||
await waitForViewStack(
|
||||
env.page,
|
||||
"viewStack",
|
||||
base.concat("main", "address"),
|
||||
"on the add token screen",
|
||||
);
|
||||
@@ -304,16 +273,15 @@ test("Back from Add Token unwinds the stack exactly once (#150)", async (env) =>
|
||||
!(await env.page.isVisible("#view-add-token")),
|
||||
"the add token screen is still showing after Back",
|
||||
);
|
||||
await waitForPersisted(
|
||||
await waitForViewStack(
|
||||
env.page,
|
||||
"viewStack",
|
||||
base.concat("main"),
|
||||
"after Back from add token",
|
||||
);
|
||||
|
||||
await env.page.click("#btn-address-back");
|
||||
await visible(env.page, "#view-main");
|
||||
await waitForPersisted(env.page, "viewStack", base, "after a second Back");
|
||||
await waitForViewStack(env.page, base, "after a second Back");
|
||||
});
|
||||
|
||||
test("a common-token quick-pick fills in the contract address (#150)", async (env) => {
|
||||
@@ -366,7 +334,6 @@ test("the native ETH transaction detail still renders (#151)", async (env) => {
|
||||
// the assertions below are as much about that row staying hidden as
|
||||
// about the screen coming up.
|
||||
env.routeOpts.seedNativeTransfer = true;
|
||||
try {
|
||||
await env.page.reload();
|
||||
await openAddressDetail(env.page);
|
||||
|
||||
@@ -383,9 +350,7 @@ test("the native ETH transaction detail still renders (#151)", async (env) => {
|
||||
"the native transaction detail shows the wrong hash: " + hash,
|
||||
);
|
||||
|
||||
const type = (
|
||||
await env.page.locator("#tx-detail-type").innerText()
|
||||
).trim();
|
||||
const type = (await env.page.locator("#tx-detail-type").innerText()).trim();
|
||||
assert(
|
||||
type === "Native ETH Transfer",
|
||||
"the native transaction was classified " + JSON.stringify(type),
|
||||
@@ -410,15 +375,13 @@ test("the native ETH transaction detail still renders (#151)", async (env) => {
|
||||
|
||||
assert(
|
||||
!(await env.page.isVisible("#tx-detail-token-contract-section")),
|
||||
"the token contract row is showing on a transfer that has no " +
|
||||
"token contract",
|
||||
"the token contract row is showing on a transfer that has no token " +
|
||||
"contract",
|
||||
);
|
||||
} finally {
|
||||
// Back to one seeded transaction for everything after this: the
|
||||
// tests below were written against a list holding the token transfer
|
||||
// alone.
|
||||
|
||||
// Back to one seeded transaction for everything after this: the tests
|
||||
// below were written against a list holding the token transfer alone.
|
||||
env.routeOpts.seedNativeTransfer = false;
|
||||
}
|
||||
});
|
||||
|
||||
test("tap-to-copy on the transaction detail screen copies the address (#151)", async (env) => {
|
||||
@@ -716,12 +679,6 @@ test("reopening the popup never lands on the phrase screen (#161)", async (env)
|
||||
await openPhraseScreen(env.page);
|
||||
await revealPhrase(env.page);
|
||||
|
||||
await waitForPersisted(
|
||||
env.page,
|
||||
"currentView",
|
||||
"show-phrase",
|
||||
"before closing the popup",
|
||||
);
|
||||
await env.page.close();
|
||||
env.page = await openPopup(env.ctx, env.popupUrl);
|
||||
await visible(env.page, "#view-main");
|
||||
@@ -757,20 +714,10 @@ function addressScreenState(page) {
|
||||
|
||||
// Close and reopen the page rather than reload it: that is what the toolbar
|
||||
// popup does, and it is the only thing that produces the unrendered views.
|
||||
// The popup reopens on the view it last saved, so the close waits until
|
||||
// `view` is the one saved. That wait cannot see a save that leaves the value
|
||||
// as it was: a caller whose popup already had `view` saved waits for a
|
||||
// screen in between first.
|
||||
async function reopenPopup(env, view) {
|
||||
await waitForPersisted(
|
||||
env.page,
|
||||
"currentView",
|
||||
view,
|
||||
"before closing the popup",
|
||||
);
|
||||
async function reopenPopup(env, restoredView) {
|
||||
await env.page.close();
|
||||
env.page = await openPopup(env.ctx, env.popupUrl);
|
||||
await visible(env.page, "#view-" + view);
|
||||
await visible(env.page, restoredView);
|
||||
}
|
||||
|
||||
// The reproduction from the issue, step for step.
|
||||
@@ -785,7 +732,7 @@ test("Back after reopening the popup renders the address screen (#268)", async (
|
||||
await env.page.click("#btn-settings");
|
||||
await visible(env.page, "#view-settings");
|
||||
|
||||
await reopenPopup(env, "settings");
|
||||
await reopenPopup(env, "#view-settings");
|
||||
|
||||
await env.page.click("#btn-settings-back");
|
||||
await visible(env.page, "#view-address");
|
||||
@@ -842,18 +789,10 @@ test("Back after reopening the popup renders the Receive screen (#268)", async (
|
||||
JSON.stringify(before.address),
|
||||
);
|
||||
|
||||
// The test above left `settings` saved too, so reopenPopup() could not
|
||||
// tell this page's save of it from that one without a save in between.
|
||||
await waitForPersisted(
|
||||
env.page,
|
||||
"currentView",
|
||||
"receive",
|
||||
"on the Receive screen",
|
||||
);
|
||||
await env.page.click("#btn-settings");
|
||||
await visible(env.page, "#view-settings");
|
||||
|
||||
await reopenPopup(env, "settings");
|
||||
await reopenPopup(env, "#view-settings");
|
||||
|
||||
await env.page.click("#btn-settings-back");
|
||||
await visible(env.page, "#view-receive");
|
||||
@@ -1236,24 +1175,11 @@ const NONDEFAULT_NETWORK = "sepolia";
|
||||
test("the theme and network selectors carry a non-default persisted value (#229)", async (env) => {
|
||||
await openSettings(env.page);
|
||||
|
||||
// selectOption() fires "change", which is what the handlers bind. It
|
||||
// returns before the handler's save lands, hence each wait.
|
||||
// selectOption() fires "change", which is what the handlers bind.
|
||||
await env.page.selectOption("#settings-theme", NONDEFAULT_THEME);
|
||||
await waitForPersisted(
|
||||
env.page,
|
||||
"theme",
|
||||
NONDEFAULT_THEME,
|
||||
"after the switch",
|
||||
);
|
||||
await env.page.selectOption("#settings-network", NONDEFAULT_NETWORK);
|
||||
await waitForPersisted(
|
||||
env.page,
|
||||
"networkId",
|
||||
NONDEFAULT_NETWORK,
|
||||
"after the switch",
|
||||
);
|
||||
|
||||
await reopenPopup(env, "settings");
|
||||
await reopenPopup(env, "#view-settings");
|
||||
|
||||
assertSelectors(
|
||||
await selectorValues(env.page),
|
||||
@@ -1272,16 +1198,9 @@ test("the theme and network selectors carry a non-default persisted value (#229)
|
||||
// fixture never customised and so are the mainnet defaults
|
||||
// src/shared/state.js starts with.
|
||||
await env.page.selectOption("#settings-theme", "system");
|
||||
await waitForPersisted(env.page, "theme", "system", "after the restore");
|
||||
await env.page.selectOption("#settings-network", "mainnet");
|
||||
await waitForPersisted(
|
||||
env.page,
|
||||
"networkId",
|
||||
"mainnet",
|
||||
"after the restore",
|
||||
);
|
||||
|
||||
await reopenPopup(env, "settings");
|
||||
await reopenPopup(env, "#view-settings");
|
||||
|
||||
assertSelectors(
|
||||
await selectorValues(env.page),
|
||||
@@ -1306,14 +1225,8 @@ test("a spam filter toggled in Settings survives a popup reopen (#229)", async (
|
||||
immediately.checked === false,
|
||||
"clicking #" + TOGGLED_FILTER + " did not clear it",
|
||||
);
|
||||
await waitForPersisted(
|
||||
env.page,
|
||||
"hideDustTransactions",
|
||||
false,
|
||||
"after clearing #" + TOGGLED_FILTER,
|
||||
);
|
||||
|
||||
await reopenPopup(env, "settings");
|
||||
await reopenPopup(env, "#view-settings");
|
||||
|
||||
const after = await checkboxStates(env.page);
|
||||
for (const { id } of SPAM_FILTER_CHECKBOXES) {
|
||||
@@ -1330,14 +1243,8 @@ test("a spam filter toggled in Settings survives a popup reopen (#229)", async (
|
||||
test("turning the same filter back on survives a reopen too (#229)", async (env) => {
|
||||
await openSettings(env.page);
|
||||
await env.page.click("#" + TOGGLED_FILTER);
|
||||
await waitForPersisted(
|
||||
env.page,
|
||||
"hideDustTransactions",
|
||||
true,
|
||||
"after setting #" + TOGGLED_FILTER + " again",
|
||||
);
|
||||
|
||||
await reopenPopup(env, "settings");
|
||||
await reopenPopup(env, "#view-settings");
|
||||
|
||||
// Restores the fixture the later sections inherit, and rules out a
|
||||
// checkbox that persists "off" only because it is stuck there.
|
||||
@@ -1790,7 +1697,6 @@ test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async
|
||||
expectToken: true,
|
||||
});
|
||||
|
||||
// Released by the next test, which watches the estimate land.
|
||||
env.routeOpts.holdGasEstimate = true;
|
||||
await goToConfirm(env.page, {
|
||||
token: "ETH",
|
||||
@@ -1953,7 +1859,6 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
|
||||
);
|
||||
env.routeOpts.failGasEstimate = true;
|
||||
env.routeOpts.holdGasEstimate = true;
|
||||
try {
|
||||
await goToConfirm(env.page, {
|
||||
token: "ETH",
|
||||
balance: FUNDED_ETH_TEXT + " ETH",
|
||||
@@ -1970,10 +1875,10 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
|
||||
await waitForEstimate(env.page);
|
||||
|
||||
const st = await confirmState(env.page);
|
||||
env.routeOpts.failGasEstimate = false;
|
||||
assert(
|
||||
st.fee === "Unable to estimate",
|
||||
"the fee line does not report the failure: " +
|
||||
JSON.stringify(st.fee),
|
||||
"the fee line does not report the failure: " + JSON.stringify(st.fee),
|
||||
);
|
||||
assert(
|
||||
!st.reserveShown,
|
||||
@@ -1999,15 +1904,10 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
|
||||
st.height +
|
||||
"px",
|
||||
);
|
||||
} finally {
|
||||
env.routeOpts.failGasEstimate = false;
|
||||
env.routeOpts.holdGasEstimate = false;
|
||||
}
|
||||
});
|
||||
|
||||
test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (env) => {
|
||||
env.routeOpts.holdGasEstimate = true;
|
||||
try {
|
||||
await goToConfirm(env.page, {
|
||||
token: STUB_TOKEN.address,
|
||||
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
|
||||
@@ -2016,12 +1916,9 @@ test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (en
|
||||
|
||||
const pending = await confirmState(env.page);
|
||||
env.erc20PendingHeight = pending.height;
|
||||
console.log(
|
||||
"# confirm-tx ERC-20 view height: " + pending.height + "px",
|
||||
);
|
||||
console.log("# confirm-tx ERC-20 view height: " + pending.height + "px");
|
||||
assert(
|
||||
pending.type ===
|
||||
"ERC-20 token transfer (" + STUB_TOKEN.symbol + ")",
|
||||
pending.type === "ERC-20 token transfer (" + STUB_TOKEN.symbol + ")",
|
||||
"unexpected transaction type: " + JSON.stringify(pending.type),
|
||||
);
|
||||
assert(
|
||||
@@ -2049,10 +1946,7 @@ test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (en
|
||||
"the ERC-20 fee block does not quote the reserve: " +
|
||||
JSON.stringify(st.reserve),
|
||||
);
|
||||
assert(
|
||||
!st.sendDisabled,
|
||||
"Send is disabled for a funded ERC-20 transfer",
|
||||
);
|
||||
assert(!st.sendDisabled, "Send is disabled for a funded ERC-20 transfer");
|
||||
assert(
|
||||
st.height === pending.height,
|
||||
"the ERC-20 view changed height when the estimate landed: " +
|
||||
@@ -2061,9 +1955,6 @@ test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (en
|
||||
st.height +
|
||||
"px",
|
||||
);
|
||||
} finally {
|
||||
env.routeOpts.holdGasEstimate = false;
|
||||
}
|
||||
});
|
||||
|
||||
test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", async (env) => {
|
||||
@@ -2165,7 +2056,6 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble
|
||||
/gas estimation failed/,
|
||||
);
|
||||
env.routeOpts.failGasEstimate = true;
|
||||
try {
|
||||
await goToConfirm(env.page, {
|
||||
token: STUB_TOKEN.address,
|
||||
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
|
||||
@@ -2174,6 +2064,7 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble
|
||||
await waitForEstimate(env.page);
|
||||
|
||||
const st = await confirmState(env.page);
|
||||
env.routeOpts.failGasEstimate = false;
|
||||
assert(
|
||||
st.fee === "Unable to estimate",
|
||||
"the ERC-20 fee line does not report the failure: " +
|
||||
@@ -2199,9 +2090,6 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble
|
||||
st.height +
|
||||
"px",
|
||||
);
|
||||
} finally {
|
||||
env.routeOpts.failGasEstimate = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------- the popup's own send, end to end (#305)
|
||||
@@ -2258,34 +2146,6 @@ async function fillPasswordAndSend(page) {
|
||||
await page.click("#btn-confirm-send");
|
||||
}
|
||||
|
||||
// Back to the address screen from wherever a send stopped, which is where a
|
||||
// passing send test leaves the popup for the next one. The success and failure
|
||||
// screens are left by their Done button. The wait for a receipt has no button
|
||||
// and asks only every ten seconds; a reopened popup resumes it and asks at
|
||||
// once, and seedReceipt has the stub node confirm the transaction.
|
||||
async function backToAddressAfterSend(env) {
|
||||
if (await env.page.isVisible("#view-wait-tx")) {
|
||||
env.routeOpts.seedReceipt = true;
|
||||
await waitForPersisted(
|
||||
env.page,
|
||||
"currentView",
|
||||
"wait-tx",
|
||||
"before closing the popup",
|
||||
);
|
||||
await env.page.close();
|
||||
env.page = await openPopup(env.ctx, env.popupUrl);
|
||||
await visible(env.page, "#view-success-tx");
|
||||
env.routeOpts.seedReceipt = false;
|
||||
}
|
||||
for (const done of ["#btn-success-tx-done", "#btn-error-tx-done"]) {
|
||||
if (await env.page.isVisible(done)) {
|
||||
await env.page.click(done);
|
||||
await visible(env.page, "#view-address");
|
||||
}
|
||||
}
|
||||
await backToAddress(env.page);
|
||||
}
|
||||
|
||||
async function goToTokenConfirm(env) {
|
||||
await goToConfirm(env.page, {
|
||||
token: STUB_TOKEN.address,
|
||||
@@ -2306,14 +2166,13 @@ test("the popup's own ERC-20 send broadcasts the amount it displayed (#305)", as
|
||||
// The previous test left the ETH balance at the fee-only fixture, which
|
||||
// blocks sending outright; this one has to be able to press Send.
|
||||
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
|
||||
// Confirm the transaction once it is broadcast, so the wait screen
|
||||
// resolves to the success view instead of polling for the rest of the run.
|
||||
env.routeOpts.seedReceipt = true;
|
||||
try {
|
||||
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true });
|
||||
const shown = await goToTokenConfirm(env);
|
||||
|
||||
const before = env.routeOpts.broadcastTransactions.length;
|
||||
// Confirm the transaction once it is broadcast, so the wait screen
|
||||
// resolves to the success view instead of polling for the rest of the run.
|
||||
env.routeOpts.seedReceipt = true;
|
||||
await fillPasswordAndSend(env.page);
|
||||
await visible(env.page, "#view-wait-tx", 60000);
|
||||
|
||||
@@ -2327,9 +2186,9 @@ test("the popup's own ERC-20 send broadcasts the amount it displayed (#305)", as
|
||||
broadcast[broadcast.length - 1],
|
||||
);
|
||||
|
||||
// The measurement, printed on every run: the amount the user read,
|
||||
// and what the signed bytes mean at each of the two candidate scales.
|
||||
// Under the defect these three lines disagree.
|
||||
// The measurement, printed on every run: the amount the user read, and
|
||||
// what the signed bytes mean at each of the two candidate scales. Under
|
||||
// the defect these three lines disagree.
|
||||
console.log(
|
||||
"# erc-20 send artifact: displayed=" +
|
||||
JSON.stringify(shown) +
|
||||
@@ -2385,38 +2244,29 @@ test("the popup's own ERC-20 send broadcasts the amount it displayed (#305)", as
|
||||
);
|
||||
|
||||
await visible(env.page, "#view-success-tx", 60000);
|
||||
} finally {
|
||||
await env.page.click("#btn-success-tx-done");
|
||||
await visible(env.page, "#view-address");
|
||||
env.routeOpts.seedReceipt = false;
|
||||
await backToAddressAfterSend(env);
|
||||
}
|
||||
});
|
||||
|
||||
test("a token that lies about decimals() at signing time broadcasts nothing (#305)", async (env) => {
|
||||
try {
|
||||
const shown = await goToTokenConfirm(env);
|
||||
|
||||
// Only now, with the screen already built and its estimate already
|
||||
// taken at the explorer's scale, does the contract start answering
|
||||
// differently. This is the whole shape of the defect: a value read at
|
||||
// signing time that nothing on screen was ever derived from.
|
||||
// Only now, with the screen already built and its estimate already taken
|
||||
// at the explorer's scale, does the contract start answering differently.
|
||||
// This is the whole shape of the defect: a value read at signing time that
|
||||
// nothing on screen was ever derived from.
|
||||
env.routeOpts.tokenDecimalsOverride = LYING_DECIMALS;
|
||||
const before = env.routeOpts.broadcastTransactions.length;
|
||||
await fillPasswordAndSend(env.page);
|
||||
await visible(env.page, "#view-error-tx", 60000);
|
||||
env.routeOpts.tokenDecimalsOverride = null;
|
||||
|
||||
// The count is asserted as soon as the send has ended either way, and
|
||||
// the screen only after it: a transfer that got through shows as the
|
||||
// wait for its receipt, never as the failure screen expected here.
|
||||
await visible(
|
||||
env.page,
|
||||
"#view-wait-tx:not(.hidden), #view-error-tx:not(.hidden)",
|
||||
60000,
|
||||
);
|
||||
assert(
|
||||
env.routeOpts.broadcastTransactions.length === before,
|
||||
"a transfer encoded against a contract that contradicts the " +
|
||||
"confirmation screen still reached the RPC (#305)",
|
||||
);
|
||||
await visible(env.page, "#view-error-tx");
|
||||
|
||||
const message = (
|
||||
await env.page.locator("#error-tx-message").innerText()
|
||||
@@ -2439,10 +2289,9 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
|
||||
/^[A-Z].*\.$/s.test(message),
|
||||
"the refusal is not a full sentence: " + JSON.stringify(message),
|
||||
);
|
||||
} finally {
|
||||
env.routeOpts.tokenDecimalsOverride = null;
|
||||
await backToAddressAfterSend(env);
|
||||
}
|
||||
|
||||
await env.page.click("#btn-error-tx-done");
|
||||
await visible(env.page, "#view-address");
|
||||
});
|
||||
|
||||
// ------------------------------------------- hostile token symbol (#307)
|
||||
@@ -2514,10 +2363,9 @@ test("a token whose symbol() returns markup renders as text (#307)", async (env)
|
||||
"# stub token symbol() now returns: " + JSON.stringify(HOSTILE_SYMBOL),
|
||||
);
|
||||
|
||||
try {
|
||||
// Close and reopen so the refresh that runs on open fetches balances
|
||||
// with the hostile symbol in them.
|
||||
await reopenPopup(env, "address");
|
||||
await reopenPopup(env, "#view-address");
|
||||
await env.page.waitForFunction(
|
||||
(addr) =>
|
||||
!!document.querySelector(
|
||||
@@ -2577,16 +2425,13 @@ test("a token whose symbol() returns markup renders as text (#307)", async (env)
|
||||
);
|
||||
assert(
|
||||
!st.rowText.includes("z-index"),
|
||||
"the uncapped symbol reached the screen: " +
|
||||
JSON.stringify(st.rowText),
|
||||
"the uncapped symbol reached the screen: " + JSON.stringify(st.rowText),
|
||||
);
|
||||
} finally {
|
||||
// Put the fixture back before the next test reads it.
|
||||
env.routeOpts.tokenSymbolOverride = null;
|
||||
}
|
||||
|
||||
// Let the stored balances be rewritten with the honest symbol.
|
||||
await reopenPopup(env, "main");
|
||||
// Put the fixture back before the next test reads it, and let the
|
||||
// stored balances be rewritten with the honest symbol.
|
||||
env.routeOpts.tokenSymbolOverride = null;
|
||||
await reopenPopup(env, "#view-main");
|
||||
await env.page.waitForFunction(
|
||||
(addr) => {
|
||||
const row = document.querySelector(
|
||||
@@ -2757,9 +2602,7 @@ function dappMessages(page, type) {
|
||||
|
||||
// The approval window the background opened. Approvals are raised from an
|
||||
// RPC call rather than from a user gesture, so the extension opens a real
|
||||
// popup window for them; it is an ordinary page in this context. It is the
|
||||
// first one found: no test starts with an approval window open (see main()),
|
||||
// so within a test it can only be this test's.
|
||||
// popup window for them; it is an ordinary page in this context.
|
||||
async function waitForApprovalWindow(ctx, timeout = 30000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
for (;;) {
|
||||
@@ -2895,7 +2738,7 @@ async function closeApprovalPages(ctx) {
|
||||
// #btn-reject on the site prompt — NOT self-proving. A page that went away
|
||||
// without the click landing disconnects the approval port, the background
|
||||
// settles that as 4001, and 4001 is exactly what assertUserRejection
|
||||
// accepts. Both call sites arm the click trace below and assert it.
|
||||
// accepts. That call site arms the click trace below and asserts it.
|
||||
//
|
||||
// A button that is missing or unclickable raises a different error, which is
|
||||
// rethrown.
|
||||
@@ -3281,9 +3124,7 @@ test("a connect request from a blocklisted site is flagged (#219)", async (env)
|
||||
// Not remembered: a remembered decision for this origin would
|
||||
// outlive the test.
|
||||
await popup.uncheck("#approve-remember");
|
||||
await armClickTrace(env, popup, "#btn-reject");
|
||||
await clickAndClose(popup, "#btn-reject");
|
||||
await assertClickLanded(env, "#btn-reject");
|
||||
await popup.click("#btn-reject");
|
||||
|
||||
await assertUserRejection(
|
||||
phishingDapp,
|
||||
@@ -3716,55 +3557,6 @@ test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
|
||||
);
|
||||
});
|
||||
|
||||
// The extension used to centre every approval window on the last focused
|
||||
// window. Centred on another approval window, the new one landed where the
|
||||
// browser refused to create it, and the request failed with no window at all
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/290).
|
||||
test("a prompt raised while another approval window has focus opens its own (#290)", async (env) => {
|
||||
await startRequest(env.dapp, "focus-sign", "personal_sign", [
|
||||
SIGN_HEX,
|
||||
env.expectedAddress,
|
||||
]);
|
||||
const signWindow = await waitForApprovalWindow(env.ctx);
|
||||
await visible(signWindow, "#view-approve-sign");
|
||||
await signWindow.bringToFront();
|
||||
const focused = await env.page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
chrome.windows.getLastFocused((w) => resolve(w.type));
|
||||
}),
|
||||
);
|
||||
assert(
|
||||
focused === "popup",
|
||||
"the sign window does not have focus, so this proves nothing: the " +
|
||||
"last focused window is a " +
|
||||
focused,
|
||||
);
|
||||
|
||||
const opened = env.ctx.waitForEvent("page");
|
||||
await startRequest(env.dapp, "focus-tx", "eth_sendTransaction", [
|
||||
{
|
||||
from: env.expectedAddress,
|
||||
to: STUB_COUNTERPARTY,
|
||||
value: toQuantity(TX_VALUE_WEI),
|
||||
data: TX_DATA,
|
||||
},
|
||||
]);
|
||||
const txWindow = await opened.catch(async () => {
|
||||
const outcome = await settleRequest(env.dapp, "focus-tx", 1000);
|
||||
throw new Error(
|
||||
"the extension opened no window for the transaction: " +
|
||||
JSON.stringify(outcome),
|
||||
);
|
||||
});
|
||||
await visible(txWindow, "#view-approve-tx");
|
||||
|
||||
// Closing a window is refusing its prompt, so both answer 4001.
|
||||
await closeApprovalPages(env.ctx);
|
||||
await assertUserRejection(env.dapp, "focus-tx", "the transaction prompt");
|
||||
await assertUserRejection(env.dapp, "focus-sign", "the sign prompt");
|
||||
});
|
||||
|
||||
// The closing pass over both boundaries at once. Every message the section
|
||||
// put on either channel is re-read here and required to be free of the
|
||||
// password — and required to be there at all, method by method, so the
|
||||
@@ -4134,36 +3926,6 @@ async function main() {
|
||||
failure = e.message;
|
||||
}
|
||||
|
||||
// No test starts with an approval window open. One that closes itself,
|
||||
// after a signature or on Reject, can still be closing when the next
|
||||
// test raises its prompt, and waitForApprovalWindow() would take it
|
||||
// for the new one. After a test that passed, its windows get five
|
||||
// seconds to close, and one still open then fails the test: it should
|
||||
// have closed itself, and closing it here unreported would hide that.
|
||||
// After a test that already failed, they are closed unreported, so
|
||||
// that a prompt it left unanswered does not refuse the next one from
|
||||
// its site.
|
||||
if (!failure) {
|
||||
const deadline = Date.now() + 5000;
|
||||
let open;
|
||||
for (;;) {
|
||||
open = env.ctx
|
||||
.pages()
|
||||
.filter(
|
||||
(p) => !p.isClosed() && p.url().includes("?approval="),
|
||||
);
|
||||
if (open.length === 0 || Date.now() > deadline) break;
|
||||
await sleep(50);
|
||||
}
|
||||
if (open.length > 0) {
|
||||
failure =
|
||||
"an approval window was still open 5000ms after the " +
|
||||
"test: " +
|
||||
open.map((p) => p.url()).join(", ");
|
||||
}
|
||||
}
|
||||
await closeApprovalPages(env.ctx);
|
||||
|
||||
// Any uncaught page error, console.error or unstubbed request
|
||||
// fails the test that provoked it, whether or not its assertions
|
||||
// passed. This is the mechanism that caught #150.
|
||||
|
||||
@@ -72,24 +72,6 @@ describe("parseHoldersCount", () => {
|
||||
expect(parseHoldersCount("-5")).toBeNull();
|
||||
expect(parseHoldersCount(-5)).toBeNull();
|
||||
});
|
||||
|
||||
// A number holds a whole number exactly only up to 2^53 - 1. Past that a
|
||||
// string of digits would come back rounded, and a long enough one as
|
||||
// Infinity, which would pass every holder-count floor.
|
||||
test("a count too large for a number to hold exactly is unknown", () => {
|
||||
expect(parseHoldersCount("9007199254740993")).toBeNull();
|
||||
expect(parseHoldersCount("9".repeat(400))).toBeNull();
|
||||
expect(parseHoldersCount(2 ** 53)).toBeNull();
|
||||
});
|
||||
|
||||
test("the largest count a number holds exactly still parses", () => {
|
||||
expect(parseHoldersCount("9007199254740991")).toBe(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
expect(parseHoldersCount(Number.MAX_SAFE_INTEGER)).toBe(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLowHolderCount", () => {
|
||||
|
||||
@@ -91,17 +91,6 @@ describe("displaySymbol", () => {
|
||||
expect(displaySymbol(exact)).toBe(exact);
|
||||
});
|
||||
|
||||
// An emoji outside the Basic Multilingual Plane is two UTF-16 units.
|
||||
// Cutting between them leaves half of one, which renders as U+FFFD.
|
||||
test("counts an emoji as one character and never cuts one in half", () => {
|
||||
expect(displaySymbol("🚀".repeat(MAX_SYMBOL_LENGTH))).toBe(
|
||||
"🚀".repeat(MAX_SYMBOL_LENGTH),
|
||||
);
|
||||
expect(displaySymbol("🚀".repeat(20))).toBe(
|
||||
"🚀".repeat(MAX_SYMBOL_LENGTH - 1) + "…",
|
||||
);
|
||||
});
|
||||
|
||||
test("substitutes a placeholder for an absent symbol", () => {
|
||||
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
|
||||
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
// 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: () => "",
|
||||
formatAddressTotal: () => "",
|
||||
getAddressValue: () => ({ usd: null, partial: false }),
|
||||
getWalletValue: () => ({ usd: null, partial: false }),
|
||||
getTotalValue: () => ({ usd: null, partial: false }),
|
||||
}));
|
||||
jest.doMock("../../src/shared/balances", () => ({
|
||||
fetchTokenBalances: jest.fn(async () => []),
|
||||
|
||||
@@ -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,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
maxEthAmount,
|
||||
maxTokenAmount,
|
||||
toFixedPoint,
|
||||
validateTransfer,
|
||||
} = 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.
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user