Compare commits
1 Commits
2d28452662
...
852798d93a
| Author | SHA1 | Date | |
|---|---|---|---|
| 852798d93a |
98
README.md
98
README.md
@@ -88,21 +88,13 @@ provide:
|
||||
- `script/lint` — run the linter
|
||||
- `script/fmt` — format all files (writes)
|
||||
- `script/fmt-check` — check formatting (read-only)
|
||||
- `script/check` — run test, test-verify-build, lint, and fmt-check
|
||||
- `script/check` — run test, lint, and fmt-check
|
||||
- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in
|
||||
`dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
|
||||
off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of
|
||||
`make build` and `make build-debug`; fails loudly rather than passing if it
|
||||
cannot determine a bundle's state. Not part of `make check`, which does not
|
||||
depend on build artifacts existing.
|
||||
- `script/test-verify-build` — exercise every failure mode of
|
||||
`script/verify-build` against a fixture tree in a temp dir, asserting the exit
|
||||
status and the message of each. Part of `make check`; it reads no build
|
||||
artifacts and writes nothing under `dist/`. The cases that depend on file
|
||||
permissions cannot mean anything for a process that is not subject to them, so
|
||||
the harness proves its runner against a mode-000 file before counting them,
|
||||
dropping to an unprivileged user when run as root; if it cannot, it skips
|
||||
those cases and says so in a banner rather than passing them.
|
||||
- `script/docker` — build the Docker image tagged via `script/projectname`
|
||||
- `script/cibuild` — CI entrypoint: plain `docker build .`
|
||||
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
||||
@@ -232,7 +224,6 @@ src/
|
||||
prices.js — ETH/USD and token/USD via CoinDesk API
|
||||
scamlist.js — known fraud contract addresses
|
||||
state.js — persisted state (extension storage)
|
||||
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
|
||||
tokenList.js — top ERC-20 tokens by market cap (hardcoded)
|
||||
transactions.js — tx history fetching + anti-poisoning filters
|
||||
uniswap.js — Uniswap Universal Router calldata decoder
|
||||
@@ -462,12 +453,11 @@ Which tokens an address shows is decided by `fetchTokenBalances()` in
|
||||
tokens do appear without the user adding them. An ERC-20 is shown when its
|
||||
balance is nonzero and it is in the bundled known-token list, is tracked by the
|
||||
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
|
||||
list from any other contract address is always dropped, and so is any token
|
||||
claiming a symbol that belongs to the native asset and therefore has no
|
||||
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
|
||||
tokens with fewer than 1,000 holders" setting governs the transaction history
|
||||
and the send-screen token selector, not this list. Tracked tokens with a zero
|
||||
balance are listed as well while "Show tracked tokens with zero balance" is on.
|
||||
list from any other contract address is always dropped. That filter is
|
||||
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
|
||||
the transaction history and the send-screen token selector, not this list.
|
||||
Tracked tokens with a zero balance are listed as well while "Show tracked tokens
|
||||
with zero balance" is on.
|
||||
|
||||
#### Navigation
|
||||
|
||||
@@ -504,14 +494,6 @@ ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
|
||||
so the popup can never reopen onto one of them with no password prompt in front
|
||||
of it.
|
||||
|
||||
Every screen that holds secret material in the page registers a cleanup with
|
||||
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
|
||||
exit from that screen rather than only on its "Back" button, so nothing secret
|
||||
survives in a hidden view once the user has navigated away by any route. That
|
||||
covers the revealed private key and recovery phrase, the recovery phrase,
|
||||
private key or extended private key entered on AddWallet, and the password typed
|
||||
on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
||||
|
||||
#### Welcome (`welcome`)
|
||||
|
||||
- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
|
||||
@@ -624,15 +606,10 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
||||
- "Reveal" (correct password) → decrypts the wallet secret, derives this
|
||||
address's key, hides the password input and shows the key (no screen
|
||||
change)
|
||||
- "Reveal" (wrong password) → full-sentence error on the error line, nothing
|
||||
revealed (no screen change)
|
||||
- "Back" → previous screen (AddressDetail)
|
||||
- **Secret handling**: nothing is decrypted, no key is derived, and nothing is
|
||||
written into the page until the password is accepted; the key is never stored
|
||||
in state, and it is wiped from the page whenever the screen is left by any
|
||||
route, including the Settings gear. A decrypt still running when the screen is
|
||||
left is discarded rather than written. The screen is not restorable, so
|
||||
reopening the popup lands on Home rather than back on the key.
|
||||
- "Reveal" (wrong password) → "Wrong password." on the error line, nothing
|
||||
revealed
|
||||
- "Back" → clears the key and password from the DOM, then → previous screen
|
||||
(AddressDetail)
|
||||
|
||||
#### AddressToken (`address-token`)
|
||||
|
||||
@@ -721,23 +698,10 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
||||
- To: color dot + full address + etherscan link
|
||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||
- Count-up timer: "Waiting for confirmation... Ns"
|
||||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
||||
persisted: closing and reopening the popup resumes the poll, with the elapsed
|
||||
counter and the timeout deadline still measured from the original broadcast. A
|
||||
lookup that fails is retried on the next tick rather than counted as a missing
|
||||
receipt, because a failed lookup says nothing about the transaction; but six
|
||||
failures in a row (60 seconds at the poll cadence) end the wait, so an RPC
|
||||
that never answers cannot leave it running indefinitely. Any lookup that
|
||||
answers resets that count.
|
||||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds.
|
||||
- **Transitions**:
|
||||
- Receipt found → **SuccessTx**
|
||||
- A lookup that answers "no receipt" 60 seconds or more after broadcast →
|
||||
**ErrorTx** (timeout message)
|
||||
- Six consecutive failed lookups → **ErrorTx**, with a message naming the
|
||||
unreachable network and pointing at the RPC URL in Settings. This is a
|
||||
different fact from the timeout — the chain was never asked — and says so
|
||||
- Exactly one outcome: a receipt found on the tick that crosses the deadline
|
||||
wins, and no outcome can be rendered over another
|
||||
- 60 seconds without confirmation → **ErrorTx** (timeout message)
|
||||
|
||||
#### SuccessTx (`success-tx`)
|
||||
|
||||
@@ -944,10 +908,10 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
||||
works is deleting the whole wallet in Settings — password-gated, and it
|
||||
destroys the stored secret — then importing again, whereupon
|
||||
`scanForAddresses()` rediscovers the address **only if it has on-chain
|
||||
activity**. An address that was never used is not found by that scan. The
|
||||
text is written by `recoveryPathText()` rather than sitting in
|
||||
`index.html`, so it can name the wallet's own kind of key material: an
|
||||
xprv wallet has no recovery phrase to re-import.
|
||||
activity**. An address that was never used does not come back. The text is
|
||||
written by `recoveryPathText()` rather than sitting in `index.html`, so it
|
||||
can name the wallet's own kind of key material: an xprv wallet has no
|
||||
recovery phrase to re-import.
|
||||
- A warning when the address holds anything, ETH or any tracked ERC-20,
|
||||
followed by the holdings themselves via `balanceLinesForAddress()` and the
|
||||
USD total via `getAddressValueUsd()`. The sentence names no figure of its
|
||||
@@ -1336,15 +1300,14 @@ indexes it as a real token transfer.
|
||||
that is the only thing that populates it. In the transaction history the check
|
||||
is the "Hide fake tokens impersonating a known symbol" setting, on by default;
|
||||
with it off, spoofed transfers are shown and no new blocklist entries are
|
||||
learned from them. The send-screen token selector and the balance list apply
|
||||
the same check unconditionally, because they decide which tokens the user can
|
||||
act on and what the user believes they own rather than what the history
|
||||
displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`,
|
||||
so they cannot answer the question differently. A symbol the list maps to no
|
||||
contract at all — `"ETH"`, the native asset, is the only one — may be borne by
|
||||
no contract, so every ERC-20 claiming it is a spoof on all three. The user's
|
||||
real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees
|
||||
it.
|
||||
learned from them. The send-screen token selector applies the same check
|
||||
unconditionally, because it decides which tokens the user can act on rather
|
||||
than what the history displays. The balance list applies it unconditionally
|
||||
too, but not identically: it exempts symbols that `KNOWN_SYMBOLS` maps to
|
||||
`null`, and `"ETH"` is the only one. So the fake "Ethereum" token above is
|
||||
filtered from the transaction history and from the send selector, but a
|
||||
fake-`ETH` ERC-20 that clears the balance list's own 1,000-holder floor — or
|
||||
that the user tracked manually — is still shown in the balance list.
|
||||
|
||||
- **Low-holder token filtering**: Token transfers from ERC-20 contracts with
|
||||
fewer than 1,000 holders are hidden from transaction history by default.
|
||||
@@ -1380,12 +1343,13 @@ indexes it as a real token transfer.
|
||||
a sharp tool — users who understand the risks can configure the wallet to show
|
||||
everything unfiltered, unix-style. All four settings govern the transaction
|
||||
history; what else each one reaches varies. The known-symbol check also runs
|
||||
unconditionally on the send-screen token selector and on the balance list, in
|
||||
both cases identically to the history. The fraud contract blocklist is applied
|
||||
unconditionally on that selector and is not consulted by the balance list at
|
||||
all. The low-holder setting also gates the send selector, while the balance
|
||||
list's own 1,000-holder floor is unconditional (see Data Model). The dust
|
||||
threshold applies to the transaction history alone.
|
||||
unconditionally on the send-screen token selector, and on the balance list
|
||||
except for symbols mapped to `null` (`"ETH"` alone), which the balance list
|
||||
does not filter. The fraud contract blocklist is applied unconditionally on
|
||||
that selector and is not consulted by the balance list at all. The low-holder
|
||||
setting also gates the send selector, while the balance list's own
|
||||
1,000-holder floor is unconditional (see Data Model). The dust threshold
|
||||
applies to the transaction history alone.
|
||||
|
||||
#### Phishing Domain Protection
|
||||
|
||||
|
||||
27
TODO.md
27
TODO.md
@@ -49,33 +49,6 @@ undefined identifiers, which is how
|
||||
state transitions with wallet deletion so the selection, site permissions and
|
||||
active-address broadcast follow the same rules
|
||||
([#162](https://git.eeqj.de/sneak/AutistMask/issues/162)).
|
||||
- 2026-08-12: The known-symbol spoof rule moved into `src/shared/symbolSpoof.js`
|
||||
and is now the only copy. The balance list had exempted symbols the token list
|
||||
maps to `null` — `"ETH"` alone — so a fake ETH ERC-20 was hidden from the
|
||||
transaction history and the Send selector but listed as a holding named ETH. A
|
||||
symbol with no legitimate contract may now be borne by no contract on any of
|
||||
the three surfaces, and the native exemption is "has no contract address", so
|
||||
a second null-mapped symbol needs no call-site change. The user's real ETH
|
||||
balance is read over RPC and never passes through the rule
|
||||
([#235](https://git.eeqj.de/sneak/AutistMask/issues/235)).
|
||||
- 2026-08-12: `script/verify-build`'s failure modes are now a committed target,
|
||||
`script/test-verify-build`, run by `make check`. It asserts the exit status
|
||||
and the message of every case against a fixture tree in a temp dir, and drops
|
||||
privileges (proving the runner against a mode-000 file first) for the cases
|
||||
that only mean something when file permissions are in force
|
||||
([#227](https://git.eeqj.de/sneak/AutistMask/issues/227)).
|
||||
- 2026-08-12: WaitTx lifecycle: a receipt and the 60-second timeout can no
|
||||
longer both render on one tick, no timer or in-flight lookup outlives its
|
||||
wait, a failed receipt lookup no longer counts as a timeout (but six in a row
|
||||
end the wait, reported as an unreachable network rather than as a timeout),
|
||||
and the wait now resumes after a popup close
|
||||
([#155](https://git.eeqj.de/sneak/AutistMask/issues/155)).
|
||||
- 2026-08-12: The private key export screen now wipes the key from the page
|
||||
whenever it is left by any route, and a decrypt still in flight when the
|
||||
screen is left is discarded instead of written; the same `onViewLeave()`
|
||||
cleanup was extended to every other screen holding secret material in the DOM
|
||||
(AddWallet, ConfirmTx, DeleteWallet, ApproveTx, ApproveSign)
|
||||
([#221](https://git.eeqj.de/sneak/AutistMask/issues/221)).
|
||||
- 2026-08-12: An xprv wallet already in storage that was imported from a
|
||||
non-master key is detected from the depth of its stored `xpub`, explained in
|
||||
the wallet list, and blocked from signing, sending and private-key export
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
#!/bin/sh
|
||||
# script/check: run all checks (test, test-verify-build, lint, fmt-check).
|
||||
# Our own extension to scripts-to-rule-them-all. Must not modify any files.
|
||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||
# extension to scripts-to-rule-them-all. Must not modify any files.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
|
||||
main() {
|
||||
"$SCRIPT_DIR/test"
|
||||
"$SCRIPT_DIR/test-verify-build"
|
||||
"$SCRIPT_DIR/lint"
|
||||
"$SCRIPT_DIR/fmt-check"
|
||||
}
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/test-verify-build: exercise every failure mode of
|
||||
# script/verify-build. Our own extension to scripts-to-rule-them-all, run
|
||||
# from script/check so make check covers it.
|
||||
#
|
||||
# Why this exists: verify-build is the build-integrity guard, and three
|
||||
# separate reviews of it each found a fresh vacuous pass — the grep exit-2
|
||||
# conflation, the discarded find status, the line-delimited walk. Every one
|
||||
# was caught by someone building a tree by hand, because nothing in make check
|
||||
# could catch it. This is that hand battery, committed and automated.
|
||||
#
|
||||
# Each case asserts the exit status AND a substring of the message. A guard
|
||||
# that fails for the wrong reason (right status, different fault) is itself a
|
||||
# defect, so matching the status alone would not be a test of anything.
|
||||
#
|
||||
# The fixture is a temp tree containing script/verify-build as a SYMLINK to
|
||||
# the real script: verify-build takes its ROOT from dirname "$0"/.., so it
|
||||
# operates on the fixture's dist/ and never reads or writes the repo's build
|
||||
# output. The symlink rather than a copy is what makes a deliberate break in
|
||||
# the real script fail here.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
VERIFY_BUILD="$ROOT/script/verify-build"
|
||||
|
||||
MARKER_ON="autistmask-build-debug=on"
|
||||
MARKER_OFF="autistmask-build-debug=off"
|
||||
|
||||
NEWLINE='
|
||||
'
|
||||
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
SKIPPED=0
|
||||
SKIPPED_NAMES=""
|
||||
|
||||
# The command prefix that runs the permission-dependent cases as a user who
|
||||
# is actually subject to file permissions, and whether those cases can run at
|
||||
# all. Both are decided by probe_permission_runner, never assumed.
|
||||
UNPRIV=""
|
||||
PERM_ENABLED=no
|
||||
PERM_HOW=""
|
||||
|
||||
WORK=""
|
||||
|
||||
cleanup() {
|
||||
[ -n "$WORK" ] || return 0
|
||||
# The cases chmod 000 files and directories on purpose.
|
||||
chmod -R u+rwX "$WORK" 2>/dev/null || true
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-test-verify-build.XXXXXX")"
|
||||
FIXTURE="$WORK/fixture"
|
||||
|
||||
# verify-build mktemps its dist/ listing under TMPDIR. Pointing that inside
|
||||
# our work dir keeps the run leaving no residue, and keeps it writable for the
|
||||
# unprivileged user the permission cases run as.
|
||||
TMPDIR="$WORK/tmp"
|
||||
export TMPDIR
|
||||
mkdir -p "$TMPDIR"
|
||||
chmod 1777 "$TMPDIR"
|
||||
chmod 755 "$WORK"
|
||||
|
||||
# --- fixture ---------------------------------------------------------------
|
||||
|
||||
# A stand-in for an emitted bundle: some text plus one marker literal, which
|
||||
# is all verify-build reads out of the real thing.
|
||||
write_bundle() {
|
||||
printf 'var a=1;/* %s */\nvar b=2;\n' "$2" >"$1"
|
||||
}
|
||||
|
||||
# A dist/ shaped like a real build: two listed bundles under different
|
||||
# browsers, an unlisted subtree to make unwalkable, and unlisted files that
|
||||
# carry no marker and must not be objected to.
|
||||
build_fixture() {
|
||||
chmod -R u+rwX "$FIXTURE" 2>/dev/null || true
|
||||
rm -rf "$FIXTURE"
|
||||
|
||||
mkdir -p "$FIXTURE/script"
|
||||
ln -s "$VERIFY_BUILD" "$FIXTURE/script/verify-build"
|
||||
|
||||
mkdir -p "$FIXTURE/dist/chrome/src/popup" \
|
||||
"$FIXTURE/dist/chrome/src/content" \
|
||||
"$FIXTURE/dist/firefox/src/popup"
|
||||
|
||||
write_bundle "$FIXTURE/dist/chrome/src/popup/index.js" "$MARKER_OFF"
|
||||
write_bundle "$FIXTURE/dist/firefox/src/popup/index.js" "$MARKER_OFF"
|
||||
printf 'body{color:#000}\n' >"$FIXTURE/dist/styles.css"
|
||||
printf 'var c=3;\n' >"$FIXTURE/dist/chrome/src/content/content.js"
|
||||
|
||||
{
|
||||
echo "dist/chrome/src/popup/index.js"
|
||||
echo "dist/firefox/src/popup/index.js"
|
||||
} >"$FIXTURE/dist/constants-bundles.txt"
|
||||
|
||||
# Readable and traversable by the unprivileged user the permission cases
|
||||
# run as, before those cases take that away again on purpose.
|
||||
chmod -R a+rX "$FIXTURE"
|
||||
}
|
||||
|
||||
# --- permission runner ------------------------------------------------------
|
||||
|
||||
# Run a command through the current unprivileged runner. Unquoted on purpose:
|
||||
# UNPRIV is a command prefix that has to word-split.
|
||||
run_unpriv() {
|
||||
# shellcheck disable=SC2086
|
||||
$UNPRIV "$@"
|
||||
}
|
||||
|
||||
# Decide whether the permission-dependent cases can run, and prove it rather
|
||||
# than assuming it.
|
||||
#
|
||||
# The problem: the CI image declares no USER, so CI runs as root, and root is
|
||||
# not subject to file permissions — chmod 000 stops neither find nor grep. A
|
||||
# permission case run as root passes vacuously, which is worse than no case at
|
||||
# all because it reads as coverage.
|
||||
#
|
||||
# So the runner is validated with two probes before any permission case is
|
||||
# counted:
|
||||
#
|
||||
# - a mode-644 file MUST be readable through it. If not, the runner itself
|
||||
# is broken (missing helper, no such user, sandbox), and every case run
|
||||
# through it would fail for the wrong reason.
|
||||
# - a mode-000 file MUST NOT be readable through it. If it is, permissions
|
||||
# are not in force and the cases would pass without proving anything.
|
||||
#
|
||||
# Unprivileged: the runner is empty and both probes are about this process,
|
||||
# which is the honest answer. Root: setpriv and runuser are tried, both
|
||||
# present in the pinned CI base image. Only when no candidate passes both
|
||||
# probes are the cases skipped, and a skipped run says so unmistakably.
|
||||
probe_permission_runner() {
|
||||
_probe="$WORK/probe"
|
||||
mkdir -p "$_probe"
|
||||
printf 'readable\n' >"$_probe/public"
|
||||
printf 'secret\n' >"$_probe/private"
|
||||
chmod 755 "$_probe"
|
||||
chmod 644 "$_probe/public"
|
||||
chmod 000 "$_probe/private"
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
_candidates="setpriv|setpriv --reuid=65534 --regid=65534 --clear-groups --
|
||||
runuser|runuser -u nobody --"
|
||||
else
|
||||
_candidates="direct|"
|
||||
fi
|
||||
|
||||
_tried=""
|
||||
_saved_ifs="$IFS"
|
||||
IFS="$NEWLINE"
|
||||
for _line in $_candidates; do
|
||||
IFS="$_saved_ifs"
|
||||
_label="${_line%%|*}"
|
||||
_cmd="${_line#*|}"
|
||||
_tried="${_tried:+$_tried, }$_label"
|
||||
|
||||
if [ -n "$_cmd" ]; then
|
||||
_bin="${_cmd%% *}"
|
||||
command -v "$_bin" >/dev/null 2>&1 || continue
|
||||
fi
|
||||
|
||||
UNPRIV="$_cmd"
|
||||
# Broken or unusable runner: the cases would fail for the wrong
|
||||
# reason. Reaching the script under test is part of usable.
|
||||
run_unpriv cat "$_probe/public" >/dev/null 2>&1 || continue
|
||||
run_unpriv cat "$VERIFY_BUILD" >/dev/null 2>&1 || continue
|
||||
# Permissions not in force through this runner: the cases would pass
|
||||
# without testing anything.
|
||||
if run_unpriv cat "$_probe/private" >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
|
||||
PERM_ENABLED=yes
|
||||
PERM_HOW="$_label"
|
||||
IFS="$_saved_ifs"
|
||||
return 0
|
||||
done
|
||||
IFS="$_saved_ifs"
|
||||
|
||||
UNPRIV=""
|
||||
PERM_ENABLED=no
|
||||
PERM_HOW="$_tried"
|
||||
}
|
||||
|
||||
# --- case runner ------------------------------------------------------------
|
||||
|
||||
# check_case <name> <perm:yes|no> <mode:release|debug> <status> <text> <setup>
|
||||
#
|
||||
# Rebuilds the fixture, applies <setup> inside it, runs verify-build, and
|
||||
# requires both the exit status and the message. <perm> marks a case that only
|
||||
# means anything when file permissions are in force.
|
||||
check_case() {
|
||||
_name="$1"
|
||||
_perm="$2"
|
||||
_mode="$3"
|
||||
_want_status="$4"
|
||||
_want_text="$5"
|
||||
_setup="$6"
|
||||
|
||||
if [ "$_perm" = yes ] && [ "$PERM_ENABLED" != yes ]; then
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
SKIPPED_NAMES="$SKIPPED_NAMES## - $_name$NEWLINE"
|
||||
echo " SKIP (permissions not in force): $_name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
build_fixture
|
||||
if ! (cd "$FIXTURE" && "$_setup") >/dev/null 2>&1; then
|
||||
FAILED=$((FAILED + 1))
|
||||
echo " FAIL: $_name"
|
||||
echo " the case's own setup failed, so nothing was tested."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$_mode" = debug ]; then
|
||||
_debug=1
|
||||
else
|
||||
_debug=""
|
||||
fi
|
||||
|
||||
# Exported rather than set as a command prefix: run_unpriv is a function,
|
||||
# and an assignment prefixed to a function call is not portable.
|
||||
AUTISTMASK_DEBUG="$_debug"
|
||||
export AUTISTMASK_DEBUG
|
||||
|
||||
_status=0
|
||||
if [ "$_perm" = yes ]; then
|
||||
_out="$(run_unpriv "$FIXTURE/script/verify-build" 2>&1)" || _status=$?
|
||||
else
|
||||
_out="$("$FIXTURE/script/verify-build" 2>&1)" || _status=$?
|
||||
fi
|
||||
|
||||
_ok=yes
|
||||
_why=""
|
||||
|
||||
if [ "$_status" -ne "$_want_status" ]; then
|
||||
_ok=no
|
||||
_why="exit status $_status, wanted $_want_status"
|
||||
fi
|
||||
|
||||
# Same discipline verify-build itself applies to grep: 0 and 1 are
|
||||
# answers, anything else is not, and must not be read as "no match".
|
||||
_g=0
|
||||
printf '%s\n' "$_out" | grep -q -F -e "$_want_text" || _g=$?
|
||||
case "$_g" in
|
||||
0) ;;
|
||||
1)
|
||||
_ok=no
|
||||
_why="${_why:+$_why; }message did not contain: $_want_text"
|
||||
;;
|
||||
*)
|
||||
_ok=no
|
||||
_why="${_why:+$_why; }grep exited $_g matching the message, so the
|
||||
message was never checked"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$_ok" = yes ]; then
|
||||
PASSED=$((PASSED + 1))
|
||||
echo " ok: $_name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
FAILED=$((FAILED + 1))
|
||||
echo " FAIL: $_name"
|
||||
echo " $_why"
|
||||
echo " --- verify-build output ---"
|
||||
printf '%s\n' "$_out" | sed 's/^/ /'
|
||||
echo " --- end output ---"
|
||||
}
|
||||
|
||||
# --- cases ------------------------------------------------------------------
|
||||
#
|
||||
# Each runs with the fixture as its working directory.
|
||||
|
||||
c_control() { :; }
|
||||
|
||||
c_trailing_space() {
|
||||
cp dist/chrome/src/popup/index.js "dist/chrome/src/popup/index.js "
|
||||
}
|
||||
|
||||
c_embedded_newline() {
|
||||
cp dist/chrome/src/popup/index.js "dist/chrome/src/popup/index.js$NEWLINE"
|
||||
}
|
||||
|
||||
c_dist_symlink() {
|
||||
mv dist dist.real
|
||||
ln -s dist.real dist
|
||||
}
|
||||
|
||||
c_unwalkable_subtree() { chmod 000 dist/chrome/src/content; }
|
||||
|
||||
c_dangling_symlink() {
|
||||
ln -s /nonexistent-target-for-test-verify-build dist/chrome/dangling.js
|
||||
}
|
||||
|
||||
c_dir_symlink() { ln -s src dist/chrome/link-to-dir; }
|
||||
|
||||
c_alias_symlink() { ln -s popup/index.js dist/chrome/src/aliased.js; }
|
||||
|
||||
c_manifest_missing() { rm dist/constants-bundles.txt; }
|
||||
|
||||
c_manifest_empty() { : >dist/constants-bundles.txt; }
|
||||
|
||||
c_manifest_unreadable() { chmod 000 dist/constants-bundles.txt; }
|
||||
|
||||
c_bundle_missing() { rm dist/chrome/src/popup/index.js; }
|
||||
|
||||
c_bundle_empty() { : >dist/chrome/src/popup/index.js; }
|
||||
|
||||
c_bundle_unreadable() { chmod 000 dist/chrome/src/popup/index.js; }
|
||||
|
||||
c_unlisted_extension() {
|
||||
cp dist/chrome/src/popup/index.js dist/chrome/src/popup/extra.mjs
|
||||
}
|
||||
|
||||
c_no_marker() { printf 'var d=4;\n' >dist/chrome/src/popup/index.js; }
|
||||
|
||||
c_both_markers() {
|
||||
printf '/* %s */\n' "$MARKER_ON" >>dist/chrome/src/popup/index.js
|
||||
}
|
||||
|
||||
run_cases() {
|
||||
check_case "control: untouched dist passes" \
|
||||
no release 0 "2 bundle(s) verified $MARKER_OFF" c_control
|
||||
|
||||
check_case "unlisted marker-carrying file, trailing space in name" \
|
||||
no release 1 "carries a debug marker but is absent from" \
|
||||
c_trailing_space
|
||||
|
||||
check_case "unlisted marker-carrying file, newline in name" \
|
||||
no release 1 "carries a debug marker but is absent from" \
|
||||
c_embedded_newline
|
||||
|
||||
check_case "dist/ replaced by a symlink" \
|
||||
no release 1 "dist is a symlink, not a directory." c_dist_symlink
|
||||
|
||||
check_case "unwalkable subtree under dist/" \
|
||||
yes release 1 "enumerating dist/, so part of the tree" \
|
||||
c_unwalkable_subtree
|
||||
|
||||
check_case "dangling symlink under dist/" \
|
||||
no release 1 \
|
||||
"reading dist/chrome/dangling.js, so the file could not be" \
|
||||
c_dangling_symlink
|
||||
|
||||
check_case "symlink to a directory under dist/" \
|
||||
no release 1 \
|
||||
"reading dist/chrome/link-to-dir, so the file could not be" \
|
||||
c_dir_symlink
|
||||
|
||||
check_case "symlink to a listed bundle under an unlisted path" \
|
||||
no release 1 \
|
||||
"dist/chrome/src/aliased.js carries a debug marker but is absent" \
|
||||
c_alias_symlink
|
||||
|
||||
check_case "manifest missing" \
|
||||
no release 1 "dist/constants-bundles.txt is missing." \
|
||||
c_manifest_missing
|
||||
|
||||
check_case "manifest empty" \
|
||||
no release 1 "is empty, so no emitted bundle was found to contain" \
|
||||
c_manifest_empty
|
||||
|
||||
check_case "manifest unreadable" \
|
||||
yes release 1 "is not readable, so nothing was inspected." \
|
||||
c_manifest_unreadable
|
||||
|
||||
check_case "listed bundle missing" \
|
||||
no release 1 \
|
||||
"lists dist/chrome/src/popup/index.js, which does not exist." \
|
||||
c_bundle_missing
|
||||
|
||||
check_case "listed bundle empty" \
|
||||
no release 1 "which is empty. An empty bundle" c_bundle_empty
|
||||
|
||||
check_case "listed bundle unreadable" \
|
||||
yes release 1 \
|
||||
"reading dist/chrome/src/popup/index.js, so the file could not be" \
|
||||
c_bundle_unreadable
|
||||
|
||||
check_case "unlisted extension carrying a marker" \
|
||||
no release 1 \
|
||||
"dist/chrome/src/popup/extra.mjs carries a debug marker but is" \
|
||||
c_unlisted_extension
|
||||
|
||||
check_case "listed bundle carries no marker" \
|
||||
no release 1 "carries no debug marker, so its DEBUG state cannot be" \
|
||||
c_no_marker
|
||||
|
||||
check_case "listed bundle carries both markers" \
|
||||
no release 1 "carries both debug markers, so DEBUG was not resolved" \
|
||||
c_both_markers
|
||||
|
||||
check_case "wrong marker for the requested mode" \
|
||||
no debug 1 "is $MARKER_OFF but this build expects $MARKER_ON" \
|
||||
c_control
|
||||
}
|
||||
|
||||
# --- main --------------------------------------------------------------------
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
[ -x "$VERIFY_BUILD" ] || {
|
||||
echo "test-verify-build: $VERIFY_BUILD is missing or not executable" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Testing script/verify-build failure modes..."
|
||||
probe_permission_runner
|
||||
if [ "$PERM_ENABLED" = yes ]; then
|
||||
echo " permission cases: enabled (runner: $PERM_HOW, proved against" \
|
||||
"a mode-000 file)"
|
||||
fi
|
||||
|
||||
run_cases
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "test-verify-build: $FAILED case(s) FAILED," \
|
||||
"$PASSED passed, $SKIPPED skipped" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$SKIPPED" -ne 0 ]; then
|
||||
cat <<EOF
|
||||
################################################################################
|
||||
## WARNING: $SKIPPED PERMISSION CASE(S) DID NOT RUN, AND THIS RUN DOES NOT
|
||||
## PROVE THEM. This process is uid $(id -u), and no runner subject to file
|
||||
## permissions was available. Tried: $PERM_HOW.
|
||||
## Under root, chmod 000 stops neither find nor grep, so these cases would
|
||||
## have passed without testing anything. They were skipped, not counted:
|
||||
$SKIPPED_NAMES################################################################################
|
||||
EOF
|
||||
echo "test-verify-build: $PASSED case(s) passed," \
|
||||
"$SKIPPED SKIPPED AND NOT PROVEN (see the warning above)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "test-verify-build: $PASSED case(s) passed"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -170,12 +170,6 @@ function restoreView() {
|
||||
fallbackView();
|
||||
}
|
||||
break;
|
||||
case "wait-tx":
|
||||
// Resumes the receipt poll from the persisted broadcast time.
|
||||
if (!txStatus.restoreWait()) {
|
||||
fallbackView();
|
||||
}
|
||||
break;
|
||||
case "success-tx":
|
||||
if (state.viewData && state.viewData.hash) {
|
||||
txStatus.renderSuccess();
|
||||
|
||||
@@ -22,7 +22,6 @@ const RESTORABLE_VIEWS = new Set([
|
||||
"settings-addtoken",
|
||||
"confirm-tx",
|
||||
"transaction",
|
||||
"wait-tx",
|
||||
"success-tx",
|
||||
"error-tx",
|
||||
]);
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
goBack,
|
||||
clearViewStack,
|
||||
onViewLeave,
|
||||
} = require("./helpers");
|
||||
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
|
||||
const {
|
||||
generateMnemonic,
|
||||
hdWalletFromMnemonic,
|
||||
@@ -73,23 +66,13 @@ function switchMode(mode) {
|
||||
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
|
||||
}
|
||||
|
||||
// Wipe the secret material this screen holds in the DOM: a generated or
|
||||
// pasted recovery phrase, an imported private key or extended private key,
|
||||
// and the password that would encrypt them. Registered as the view-leave
|
||||
// handler as well as run on entry, so none of it survives in the hidden
|
||||
// view after the user navigates away by any route, including the Settings
|
||||
// gear and the import itself.
|
||||
function clear() {
|
||||
function show() {
|
||||
$("wallet-mnemonic").value = "";
|
||||
$("import-private-key").value = "";
|
||||
$("import-xprv-key").value = "";
|
||||
$("add-wallet-password").value = "";
|
||||
$("add-wallet-password-confirm").value = "";
|
||||
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
||||
}
|
||||
|
||||
function show() {
|
||||
clear();
|
||||
switchMode("mnemonic");
|
||||
showView("add-wallet");
|
||||
}
|
||||
@@ -305,8 +288,6 @@ async function importXprvKey(ctx) {
|
||||
}
|
||||
|
||||
function init(ctx) {
|
||||
onViewLeave("add-wallet", clear);
|
||||
|
||||
// Tab click handlers
|
||||
$("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic"));
|
||||
$("tab-privkey").addEventListener("click", () => switchMode("privkey"));
|
||||
|
||||
@@ -2,6 +2,7 @@ const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
flashCopyFeedback,
|
||||
balanceLinesForAddress,
|
||||
addressDotHtml,
|
||||
addressTitle,
|
||||
@@ -26,7 +27,8 @@ const {
|
||||
} = require("./send");
|
||||
const { log } = require("../../shared/log");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const exportPrivkey = require("./exportPrivkey");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
const { walletDefect } = require("../../shared/walletDefects");
|
||||
|
||||
// The defect of the wallet the selected address belongs to, or null. Both the
|
||||
@@ -319,12 +321,81 @@ function init(_ctx) {
|
||||
showFlash(defect.shortMessage);
|
||||
return;
|
||||
}
|
||||
// No pushCurrentView() here: exportPrivkey.show() can return
|
||||
// without navigating, so it does its own push.
|
||||
exportPrivkey.show(state.selectedWallet, state.selectedAddress);
|
||||
pushCurrentView();
|
||||
const wallet = state.wallets[state.selectedWallet];
|
||||
const addr = wallet.addresses[state.selectedAddress];
|
||||
const blockieEl = $("export-privkey-jazzicon");
|
||||
blockieEl.innerHTML = "";
|
||||
const bImg = document.createElement("img");
|
||||
bImg.src = makeBlockie(addr.address);
|
||||
bImg.width = 48;
|
||||
bImg.height = 48;
|
||||
bImg.style.imageRendering = "pixelated";
|
||||
bImg.style.borderRadius = "50%";
|
||||
blockieEl.appendChild(bImg);
|
||||
$("export-privkey-title").textContent =
|
||||
wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
|
||||
const exportAddrContainer = $("export-privkey-dot").parentElement;
|
||||
exportAddrContainer.innerHTML = renderAddressHtml(addr.address);
|
||||
attachCopyHandlers(exportAddrContainer);
|
||||
$("export-privkey-password").value = "";
|
||||
$("export-privkey-flash").textContent = "";
|
||||
$("export-privkey-flash").style.visibility = "hidden";
|
||||
$("export-privkey-password-section").classList.remove("hidden");
|
||||
$("export-privkey-result").classList.add("hidden");
|
||||
$("export-privkey-value").textContent = "";
|
||||
showView("export-privkey");
|
||||
});
|
||||
|
||||
exportPrivkey.init();
|
||||
$("btn-export-privkey-confirm").addEventListener("click", async () => {
|
||||
const password = $("export-privkey-password").value;
|
||||
if (!password) {
|
||||
$("export-privkey-flash").textContent = "Password is required.";
|
||||
$("export-privkey-flash").style.visibility = "visible";
|
||||
return;
|
||||
}
|
||||
const btn = $("btn-export-privkey-confirm");
|
||||
btn.disabled = true;
|
||||
btn.classList.add("text-muted");
|
||||
const wallet = state.wallets[state.selectedWallet];
|
||||
try {
|
||||
const secret = await decryptWithPassword(
|
||||
wallet.encryptedSecret,
|
||||
password,
|
||||
);
|
||||
const signer = getSignerForAddress(
|
||||
wallet,
|
||||
state.selectedAddress,
|
||||
secret,
|
||||
);
|
||||
const privateKey = signer.privateKey;
|
||||
$("export-privkey-password-section").classList.add("hidden");
|
||||
$("export-privkey-value").textContent = privateKey;
|
||||
$("export-privkey-result").classList.remove("hidden");
|
||||
$("export-privkey-flash").style.visibility = "hidden";
|
||||
} catch {
|
||||
$("export-privkey-flash").textContent = "Wrong password.";
|
||||
$("export-privkey-flash").style.visibility = "visible";
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("text-muted");
|
||||
}
|
||||
});
|
||||
|
||||
$("export-privkey-value").addEventListener("click", () => {
|
||||
const key = $("export-privkey-value").textContent;
|
||||
if (key) {
|
||||
navigator.clipboard.writeText(key);
|
||||
showFlash("Copied!");
|
||||
flashCopyFeedback($("export-privkey-value"));
|
||||
}
|
||||
});
|
||||
|
||||
$("btn-export-privkey-back").addEventListener("click", () => {
|
||||
$("export-privkey-value").textContent = "";
|
||||
$("export-privkey-password").value = "";
|
||||
goBack();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show };
|
||||
|
||||
@@ -7,7 +7,6 @@ const {
|
||||
hideError,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
onViewLeave,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const {
|
||||
@@ -462,24 +461,7 @@ function findActiveWallet() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Drop the password from the DOM when either approval screen is left. The
|
||||
// approval window navigates on after a signature — approve-tx goes to the
|
||||
// wait screen — and the password must not sit in the hidden view for the
|
||||
// life of that window.
|
||||
function clearTxPassword() {
|
||||
$("approve-tx-password").value = "";
|
||||
hideError("approve-tx-error");
|
||||
}
|
||||
|
||||
function clearSignPassword() {
|
||||
$("approve-sign-password").value = "";
|
||||
hideError("approve-sign-error");
|
||||
}
|
||||
|
||||
function init(ctx) {
|
||||
onViewLeave("approve-tx", clearTxPassword);
|
||||
onViewLeave("approve-sign", clearSignPassword);
|
||||
|
||||
$("approve-remember").addEventListener("change", async () => {
|
||||
state.rememberSiteChoice = $("approve-remember").checked;
|
||||
await saveState();
|
||||
|
||||
@@ -21,7 +21,6 @@ const {
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
onViewLeave,
|
||||
} = require("./helpers");
|
||||
const { state, currentNetwork } = require("../../shared/state");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
@@ -391,17 +390,7 @@ async function checkRecipientHistory(txInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the password from the DOM. Registered as the view-leave handler so
|
||||
// it does not sit in the hidden view once the screen navigates on — to the
|
||||
// wait screen after a send, or anywhere else the user goes.
|
||||
function clearPassword() {
|
||||
$("confirm-tx-password").value = "";
|
||||
hideError("confirm-tx-password-error");
|
||||
}
|
||||
|
||||
function init(ctx) {
|
||||
onViewLeave("confirm-tx", clearPassword);
|
||||
|
||||
$("btn-confirm-send").addEventListener("click", async () => {
|
||||
const password = $("confirm-tx-password").value;
|
||||
if (!password) {
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
goBack,
|
||||
clearViewStack,
|
||||
onViewLeave,
|
||||
} = require("./helpers");
|
||||
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const {
|
||||
@@ -16,34 +9,22 @@ const {
|
||||
let deleteWalletIndex = null;
|
||||
let ctx = null;
|
||||
|
||||
// Drop the password from the DOM and the wallet selection from the
|
||||
// closure. Registered as the view-leave handler as well as run on entry,
|
||||
// so the typed password does not sit in the hidden view after the user
|
||||
// navigates away by any route, including the Settings gear.
|
||||
function clear() {
|
||||
deleteWalletIndex = null;
|
||||
$("delete-wallet-password").value = "";
|
||||
$("delete-wallet-flash").textContent = "";
|
||||
$("delete-wallet-flash").style.visibility = "hidden";
|
||||
}
|
||||
|
||||
function show(walletIdx) {
|
||||
clear();
|
||||
deleteWalletIndex = walletIdx;
|
||||
const wallet = state.wallets[walletIdx];
|
||||
$("delete-wallet-name").textContent =
|
||||
wallet.name || "Wallet " + (walletIdx + 1);
|
||||
$("delete-wallet-password").value = "";
|
||||
$("delete-wallet-flash").textContent = "";
|
||||
$("delete-wallet-flash").style.visibility = "hidden";
|
||||
showView("delete-wallet-confirm");
|
||||
}
|
||||
|
||||
function init(_ctx) {
|
||||
ctx = _ctx;
|
||||
|
||||
onViewLeave("delete-wallet-confirm", clear);
|
||||
|
||||
// No wipe here: goBack() routes through showView(), which runs the
|
||||
// leave hook.
|
||||
$("btn-delete-wallet-back").addEventListener("click", () => {
|
||||
deleteWalletIndex = null;
|
||||
goBack();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
// Private key export for a single address.
|
||||
//
|
||||
// The key controls the address outright — anyone holding it can move every
|
||||
// token in it, from any device, forever — so this screen is handled under
|
||||
// the same rules as the recovery phrase screen (./showPhrase.js):
|
||||
//
|
||||
// 1. Nothing is decrypted, no key is derived, and nothing is written into
|
||||
// the DOM until decryptWithPassword has accepted the password.
|
||||
// 2. Leaving the screen by any path wipes it, via the onViewLeave hook,
|
||||
// and a decrypt still in flight when that happens is discarded
|
||||
// instead of written (revealGeneration).
|
||||
// 3. The key never reaches the logger. This module deliberately does not
|
||||
// import src/shared/log.js.
|
||||
//
|
||||
// The key is also never assigned to `state`, so it cannot be persisted to
|
||||
// extension storage, and "export-privkey" is excluded from RESTORABLE_VIEWS
|
||||
// so the popup can never reopen onto it.
|
||||
|
||||
const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
flashCopyFeedback,
|
||||
goBack,
|
||||
onViewLeave,
|
||||
pushCurrentView,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
} = require("./helpers");
|
||||
const { state } = require("../../shared/state");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
|
||||
const VIEW = "export-privkey";
|
||||
|
||||
let walletIndex = null;
|
||||
let addressIndex = null;
|
||||
|
||||
// Bumped by every clear(), which is what leaving the screen runs. reveal()
|
||||
// captures it before awaiting the decrypt and refuses to touch the DOM if
|
||||
// it has moved: a decrypt still in flight when the screen is left would
|
||||
// otherwise write the key *after* the wipe, with nothing scheduled to wipe
|
||||
// it again, leaving it in the hidden view for the life of the popup.
|
||||
let revealGeneration = 0;
|
||||
|
||||
// True only if the reveal that captured `generation` is still the live one:
|
||||
// the screen has not been left, cleared, or re-entered for another address
|
||||
// since it started.
|
||||
function isCurrentReveal(generation) {
|
||||
return (
|
||||
generation === revealGeneration &&
|
||||
walletIndex !== null &&
|
||||
addressIndex !== null &&
|
||||
state.currentView === VIEW
|
||||
);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
$("export-privkey-flash").textContent = message;
|
||||
$("export-privkey-flash").style.visibility = "visible";
|
||||
}
|
||||
|
||||
// Wipe every trace of the key and drop the address selection. Safe to call
|
||||
// when nothing was ever revealed, and safe to call twice.
|
||||
function clear() {
|
||||
walletIndex = null;
|
||||
addressIndex = null;
|
||||
revealGeneration += 1;
|
||||
$("export-privkey-value").textContent = "";
|
||||
$("export-privkey-password").value = "";
|
||||
$("export-privkey-result").classList.add("hidden");
|
||||
$("export-privkey-password-section").classList.remove("hidden");
|
||||
$("export-privkey-flash").textContent = "";
|
||||
$("export-privkey-flash").style.visibility = "hidden";
|
||||
}
|
||||
|
||||
function show(walletIdx, addrIdx) {
|
||||
const wallet = state.wallets[walletIdx];
|
||||
const addr = wallet && wallet.addresses[addrIdx];
|
||||
if (!addr) {
|
||||
showFlash("That address is no longer available.");
|
||||
return;
|
||||
}
|
||||
clear();
|
||||
walletIndex = walletIdx;
|
||||
addressIndex = addrIdx;
|
||||
|
||||
const blockieEl = $("export-privkey-jazzicon");
|
||||
blockieEl.innerHTML = "";
|
||||
const img = document.createElement("img");
|
||||
img.src = makeBlockie(addr.address);
|
||||
img.width = 48;
|
||||
img.height = 48;
|
||||
img.style.imageRendering = "pixelated";
|
||||
img.style.borderRadius = "50%";
|
||||
blockieEl.appendChild(img);
|
||||
|
||||
$("export-privkey-title").textContent =
|
||||
wallet.name + " — Address " + (addrIdx + 1);
|
||||
const addrContainer = $("export-privkey-dot").parentElement;
|
||||
addrContainer.innerHTML = renderAddressHtml(addr.address);
|
||||
attachCopyHandlers(addrContainer);
|
||||
|
||||
// Pushed here rather than by the caller: this function can return
|
||||
// without navigating, and a push that happened anyway would leave an
|
||||
// entry on the stack that no screen transition matches.
|
||||
pushCurrentView();
|
||||
showView(VIEW);
|
||||
}
|
||||
|
||||
async function reveal() {
|
||||
const password = $("export-privkey-password").value;
|
||||
if (!password) {
|
||||
fail("Password is required.");
|
||||
return;
|
||||
}
|
||||
if (walletIndex === null) {
|
||||
fail("No address is selected.");
|
||||
return;
|
||||
}
|
||||
const wallet = state.wallets[walletIndex];
|
||||
|
||||
const btn = $("btn-export-privkey-confirm");
|
||||
btn.disabled = true;
|
||||
btn.classList.add("text-muted");
|
||||
const generation = revealGeneration;
|
||||
try {
|
||||
const secret = await decryptWithPassword(
|
||||
wallet.encryptedSecret,
|
||||
password,
|
||||
);
|
||||
// The only suspension point in this view, and the gate on the only
|
||||
// place a secret is written: if the screen was left while the
|
||||
// decrypt ran, the wipe has already happened, so the key is not
|
||||
// even derived, let alone written.
|
||||
if (!isCurrentReveal(generation)) return;
|
||||
const signer = getSignerForAddress(wallet, addressIndex, secret);
|
||||
$("export-privkey-password").value = "";
|
||||
$("export-privkey-password-section").classList.add("hidden");
|
||||
$("export-privkey-value").textContent = signer.privateKey;
|
||||
$("export-privkey-result").classList.remove("hidden");
|
||||
$("export-privkey-flash").textContent = "";
|
||||
$("export-privkey-flash").style.visibility = "hidden";
|
||||
} catch {
|
||||
if (!isCurrentReveal(generation)) return;
|
||||
fail("That password is not correct. Please try again.");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("text-muted");
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
onViewLeave(VIEW, clear);
|
||||
|
||||
// No wipe here: goBack() routes through showView(), which runs the
|
||||
// leave hook. A per-button wipe would only cover this one path.
|
||||
$("btn-export-privkey-back").addEventListener("click", () => {
|
||||
goBack();
|
||||
});
|
||||
|
||||
$("btn-export-privkey-confirm").addEventListener("click", reveal);
|
||||
|
||||
$("export-privkey-value").addEventListener("click", () => {
|
||||
const key = $("export-privkey-value").textContent;
|
||||
if (!key) return;
|
||||
navigator.clipboard.writeText(key);
|
||||
showFlash("Copied!");
|
||||
flashCopyFeedback($("export-privkey-value"));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show };
|
||||
@@ -12,9 +12,8 @@ const {
|
||||
const { state, currentAddress } = require("../../shared/state");
|
||||
let ctx;
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const { resolveSymbol } = require("../../shared/tokenList");
|
||||
const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
|
||||
const { isLowHolderCount } = require("../../shared/holders");
|
||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||
const { getAddress } = require("ethers");
|
||||
|
||||
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
||||
@@ -117,6 +116,14 @@ function updateToValidation() {
|
||||
}
|
||||
}
|
||||
|
||||
function isSpoofedToken(t) {
|
||||
const upper = (t.symbol || "").toUpperCase();
|
||||
if (!KNOWN_SYMBOLS.has(upper)) return false;
|
||||
const legit = KNOWN_SYMBOLS.get(upper);
|
||||
if (legit === null) return true;
|
||||
return t.address.toLowerCase() !== legit;
|
||||
}
|
||||
|
||||
function renderSendTokenSelect(addr) {
|
||||
const sel = $("send-token");
|
||||
sel.innerHTML = '<option value="ETH">ETH</option>';
|
||||
@@ -124,7 +131,7 @@ function renderSendTokenSelect(addr) {
|
||||
(state.fraudContracts || []).map((a) => a.toLowerCase()),
|
||||
);
|
||||
for (const t of addr.tokenBalances || []) {
|
||||
if (isSpoofedSymbol(t.symbol, t.address)) continue;
|
||||
if (isSpoofedToken(t)) continue;
|
||||
if (fraudSet.has(t.address.toLowerCase())) continue;
|
||||
// An unknown holder count does not withhold a token the user holds:
|
||||
// only a count the explorer actually reported as below the threshold
|
||||
|
||||
@@ -16,36 +16,11 @@ const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const { log } = require("../../shared/log");
|
||||
|
||||
// Receipt poll cadence and the deadline after which the wait is reported as
|
||||
// a timeout. Both are documented in the WaitTx section of README.md.
|
||||
const POLL_INTERVAL_MS = 10000;
|
||||
const TIMEOUT_MS = 60000;
|
||||
|
||||
// How many receipt lookups may fail in a row before the wait is ended and
|
||||
// the failure reported. A lookup that throws says nothing about the
|
||||
// transaction, so one must not end the wait — but an RPC that never answers
|
||||
// (a mistyped URL in settings is the ordinary case) must not leave the wait
|
||||
// running forever either, least of all a persisted one that every popup
|
||||
// open would resume. Six is 60 seconds at the poll cadence: the same
|
||||
// patience the confirmation deadline gets. Any lookup that answers, with a
|
||||
// receipt or with null, resets the count.
|
||||
const MAX_CONSECUTIVE_LOOKUP_FAILURES = 6;
|
||||
|
||||
let ctx;
|
||||
let elapsedTimer = null;
|
||||
let pollTimer = null;
|
||||
|
||||
// Identifies the wait currently on screen. Bumped by endWait(), so a timer
|
||||
// callback or an in-flight receipt lookup that outlives its wait can tell
|
||||
// that it is stale and leave the current view alone. Without it, a receipt
|
||||
// resolving after the wait has ended renders over whatever view replaced it.
|
||||
let waitId = 0;
|
||||
|
||||
// End the wait on screen: stop its timers and invalidate its pending async
|
||||
// work. Called on receipt, on timeout, when a new wait starts, and when the
|
||||
// user navigates away.
|
||||
function endWait() {
|
||||
waitId++;
|
||||
function clearTimers() {
|
||||
if (elapsedTimer) {
|
||||
clearInterval(elapsedTimer);
|
||||
elapsedTimer = null;
|
||||
@@ -72,13 +47,8 @@ function blockNumberHtml(blockNumber) {
|
||||
return copyableHtml(num) + etherscanLinkHtml(link);
|
||||
}
|
||||
|
||||
// Render the wait view and start polling for the receipt. broadcastTime is
|
||||
// when the transaction was broadcast, which is what the elapsed counter and
|
||||
// the timeout deadline are both measured from; pollNow runs one lookup
|
||||
// immediately instead of waiting a full poll interval.
|
||||
function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
endWait();
|
||||
const id = waitId;
|
||||
function showWait(txInfo, txHash) {
|
||||
clearTimers();
|
||||
|
||||
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
||||
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
||||
@@ -86,130 +56,41 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
|
||||
attachCopyHandlers("view-wait-tx");
|
||||
|
||||
// Persisted so closing and reopening the popup resumes this wait
|
||||
// instead of silently abandoning it.
|
||||
state.viewData = {
|
||||
pendingWait: {
|
||||
txInfo: txInfo,
|
||||
hash: txHash,
|
||||
broadcastTime: broadcastTime,
|
||||
},
|
||||
};
|
||||
const broadcastTime = Date.now();
|
||||
$("wait-tx-status").textContent = "Waiting for confirmation... 0s";
|
||||
|
||||
function renderElapsed() {
|
||||
elapsedTimer = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
|
||||
$("wait-tx-status").textContent =
|
||||
"Waiting for confirmation... " + elapsed + "s";
|
||||
}
|
||||
renderElapsed();
|
||||
|
||||
elapsedTimer = setInterval(() => {
|
||||
if (id !== waitId) return;
|
||||
renderElapsed();
|
||||
}, 1000);
|
||||
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
async function poll() {
|
||||
if (id !== waitId) return;
|
||||
let receipt = null;
|
||||
let answered = true;
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
receipt = await provider.getTransactionReceipt(txHash);
|
||||
const receipt = await provider.getTransactionReceipt(txHash);
|
||||
if (receipt) {
|
||||
showSuccess(txInfo, txHash, receipt.blockNumber);
|
||||
}
|
||||
} catch (e) {
|
||||
// A thrown lookup means "no answer this tick", not "no
|
||||
// receipt": the RPC failed, the chain said nothing. Declaring
|
||||
// the timeout off it would report a confirmed transaction as
|
||||
// failed — which matters most on a resumed wait, where the
|
||||
// first poll is already past the deadline.
|
||||
answered = false;
|
||||
log.errorf("poll receipt failed:", e.message);
|
||||
}
|
||||
// The lookup is async: the wait may have ended while it was in
|
||||
// flight, in which case this result must not touch the view.
|
||||
if (id !== waitId) return;
|
||||
// Exactly one outcome per wait. A receipt wins even on the tick
|
||||
// that crosses the deadline, because the transaction did confirm.
|
||||
if (receipt) {
|
||||
showSuccess(txInfo, txHash, receipt.blockNumber);
|
||||
return;
|
||||
}
|
||||
if (!answered) {
|
||||
consecutiveFailures++;
|
||||
// The failure is the user's news, and it is a different fact
|
||||
// from "the transaction did not confirm" — the chain was never
|
||||
// asked. Ending the wait here is what keeps it bounded and
|
||||
// gives the user a Done button to leave by.
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_LOOKUP_FAILURES) {
|
||||
showError(
|
||||
txInfo,
|
||||
txHash,
|
||||
"The network could not be reached to check this transaction — " +
|
||||
MAX_CONSECUTIVE_LOOKUP_FAILURES +
|
||||
" lookups failed in a row. Check the RPC URL in Settings. The transaction may still have confirmed — check Etherscan.",
|
||||
);
|
||||
}
|
||||
// Otherwise keep polling: the next tick may answer.
|
||||
return;
|
||||
}
|
||||
consecutiveFailures = 0;
|
||||
if (Date.now() - broadcastTime >= TIMEOUT_MS) {
|
||||
|
||||
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
|
||||
if (elapsed >= 60) {
|
||||
showError(
|
||||
txInfo,
|
||||
txHash,
|
||||
"Transaction was not confirmed within 60 seconds. It may still confirm later \u2014 check Etherscan.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pollTimer = setInterval(poll, POLL_INTERVAL_MS);
|
||||
}, 10000);
|
||||
|
||||
showView("wait-tx");
|
||||
|
||||
if (pollNow) poll();
|
||||
}
|
||||
|
||||
function showWait(txInfo, txHash) {
|
||||
startWait(txInfo, txHash, Date.now(), false);
|
||||
}
|
||||
|
||||
// Resume a wait persisted by a previous popup session. The deadline still
|
||||
// runs from the original broadcast, so a wait that has already outlived it
|
||||
// resolves on the immediate first poll rather than restarting the clock.
|
||||
// Returns false when there is nothing resumable to resume. Every field
|
||||
// startWait() goes on to use is validated, not just the presence of the
|
||||
// containers: txInfo.to reaches addressTitle(), which calls
|
||||
// address.toLowerCase(), and txInfo.amount is rendered into the summary, so
|
||||
// an object merely missing one of them throws a TypeError out of
|
||||
// restoreView() — which init() does not guard, skipping the rest of popup
|
||||
// init and leaving wait-tx on screen with no back control. A non-numeric
|
||||
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and
|
||||
// txInfo.tokenSymbol are deliberately unchecked: they are compared and
|
||||
// coalesced rather than dereferenced, and tokenSymbol is null for ETH.
|
||||
function restoreWait() {
|
||||
const d = state.viewData;
|
||||
if (!d || !d.pendingWait) return false;
|
||||
const w = d.pendingWait;
|
||||
if (!w.hash) return false;
|
||||
// typeof [] is "object", so an array passes an object check.
|
||||
const info = w.txInfo;
|
||||
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
|
||||
// A string is the whole requirement: the empty string is what a
|
||||
// contract-deployment approval persists (approval.js writes `to: toAddr
|
||||
// || ""`), and both fields render harmlessly when empty, so refusing it
|
||||
// would abandon a wait the live path itself created.
|
||||
if (typeof info.to !== "string") return false;
|
||||
if (typeof info.amount !== "string") return false;
|
||||
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
||||
return false;
|
||||
}
|
||||
startWait(w.txInfo, w.hash, w.broadcastTime, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function showSuccess(txInfo, txHash, blockNumber) {
|
||||
endWait();
|
||||
clearTimers();
|
||||
|
||||
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
||||
state.viewData = {
|
||||
@@ -301,7 +182,7 @@ function renderSuccess() {
|
||||
}
|
||||
|
||||
function showError(txInfo, txHash, message) {
|
||||
endWait();
|
||||
clearTimers();
|
||||
|
||||
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
||||
state.viewData = {
|
||||
@@ -337,9 +218,6 @@ function isApprovalPopup() {
|
||||
}
|
||||
|
||||
function navigateBack() {
|
||||
// Nothing should still be polling by now, but leaving a view is the
|
||||
// point at which its timers must be gone.
|
||||
endWait();
|
||||
if (isApprovalPopup()) {
|
||||
window.close();
|
||||
return;
|
||||
@@ -364,12 +242,4 @@ function init(_ctx) {
|
||||
$("btn-error-tx-done").addEventListener("click", navigateBack);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
showWait,
|
||||
restoreWait,
|
||||
endWait,
|
||||
showError,
|
||||
renderSuccess,
|
||||
renderError,
|
||||
};
|
||||
module.exports = { init, showWait, showError, renderSuccess, renderError };
|
||||
|
||||
@@ -11,9 +11,8 @@ const {
|
||||
const { ERC20_ABI } = require("./constants");
|
||||
const { log, debugFetch } = require("./log");
|
||||
const { deriveAddressFromXpub } = require("./wallet");
|
||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||
|
||||
// Use a static network to skip auto-detection (which can fail and cause
|
||||
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
||||
@@ -90,11 +89,15 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
// Skip spam tokens the user never asked to see
|
||||
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
||||
|
||||
// Skip tokens spoofing a known symbol from a different address.
|
||||
// Every row here is an ERC-20 the explorer reported, so it has a
|
||||
// contract address; the native ETH balance is fetched over RPC in
|
||||
// refreshBalances and never passes through this loop.
|
||||
if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue;
|
||||
// Skip tokens spoofing a known symbol from a different address
|
||||
const sym = (item.token.symbol || "").toUpperCase();
|
||||
const legitAddr = KNOWN_SYMBOLS.get(sym);
|
||||
if (
|
||||
legitAddr !== undefined &&
|
||||
legitAddr !== null &&
|
||||
tokenAddr !== legitAddr
|
||||
)
|
||||
continue;
|
||||
|
||||
balances.push({
|
||||
address: item.token.address_hash,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// The known-symbol spoof rule, in one place.
|
||||
//
|
||||
// A token that borrows a known symbol from a contract that is not the one
|
||||
// that symbol belongs to is a spoof, and the wallet hides it. Three surfaces
|
||||
// ask that question — the transaction history, the Send token selector and
|
||||
// the balance list — and they must answer it identically: a token the history
|
||||
// calls fake while the balance list lists it as a holding is worse than
|
||||
// either verdict alone, because the balance list is where the user forms
|
||||
// their belief about what they own (issue #235).
|
||||
//
|
||||
// KNOWN_SYMBOLS maps a symbol to the lowercased contract address that may
|
||||
// bear it, or to null. Null means the symbol belongs to the native asset,
|
||||
// which has no contract at all, so no contract may bear it and every one
|
||||
// that does is a spoof. "ETH" is the only such entry today; the rule is
|
||||
// written so that a second one needs no change here or at any call site.
|
||||
|
||||
const { KNOWN_SYMBOLS } = require("./tokenList");
|
||||
|
||||
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
||||
// over the address, not part of its identity.
|
||||
function normalizeAddress(addr) {
|
||||
return (addr || "").toLowerCase();
|
||||
}
|
||||
|
||||
// True when a token bearing `symbol` from contract `contractAddress` is
|
||||
// impersonating a known symbol.
|
||||
//
|
||||
// An empty contract address is the native asset, which is never a spoof:
|
||||
// this is what keeps the user's real ETH out of the rule, and it holds for
|
||||
// any symbol that becomes null-mapped later, not just for ETH.
|
||||
function isSpoofedSymbol(symbol, contractAddress) {
|
||||
const contract = normalizeAddress(contractAddress);
|
||||
if (!contract) return false;
|
||||
const sym = (symbol || "").toUpperCase();
|
||||
if (!KNOWN_SYMBOLS.has(sym)) return false;
|
||||
const legit = KNOWN_SYMBOLS.get(sym);
|
||||
if (legit === null) return true;
|
||||
return contract !== normalizeAddress(legit);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isSpoofedSymbol,
|
||||
};
|
||||
@@ -8,9 +8,8 @@
|
||||
|
||||
const { formatEther, formatUnits } = require("ethers");
|
||||
const { log, debugFetch } = require("./log");
|
||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||
|
||||
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
||||
// over the address, not part of its identity. Every address comparison in
|
||||
@@ -246,6 +245,18 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if a token transfer is spoofing a known symbol.
|
||||
// Returns true if the symbol matches a known token but the contract
|
||||
// address doesn't match the legitimate one.
|
||||
function isSpoofedSymbol(tx) {
|
||||
if (!tx.contractAddress) return false;
|
||||
const symbol = (tx.symbol || "").toUpperCase();
|
||||
if (!KNOWN_SYMBOLS.has(symbol)) return false;
|
||||
const legit = KNOWN_SYMBOLS.get(symbol);
|
||||
if (legit === null) return true; // "ETH" as ERC-20 is always fake
|
||||
return normalizeAddress(tx.contractAddress) !== normalizeAddress(legit);
|
||||
}
|
||||
|
||||
// Pure filter function. Takes raw transactions and filter settings,
|
||||
// returns { transactions, newFraudContracts }.
|
||||
function filterTransactions(txs, filters = {}) {
|
||||
@@ -272,7 +283,7 @@ function filterTransactions(txs, filters = {}) {
|
||||
const contract = normalizeAddress(tx.contractAddress);
|
||||
|
||||
// Filter spoofed known symbols and record the fraud contract
|
||||
if (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) {
|
||||
if (hideSpoofed && isSpoofedSymbol(tx)) {
|
||||
if (contract && !fraudSet.has(contract)) {
|
||||
fraudSet.add(contract);
|
||||
newFraud.push(contract);
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
// Tests for the private key export screen (issue #221).
|
||||
//
|
||||
// The screen holds the one secret that owns an address outright, so what is
|
||||
// pinned here is disposal: the key is wiped from the DOM whenever the screen
|
||||
// is left by any route, and a decrypt still in flight when the screen is
|
||||
// left never writes at all. That last case is the one a per-button wipe and
|
||||
// a naive leave hook both miss — the write lands after the wipe, with
|
||||
// nothing scheduled to wipe it again.
|
||||
//
|
||||
// The view is driven against a minimal DOM stub rather than a real browser:
|
||||
// the module is deliberately shaped like src/popup/views/showPhrase.js, with
|
||||
// no dependency that needs a document beyond the nodes it reads and writes.
|
||||
|
||||
const mockPrivateKey = "0x" + "ab".repeat(32);
|
||||
|
||||
jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x");
|
||||
jest.mock("../src/shared/vault", () => ({
|
||||
decryptWithPassword: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/shared/wallet", () => ({
|
||||
getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
|
||||
}));
|
||||
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
|
||||
const VIEW = "export-privkey";
|
||||
const PASSWORD = "correct horse battery";
|
||||
|
||||
// ------------------------------------------------------------ DOM stub
|
||||
|
||||
function makeElement(id, withParent) {
|
||||
const classes = new Set();
|
||||
const el = {
|
||||
id,
|
||||
textContent: "",
|
||||
value: "",
|
||||
innerHTML: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
listeners: {},
|
||||
classList: {
|
||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
||||
contains: (n) => classes.has(n),
|
||||
toggle: (n, force) => {
|
||||
const on = force === undefined ? !classes.has(n) : force;
|
||||
if (on) classes.add(n);
|
||||
else classes.delete(n);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
addEventListener: (name, fn) => {
|
||||
el.listeners[name] = el.listeners[name] || [];
|
||||
el.listeners[name].push(fn);
|
||||
},
|
||||
appendChild: () => {},
|
||||
remove: () => {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
el.parentElement = withParent ? makeElement(id + "-parent", false) : null;
|
||||
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, true));
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => makeElement("created", false),
|
||||
addEventListener: () => {},
|
||||
body: { prepend: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ harness
|
||||
|
||||
function load() {
|
||||
jest.resetModules();
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
globalThis.document = makeDocument();
|
||||
|
||||
const helpers = require("../src/popup/views/helpers");
|
||||
const { state } = require("../src/shared/state");
|
||||
const vault = require("../src/shared/vault");
|
||||
const wallet = require("../src/shared/wallet");
|
||||
const exportPrivkey = require("../src/popup/views/exportPrivkey");
|
||||
|
||||
state.wallets = [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
type: "key",
|
||||
encryptedSecret: "ciphertext",
|
||||
addresses: [
|
||||
{
|
||||
address: "0x" + "11".repeat(20),
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
},
|
||||
{
|
||||
address: "0x" + "22".repeat(20),
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
state.viewStack = [];
|
||||
state.currentView = "address";
|
||||
|
||||
exportPrivkey.init();
|
||||
return { helpers, state, vault, wallet, exportPrivkey };
|
||||
}
|
||||
|
||||
function click(id) {
|
||||
const el = globalThis.document.getElementById(id);
|
||||
return Promise.all((el.listeners.click || []).map((fn) => fn()));
|
||||
}
|
||||
|
||||
function node(id) {
|
||||
return globalThis.document.getElementById(id);
|
||||
}
|
||||
|
||||
// Start a reveal and hand back both the promise it returns and the resolver
|
||||
// for the decrypt it is waiting on, so a test can navigate away mid-flight.
|
||||
function startReveal(vault) {
|
||||
let resolveDecrypt;
|
||||
let rejectDecrypt;
|
||||
vault.decryptWithPassword.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
resolveDecrypt = resolve;
|
||||
rejectDecrypt = reject;
|
||||
}),
|
||||
);
|
||||
node("export-privkey-password").value = PASSWORD;
|
||||
const pending = click("btn-export-privkey-confirm");
|
||||
return {
|
||||
pending,
|
||||
resolve: (v) => resolveDecrypt(v),
|
||||
reject: (e) => rejectDecrypt(e),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ tests
|
||||
|
||||
describe("a decrypt still running when the screen is left", () => {
|
||||
// The load-bearing case. Without the liveness guard in reveal(), the
|
||||
// write lands after the leave hook has already wiped, and the key sits
|
||||
// in the hidden view for the life of the popup.
|
||||
test("never writes the key into the DOM", async () => {
|
||||
const { helpers, vault, wallet, exportPrivkey } = load();
|
||||
exportPrivkey.show(0, 0);
|
||||
|
||||
const reveal = startReveal(vault);
|
||||
// The settings gear, mid-decrypt.
|
||||
helpers.showView("settings");
|
||||
reveal.resolve("wallet secret");
|
||||
await reveal.pending;
|
||||
|
||||
expect(node("export-privkey-value").textContent).toBe("");
|
||||
// Nothing was even derived: the guard sits in front of the
|
||||
// derivation, not just in front of the write.
|
||||
expect(wallet.getSignerForAddress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The generation counter, not merely the current-view check: by the time
|
||||
// the stale decrypt resolves the user is back on the screen, so a guard
|
||||
// that only asked "is this view showing?" would let the write through.
|
||||
test("never writes it after the screen is re-entered", async () => {
|
||||
const { helpers, vault, exportPrivkey } = load();
|
||||
exportPrivkey.show(0, 0);
|
||||
|
||||
const stale = startReveal(vault);
|
||||
helpers.showView("settings");
|
||||
exportPrivkey.show(0, 1);
|
||||
expect(node("export-privkey-value").textContent).toBe("");
|
||||
|
||||
stale.resolve("wallet secret");
|
||||
await stale.pending;
|
||||
|
||||
expect(node("export-privkey-value").textContent).toBe("");
|
||||
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Same hole on the failure path: a wrong-password error written after
|
||||
// the wipe would restore the flash line on a screen the user has left.
|
||||
test("never writes the failure message either", async () => {
|
||||
const { helpers, vault, exportPrivkey } = load();
|
||||
exportPrivkey.show(0, 0);
|
||||
|
||||
const reveal = startReveal(vault);
|
||||
helpers.showView("settings");
|
||||
reveal.reject(new Error("decryption failed"));
|
||||
await reveal.pending;
|
||||
|
||||
expect(node("export-privkey-flash").textContent).toBe("");
|
||||
expect(node("export-privkey-flash").style.visibility).toBe("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a reveal that is not interrupted", () => {
|
||||
// Guards the guard: a liveness check that rejected every write would
|
||||
// pass every test above and ship a screen that reveals nothing.
|
||||
test("puts the key on screen", async () => {
|
||||
const { vault, exportPrivkey } = load();
|
||||
exportPrivkey.show(0, 0);
|
||||
|
||||
const reveal = startReveal(vault);
|
||||
reveal.resolve("wallet secret");
|
||||
await reveal.pending;
|
||||
|
||||
expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
|
||||
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
|
||||
false,
|
||||
);
|
||||
// The password is dropped as soon as it has been spent.
|
||||
expect(node("export-privkey-password").value).toBe("");
|
||||
});
|
||||
|
||||
test("writes nothing before the password is accepted", async () => {
|
||||
const { vault, exportPrivkey } = load();
|
||||
exportPrivkey.show(0, 0);
|
||||
|
||||
const reveal = startReveal(vault);
|
||||
expect(node("export-privkey-value").textContent).toBe("");
|
||||
reveal.resolve("wallet secret");
|
||||
await reveal.pending;
|
||||
});
|
||||
|
||||
test("reveals nothing when the password is wrong", async () => {
|
||||
const { vault, exportPrivkey } = load();
|
||||
exportPrivkey.show(0, 0);
|
||||
|
||||
const reveal = startReveal(vault);
|
||||
reveal.reject(new Error("decryption failed"));
|
||||
await reveal.pending;
|
||||
|
||||
expect(node("export-privkey-value").textContent).toBe("");
|
||||
expect(node("export-privkey-flash").textContent).toBe(
|
||||
"That password is not correct. Please try again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaving the screen after the key is on it", () => {
|
||||
async function revealed() {
|
||||
const loaded = load();
|
||||
loaded.exportPrivkey.show(0, 0);
|
||||
const reveal = startReveal(loaded.vault);
|
||||
reveal.resolve("wallet secret");
|
||||
await reveal.pending;
|
||||
expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
test("the Back button clears the key", async () => {
|
||||
await revealed();
|
||||
await click("btn-export-privkey-back");
|
||||
|
||||
expect(node("export-privkey-value").textContent).toBe("");
|
||||
expect(node("export-privkey-password").value).toBe("");
|
||||
});
|
||||
|
||||
test("the settings gear clears the key", async () => {
|
||||
const { helpers } = await revealed();
|
||||
helpers.showView("settings");
|
||||
|
||||
expect(node("export-privkey-value").textContent).toBe("");
|
||||
expect(node("export-privkey-password").value).toBe("");
|
||||
// And the screen is back to its password prompt, not to a result
|
||||
// panel that would flash an empty well on the next visit.
|
||||
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
node("export-privkey-password-section").classList.contains(
|
||||
"hidden",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Any other navigation: the same hook covers routes that do not exist
|
||||
// yet, which is the point of registering it on the view rather than on
|
||||
// the controls that leave it.
|
||||
test("any other navigation clears the key", async () => {
|
||||
const { helpers } = await revealed();
|
||||
helpers.showView("main");
|
||||
|
||||
expect(node("export-privkey-value").textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("views the popup may reopen onto", () => {
|
||||
// Restoring onto this screen would put a private key on display with no
|
||||
// password prompt in front of it, on a popup reopened by accident.
|
||||
test("the private key export screen is not restorable", () => {
|
||||
expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false);
|
||||
});
|
||||
|
||||
test("it is still a registered view", () => {
|
||||
const { helpers } = load();
|
||||
expect(helpers.VIEWS).toContain(VIEW);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the key cannot reach the logger", () => {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, "..", "src", "popup", "views", "exportPrivkey.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("the view does not import src/shared/log.js", () => {
|
||||
expect(source).not.toMatch(/require\(["'][^"']*shared\/log["']\)/);
|
||||
});
|
||||
|
||||
test("the view calls no logger method", () => {
|
||||
expect(source).not.toMatch(/\blog\.(debugf|infof|warnf|errorf)\b/);
|
||||
});
|
||||
});
|
||||
@@ -1,296 +0,0 @@
|
||||
// Tests for the known-symbol spoof rule (src/shared/symbolSpoof.js) and for
|
||||
// its application on all three surfaces that show tokens: the transaction
|
||||
// history, the Send token selector, and the balance list.
|
||||
//
|
||||
// Issue #235: the three surfaces disagreed about what a `null` entry in
|
||||
// KNOWN_SYMBOLS means. The history and the selector read it as "no contract
|
||||
// may bear this symbol" and filtered a fake `ETH` ERC-20; the balance list
|
||||
// read it as "no comparison is possible" and listed the fake token next to
|
||||
// the user's real ETH, which is where a user forms their belief about what
|
||||
// they own. The rule now lives in one module, so a fourth surface cannot
|
||||
// reintroduce a fourth reading, and these tests assert the same attack on
|
||||
// each surface.
|
||||
//
|
||||
// Nothing here touches the network: global.fetch is a throwing stub and the
|
||||
// only fetch path in the modules under test (debugFetch, from
|
||||
// src/shared/log) is mocked at the module boundary.
|
||||
|
||||
// The RPC provider is replaced so that refreshBalances can be driven end to
|
||||
// end: the native balance it reports must survive a balance list in which
|
||||
// every ERC-20 row is a fake ETH. Everything else in ethers is the real
|
||||
// module, including the formatters the assertions depend on.
|
||||
jest.mock("ethers", () => {
|
||||
const actual = jest.requireActual("ethers");
|
||||
class StubProvider {
|
||||
async getBalance() {
|
||||
return 1234500000000000000n;
|
||||
}
|
||||
async lookupAddress() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
JsonRpcProvider: StubProvider,
|
||||
Network: { from: () => ({}) },
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
debugf: () => {},
|
||||
infof: () => {},
|
||||
warnf: () => {},
|
||||
errorf: () => {},
|
||||
},
|
||||
debugFetch: jest.fn(),
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn(() => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
global.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { isSpoofedSymbol } = require("../src/shared/symbolSpoof");
|
||||
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
|
||||
const { filterTransactions } = require("../src/shared/transactions");
|
||||
const {
|
||||
fetchTokenBalances,
|
||||
refreshBalances,
|
||||
} = require("../src/shared/balances");
|
||||
const { renderSendTokenSelect } = require("../src/popup/views/send");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { debugFetch } = require("../src/shared/log");
|
||||
|
||||
// The fake "Ethereum" token with symbol "ETH" from the attack documented in
|
||||
// README.md, given a holder count high enough to clear every other filter so
|
||||
// that only the known-symbol rule can catch it.
|
||||
const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
|
||||
const HOLDER = "0x66133e8ea0f5d1d612d2502a968757d1048c214a";
|
||||
const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
||||
const WETH_CONTRACT = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";
|
||||
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
|
||||
|
||||
describe("the shared rule", () => {
|
||||
test('"ETH" is still the null-mapped symbol these tests assume', () => {
|
||||
expect(KNOWN_SYMBOLS.get("ETH")).toBeNull();
|
||||
});
|
||||
|
||||
test("a contract bearing a null-mapped symbol is a spoof", () => {
|
||||
expect(isSpoofedSymbol("ETH", FAKE_ETH_CONTRACT)).toBe(true);
|
||||
});
|
||||
|
||||
test("even a genuine contract may not bear a null-mapped symbol", () => {
|
||||
expect(isSpoofedSymbol("ETH", WETH_CONTRACT)).toBe(true);
|
||||
});
|
||||
|
||||
test("the native asset carries no contract and is never a spoof", () => {
|
||||
expect(isSpoofedSymbol("ETH", null)).toBe(false);
|
||||
expect(isSpoofedSymbol("ETH", undefined)).toBe(false);
|
||||
expect(isSpoofedSymbol("ETH", "")).toBe(false);
|
||||
});
|
||||
|
||||
// The native exemption is "has no contract address", not "the symbol is
|
||||
// ETH". A second null-mapped symbol added to the table later inherits
|
||||
// both halves of the rule without any call site being revisited.
|
||||
test("a newly null-mapped symbol behaves the same way", () => {
|
||||
const added = !KNOWN_SYMBOLS.has("XTZTEST");
|
||||
KNOWN_SYMBOLS.set("XTZTEST", null);
|
||||
try {
|
||||
expect(isSpoofedSymbol("XTZTEST", FAKE_ETH_CONTRACT)).toBe(true);
|
||||
expect(isSpoofedSymbol("XTZTEST", null)).toBe(false);
|
||||
} finally {
|
||||
if (added) KNOWN_SYMBOLS.delete("XTZTEST");
|
||||
}
|
||||
});
|
||||
|
||||
test("a known symbol from its own contract is not a spoof", () => {
|
||||
expect(isSpoofedSymbol("USDC", USDC_CONTRACT)).toBe(false);
|
||||
expect(isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase())).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a known symbol from another contract is a spoof", () => {
|
||||
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT)).toBe(true);
|
||||
});
|
||||
|
||||
test("a symbol that is not in the table is not judged here", () => {
|
||||
expect(isSpoofedSymbol("SPAMTKN", FAKE_ETH_CONTRACT)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("surface 1: the transaction history", () => {
|
||||
function fakeEthTransfer() {
|
||||
return {
|
||||
hash: "0x" + "1".repeat(64),
|
||||
symbol: "ETH",
|
||||
contractAddress: FAKE_ETH_CONTRACT,
|
||||
holders: 900000,
|
||||
valueGwei: null,
|
||||
isContractCall: false,
|
||||
};
|
||||
}
|
||||
|
||||
test("a fake ETH token transfer is filtered", () => {
|
||||
const result = filterTransactions([fakeEthTransfer()], {
|
||||
hideSpoofedSymbols: true,
|
||||
hideFraudContracts: true,
|
||||
hideLowHolderTokens: true,
|
||||
hideDustTransactions: true,
|
||||
dustThresholdGwei: 100000,
|
||||
});
|
||||
expect(result.transactions).toEqual([]);
|
||||
});
|
||||
|
||||
test("a real native ETH transfer survives", () => {
|
||||
const native = {
|
||||
hash: "0x" + "2".repeat(64),
|
||||
symbol: "ETH",
|
||||
contractAddress: null,
|
||||
holders: null,
|
||||
valueGwei: 5000000,
|
||||
isContractCall: false,
|
||||
};
|
||||
const result = filterTransactions([native], {
|
||||
hideSpoofedSymbols: true,
|
||||
hideFraudContracts: true,
|
||||
hideLowHolderTokens: true,
|
||||
hideDustTransactions: true,
|
||||
dustThresholdGwei: 100000,
|
||||
});
|
||||
expect(result.transactions).toEqual([native]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("surface 2: the Send token selector", () => {
|
||||
let select;
|
||||
|
||||
function render(tokenBalances) {
|
||||
select = { innerHTML: "", children: [] };
|
||||
select.appendChild = (child) => select.children.push(child);
|
||||
globalThis.document = {
|
||||
getElementById: (id) => (id === "send-token" ? select : null),
|
||||
createElement: () => ({ value: "", textContent: "" }),
|
||||
};
|
||||
renderSendTokenSelect({
|
||||
address: "0x" + "a".repeat(40),
|
||||
tokenBalances,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.fraudContracts = [];
|
||||
state.hideLowHolderTokens = true;
|
||||
});
|
||||
|
||||
test("a fake ETH token is not selectable", () => {
|
||||
render([
|
||||
{
|
||||
address: FAKE_ETH_CONTRACT,
|
||||
symbol: "ETH",
|
||||
decimals: 18,
|
||||
balance: "0.005",
|
||||
holders: 900000,
|
||||
},
|
||||
]);
|
||||
expect(select.children).toEqual([]);
|
||||
});
|
||||
|
||||
test("native ETH remains the always-present option", () => {
|
||||
render([]);
|
||||
expect(select.innerHTML).toBe('<option value="ETH">ETH</option>');
|
||||
});
|
||||
});
|
||||
|
||||
describe("surface 3: the balance list", () => {
|
||||
function respondWith(items) {
|
||||
debugFetch.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => items,
|
||||
}));
|
||||
}
|
||||
|
||||
function fakeEthItem(overrides = {}) {
|
||||
return {
|
||||
value: "5000000000000000",
|
||||
token: {
|
||||
type: "ERC-20",
|
||||
address_hash: FAKE_ETH_CONTRACT,
|
||||
symbol: "ETH",
|
||||
name: "Ethereum",
|
||||
decimals: "18",
|
||||
holders_count: "900000",
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
debugFetch.mockReset();
|
||||
});
|
||||
|
||||
// The bug in issue #235: this token cleared the balance list's own
|
||||
// 1,000-holder floor and was listed as a holding named ETH.
|
||||
test("a fake ETH token clearing the holder floor is filtered", async () => {
|
||||
respondWith([fakeEthItem()]);
|
||||
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||
});
|
||||
|
||||
test("tracking the fake token manually does not admit it either", async () => {
|
||||
respondWith([fakeEthItem({ holders_count: "0" })]);
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
|
||||
{ address: FAKE_ETH_CONTRACT },
|
||||
]);
|
||||
expect(balances).toEqual([]);
|
||||
});
|
||||
|
||||
test("a genuine token keeps its place in the list", async () => {
|
||||
respondWith([
|
||||
fakeEthItem({
|
||||
address_hash: USDC_CONTRACT,
|
||||
symbol: "USDC",
|
||||
name: "USD Coin",
|
||||
decimals: "6",
|
||||
}),
|
||||
]);
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||
expect(balances).toHaveLength(1);
|
||||
expect(balances[0].symbol).toBe("USDC");
|
||||
});
|
||||
|
||||
// The trap in this change: the user's real ETH balance is not an ERC-20
|
||||
// and is fetched over RPC in refreshBalances, so it never passes through
|
||||
// this loop at all. An explorer row that is not an ERC-20 is dropped
|
||||
// before the symbol rule is consulted.
|
||||
test("a non-ERC-20 row claiming ETH never reaches the symbol rule", async () => {
|
||||
respondWith([fakeEthItem({ type: "ERC-721" })]);
|
||||
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||
});
|
||||
|
||||
// The money test: the user holds real ETH and has been airdropped a fake
|
||||
// ETH ERC-20. The fake is gone from the list of tokens; the real balance
|
||||
// is exactly what the node reported.
|
||||
test("the real native ETH balance survives a fake ETH airdrop", async () => {
|
||||
respondWith([fakeEthItem()]);
|
||||
const addr = { address: HOLDER };
|
||||
await refreshBalances(
|
||||
[{ addresses: [addr] }],
|
||||
"https://rpc.example.invalid",
|
||||
BLOCKSCOUT,
|
||||
[],
|
||||
);
|
||||
expect(addr.balance).toBe("1.2345");
|
||||
expect(addr.tokenBalances).toEqual([]);
|
||||
});
|
||||
|
||||
test("no test in this file performed a network request", () => {
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,482 +0,0 @@
|
||||
// Lifecycle tests for the post-broadcast transaction status views
|
||||
// (src/popup/views/txStatus.js).
|
||||
//
|
||||
// The bug these pin down: the receipt poll rendered both outcomes on the tick
|
||||
// that crossed the 60-second deadline, so a confirmed transaction was replaced
|
||||
// by "not confirmed within 60 seconds" — the user is told their transaction
|
||||
// failed when it succeeded. The same shape applies to any callback that
|
||||
// outlives its wait: a receipt lookup still in flight when the view is left
|
||||
// must not render over whatever replaced it.
|
||||
//
|
||||
// Fake timers make the race deterministic: the receipt promise is already
|
||||
// resolved when the deadline tick runs, so in the unfixed code showSuccess()
|
||||
// is always followed by showError() on that tick.
|
||||
//
|
||||
// No network: getProvider is mocked at the module boundary and there is no
|
||||
// jsdom in this repo, so the handful of DOM calls these views make are served
|
||||
// by the stub below.
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
debugf: () => {},
|
||||
infof: () => {},
|
||||
warnf: () => {},
|
||||
errorf: () => {},
|
||||
},
|
||||
debugFetch: jest.fn(),
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
const mockReceiptLookup = jest.fn();
|
||||
jest.mock("../src/shared/balances", () => ({
|
||||
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
|
||||
refreshBalances: jest.fn(),
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn(() => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal DOM. Every element is created on demand and remembered by id, so a
|
||||
// test can read back what a view wrote into it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const elements = new Map();
|
||||
|
||||
function makeElement(id) {
|
||||
const classes = new Set(["view", "hidden"]);
|
||||
const el = {
|
||||
id,
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
style: {},
|
||||
classList: {
|
||||
add: (c) => classes.add(c),
|
||||
remove: (c) => classes.delete(c),
|
||||
contains: (c) => classes.has(c),
|
||||
toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)),
|
||||
},
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
remove: () => {},
|
||||
prepend: () => {},
|
||||
};
|
||||
// Views reach for .parentElement to hide whole sections.
|
||||
Object.defineProperty(el, "parentElement", {
|
||||
get: () => getElement(id + "-parent"),
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function getElement(id) {
|
||||
if (!elements.has(id)) elements.set(id, makeElement(id));
|
||||
return elements.get(id);
|
||||
}
|
||||
|
||||
global.document = {
|
||||
getElementById: (id) => getElement(id),
|
||||
// escapeHtml() builds a detached div; textContent in, escaped HTML out.
|
||||
createElement: () => {
|
||||
const el = { innerHTML: "" };
|
||||
Object.defineProperty(el, "textContent", {
|
||||
set(v) {
|
||||
el.innerHTML = String(v)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
},
|
||||
});
|
||||
return el;
|
||||
},
|
||||
body: { prepend: () => {} },
|
||||
addEventListener: () => {},
|
||||
};
|
||||
|
||||
global.window = { location: { search: "" } };
|
||||
|
||||
const stored = {};
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
set: (obj) => {
|
||||
Object.assign(stored, obj);
|
||||
return Promise.resolve();
|
||||
},
|
||||
get: () => Promise.resolve(stored),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
|
||||
const TX_HASH =
|
||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
const TX_INFO = {
|
||||
to: RECIPIENT,
|
||||
amount: "0.0050",
|
||||
token: "ETH",
|
||||
tokenSymbol: null,
|
||||
};
|
||||
|
||||
// True when a view element is not hidden.
|
||||
function visible(view) {
|
||||
return !getElement("view-" + view).classList.contains("hidden");
|
||||
}
|
||||
|
||||
function waitStatusText() {
|
||||
return getElement("wait-tx-status").textContent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
|
||||
elements.clear();
|
||||
mockReceiptLookup.mockReset();
|
||||
state.wallets = [];
|
||||
state.viewData = {};
|
||||
state.viewStack = [];
|
||||
state.currentView = null;
|
||||
txStatus.init({ doRefreshAndRender: jest.fn() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
txStatus.endWait();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("WaitTx receipt/timeout race", () => {
|
||||
test("a receipt arriving on the deadline tick leaves the user on SuccessTx", async () => {
|
||||
// No receipt for the first five polls; the sixth — the tick at
|
||||
// t=60s, which is also the timeout deadline — returns one.
|
||||
mockReceiptLookup
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue({ blockNumber: 21000000 });
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
expect(visible("wait-tx")).toBe(true);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(60000);
|
||||
|
||||
expect(visible("success-tx")).toBe(true);
|
||||
expect(visible("error-tx")).toBe(false);
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
expect(state.viewData.blockNumber).toBe(21000000);
|
||||
expect(state.viewData.message).toBeUndefined();
|
||||
|
||||
// And nothing is left running to undo it.
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
await jest.advanceTimersByTimeAsync(300000);
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
expect(mockReceiptLookup).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
test("a genuine timeout still shows ErrorTx with the hash", async () => {
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
await jest.advanceTimersByTimeAsync(60000);
|
||||
|
||||
expect(visible("error-tx")).toBe(true);
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(state.viewData.message).toMatch(
|
||||
/not confirmed within 60 seconds/,
|
||||
);
|
||||
expect(state.viewData.hash).toBe(TX_HASH);
|
||||
// The hash section carries the hash and the etherscan link.
|
||||
expect(getElement("error-tx-hash").innerHTML).toContain(TX_HASH);
|
||||
expect(getElement("error-tx-hash").innerHTML).toContain(
|
||||
"/tx/" + TX_HASH,
|
||||
);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("a receipt still in flight when the view is left does not render over it", async () => {
|
||||
let resolveReceipt;
|
||||
mockReceiptLookup.mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolveReceipt = r;
|
||||
}),
|
||||
);
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
await jest.advanceTimersByTimeAsync(10000);
|
||||
expect(mockReceiptLookup).toHaveBeenCalledTimes(1);
|
||||
|
||||
// User leaves the wait (popup navigation / teardown) while the
|
||||
// lookup is outstanding, then the lookup finally answers.
|
||||
txStatus.endWait();
|
||||
state.currentView = "main";
|
||||
resolveReceipt({ blockNumber: 21000000 });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(state.currentView).toBe("main");
|
||||
expect(visible("success-tx")).toBe(false);
|
||||
});
|
||||
|
||||
test("no timer survives the view being left", async () => {
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
||||
|
||||
txStatus.endWait();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(120000);
|
||||
expect(mockReceiptLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WaitTx persistence across popup close", () => {
|
||||
test("restoreWait resumes the poll with the deadline running from broadcast", async () => {
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
expect(state.viewData.pendingWait.hash).toBe(TX_HASH);
|
||||
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
||||
|
||||
// Popup closes: timers die with the page.
|
||||
txStatus.endWait();
|
||||
|
||||
// 45 seconds pass with the popup shut, then it is reopened.
|
||||
jest.advanceTimersByTime(45000);
|
||||
state.viewData = persisted;
|
||||
expect(txStatus.restoreWait()).toBe(true);
|
||||
|
||||
expect(visible("wait-tx")).toBe(true);
|
||||
// Elapsed is counted from the broadcast, not from the reopen.
|
||||
expect(waitStatusText()).toBe("Waiting for confirmation... 45s");
|
||||
// The immediate poll on resume has already run.
|
||||
await Promise.resolve();
|
||||
expect(mockReceiptLookup).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The deadline is 15 seconds away, not 60.
|
||||
await jest.advanceTimersByTimeAsync(20000);
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
});
|
||||
|
||||
test("a rejected lookup on the resume poll keeps waiting instead of reporting failure", async () => {
|
||||
// A wait resumed after the deadline has already passed: the first
|
||||
// poll is immediate and past 60s, so a thrown lookup must not be
|
||||
// read as "no receipt". It means "no answer this tick" — keep
|
||||
// polling, because the transaction may well have confirmed.
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
||||
txStatus.endWait();
|
||||
|
||||
// Ten minutes with the popup shut, then it is reopened and the
|
||||
// first receipt lookup fails transiently.
|
||||
jest.advanceTimersByTime(600000);
|
||||
mockReceiptLookup.mockReset();
|
||||
mockReceiptLookup
|
||||
.mockRejectedValueOnce(new Error("rpc unavailable"))
|
||||
.mockResolvedValue({ blockNumber: 21000000 });
|
||||
|
||||
state.viewData = persisted;
|
||||
expect(txStatus.restoreWait()).toBe(true);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
// The wait is still alive: no timeout was declared off one error.
|
||||
expect(visible("wait-tx")).toBe(true);
|
||||
expect(visible("error-tx")).toBe(false);
|
||||
expect(state.currentView).toBe("wait-tx");
|
||||
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
||||
|
||||
// And the next tick answers, so the confirmed transaction is
|
||||
// reported as confirmed.
|
||||
await jest.advanceTimersByTimeAsync(10000);
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
expect(state.viewData.blockNumber).toBe(21000000);
|
||||
});
|
||||
|
||||
test("a lookup returning null past the deadline still times out", async () => {
|
||||
// The counterpart to the test above: the deadline must still fire
|
||||
// when the lookup actually answers "no receipt".
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
||||
txStatus.endWait();
|
||||
|
||||
jest.advanceTimersByTime(600000);
|
||||
state.viewData = persisted;
|
||||
expect(txStatus.restoreWait()).toBe(true);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(state.viewData.message).toMatch(
|
||||
/not confirmed within 60 seconds/,
|
||||
);
|
||||
});
|
||||
|
||||
test("restoreWait reports nothing to resume when no wait is persisted", () => {
|
||||
state.viewData = {};
|
||||
expect(txStatus.restoreWait()).toBe(false);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("restoreWait rejects a persisted wait missing its txInfo or broadcast time", () => {
|
||||
for (const bad of [
|
||||
{ hash: TX_HASH, broadcastTime: Date.now() },
|
||||
{ hash: TX_HASH, txInfo: TX_INFO },
|
||||
{ hash: TX_HASH, txInfo: TX_INFO, broadcastTime: "soon" },
|
||||
{ hash: TX_HASH, txInfo: TX_INFO, broadcastTime: NaN },
|
||||
{ hash: TX_HASH, txInfo: "nope", broadcastTime: Date.now() },
|
||||
// An object that merely lacks a field startWait() dereferences
|
||||
// is the shape that actually escaped: txInfo.to reaches
|
||||
// addressTitle(), which calls address.toLowerCase(). typeof []
|
||||
// is "object", so an array passes an object check.
|
||||
{ hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() },
|
||||
{ hash: TX_HASH, txInfo: [], broadcastTime: Date.now() },
|
||||
{ hash: TX_HASH, txInfo: { to: 42 }, broadcastTime: Date.now() },
|
||||
// Otherwise complete but for a non-string `to`: only the `to`
|
||||
// check rejects this one, and without it addressTitle() throws
|
||||
// out of restoreView().
|
||||
{
|
||||
hash: TX_HASH,
|
||||
txInfo: { to: 42, amount: "0.0050" },
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
// Otherwise complete but an array: only Array.isArray() rejects
|
||||
// it, since typeof [] is "object" and the fields are present.
|
||||
{
|
||||
hash: TX_HASH,
|
||||
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
{
|
||||
hash: TX_HASH,
|
||||
txInfo: { to: RECIPIENT },
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
]) {
|
||||
state.viewData = { pendingWait: bad };
|
||||
expect(txStatus.restoreWait()).toBe(false);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("restoreWait resumes a wait whose recipient is the empty string", () => {
|
||||
// The shape a contract-deployment approval persists: approval.js
|
||||
// writes `to: toAddr || ""`, and showWait() renders it without
|
||||
// complaint. Validation must not be stricter than the live path, or
|
||||
// that wait is silently abandoned on every popup open.
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
state.viewData = {
|
||||
pendingWait: {
|
||||
hash: TX_HASH,
|
||||
txInfo: { ...TX_INFO, to: "" },
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
};
|
||||
expect(txStatus.restoreWait()).toBe(true);
|
||||
expect(visible("wait-tx")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WaitTx against an RPC that never answers", () => {
|
||||
test("a permanently failing lookup ends the wait instead of polling forever", async () => {
|
||||
mockReceiptLookup.mockRejectedValue(new Error("rpc unavailable"));
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
|
||||
// Six consecutive failures is 60 seconds at the 10s cadence — the
|
||||
// same patience as the confirmation deadline.
|
||||
await jest.advanceTimersByTimeAsync(60000);
|
||||
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(visible("wait-tx")).toBe(false);
|
||||
// The user is told what actually happened: the lookup failed. It is
|
||||
// not the same fact as "the transaction did not confirm".
|
||||
expect(state.viewData.message).toMatch(/could not be reached/i);
|
||||
expect(state.viewData.message).not.toMatch(/not confirmed within/);
|
||||
expect(state.viewData.hash).toBe(TX_HASH);
|
||||
|
||||
// Nothing is left running, and nothing is left to resume onto.
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
expect(state.viewData.pendingWait).toBeUndefined();
|
||||
|
||||
const calls = mockReceiptLookup.mock.calls.length;
|
||||
await jest.advanceTimersByTimeAsync(3600000);
|
||||
expect(mockReceiptLookup).toHaveBeenCalledTimes(calls);
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
});
|
||||
|
||||
test("an answered lookup clears the failure count, so the bound is on consecutive failures", async () => {
|
||||
// The bound counts failures in a row, not failures in total: a
|
||||
// flaky RPC that keeps answering in between must not accumulate its
|
||||
// way to a false "network unreachable".
|
||||
//
|
||||
// Polls 1-5 (t=10s..50s) alternate reject / null, so three fail and
|
||||
// the last answer resets the count at poll 4. From poll 6 on every
|
||||
// lookup fails. Six in a row is then poll 10, at t=100s. A counter
|
||||
// that never reset would have reached six at poll 8, t=80s, so the
|
||||
// window between those two is what this test occupies.
|
||||
mockReceiptLookup.mockImplementation(() => {
|
||||
const n = mockReceiptLookup.mock.calls.length;
|
||||
if (n <= 5 && n % 2 === 0) return Promise.resolve(null);
|
||||
return Promise.reject(new Error("flaky"));
|
||||
});
|
||||
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
|
||||
// t=90s: eight failures in total, five of them in a row. A
|
||||
// cumulative counter has long since fired; a consecutive one has not.
|
||||
await jest.advanceTimersByTimeAsync(90000);
|
||||
expect(state.currentView).toBe("wait-tx");
|
||||
expect(visible("wait-tx")).toBe(true);
|
||||
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
||||
|
||||
// t=100s: the sixth in a row.
|
||||
await jest.advanceTimersByTimeAsync(10000);
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(state.viewData.message).toMatch(/could not be reached/i);
|
||||
// No lookup ever answered "no receipt" past the deadline, so this
|
||||
// is not the timeout and must not be reported as one.
|
||||
expect(state.viewData.message).not.toMatch(/not confirmed within/);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("a resumed wait against a dead RPC also terminates", async () => {
|
||||
// The reopen path is the one that made this unbounded: the wait is
|
||||
// persisted, so without a bound every popup open resumes it forever.
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
txStatus.showWait(TX_INFO, TX_HASH);
|
||||
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
||||
txStatus.endWait();
|
||||
|
||||
jest.advanceTimersByTime(3600000);
|
||||
mockReceiptLookup.mockReset();
|
||||
mockReceiptLookup.mockRejectedValue(new Error("rpc unavailable"));
|
||||
|
||||
state.viewData = persisted;
|
||||
expect(txStatus.restoreWait()).toBe(true);
|
||||
await jest.advanceTimersByTimeAsync(60000);
|
||||
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(state.viewData.message).toMatch(/could not be reached/i);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
expect(state.viewData.pendingWait).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wait-tx is a view the popup may reopen onto", () => {
|
||||
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
|
||||
// refuses any view not in the set, so dropping "wait-tx" from it kills
|
||||
// the resume silently — the tests above call restoreWait() directly and
|
||||
// would all still pass. This pins the membership. Mirrors the exclusion
|
||||
// assertions in tests/showPhrase.test.js.
|
||||
test("wait-tx is restorable", () => {
|
||||
expect(RESTORABLE_VIEWS.has("wait-tx")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user