Compare commits
1 Commits
07780c3d8c
...
ef349e8a39
| Author | SHA1 | Date | |
|---|---|---|---|
| ef349e8a39 |
95
README.md
95
README.md
@@ -131,17 +131,16 @@ unavailable). The suite lives in `tests/e2e/` and is driven by
|
||||
`playwright-core`, whose version must stay matched to the container's Playwright
|
||||
version — the browsers ship inside the image.
|
||||
|
||||
It covers popup load, WebAssembly compilation under the shipped CSP (see
|
||||
[Content Security Policy](#content-security-policy)), wallet creation through
|
||||
the UI, the Add Token screen, the transaction detail screen for an ERC-20
|
||||
transfer, and the recovery phrase screen — which wallet types are offered it,
|
||||
that it holds nothing before the password is accepted, that a wrong password
|
||||
reveals nothing, that leaving it by either route wipes it — including a leave
|
||||
taken while the decrypt is still running — and that reopening the popup does not
|
||||
land on it. All outbound network is intercepted at the browser level and served
|
||||
from fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
|
||||
offline; unrecognised outbound requests are reported as failures rather than
|
||||
silently allowed.
|
||||
It covers popup load, wallet creation through the UI, the Add Token screen, the
|
||||
transaction detail screen for an ERC-20 transfer, and the recovery phrase screen
|
||||
— which wallet types are offered it, that it holds nothing before the password
|
||||
is accepted, that a wrong password reveals nothing, that leaving it by either
|
||||
route wipes it — including a leave taken while the decrypt is still running —
|
||||
and that reopening the popup does not land on it. All outbound network is
|
||||
intercepted at the browser level and served from fixtures in
|
||||
`tests/e2e/network.js`, so the run is deterministic and fully offline;
|
||||
unrecognised outbound requests are reported as failures rather than silently
|
||||
allowed.
|
||||
|
||||
That reporting has one bound worth knowing. Observation ends when the browser
|
||||
context is torn down, and nothing can watch traffic after that, so the run keeps
|
||||
@@ -444,11 +443,7 @@ The core hierarchy is **Wallets → Addresses**:
|
||||
multi-address behavior as an HD wallet, including the "+" button and the
|
||||
address scan on import, but imported from an extended private key rather
|
||||
than a recovery phrase. It therefore has no recovery phrase to display or
|
||||
back up. Only a master key may be imported; an xprv wallet already in
|
||||
storage that was imported from a non-master key is detected from the depth
|
||||
of its stored `xpub` by `src/shared/walletDefects.js`, explained in the
|
||||
wallet list, and blocked from signing, sending and private-key export. It
|
||||
is never deleted or rewritten.
|
||||
back up.
|
||||
- An **address** holds ETH and ERC-20 tokens.
|
||||
- The user can have multiple wallets, each with multiple addresses (HD) or a
|
||||
single address (key).
|
||||
@@ -499,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
|
||||
@@ -617,15 +604,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`)
|
||||
|
||||
@@ -714,23 +696,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`)
|
||||
|
||||
@@ -1107,36 +1076,6 @@ battle-tested.
|
||||
Exceptions require explicit authorization in a code comment referencing this
|
||||
policy, but as of now there are none.
|
||||
|
||||
### Content Security Policy
|
||||
|
||||
Both manifests declare the same policy for extension pages —
|
||||
`script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` — as an object under
|
||||
`content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as
|
||||
a bare string in `manifest/firefox.json` (MV2).
|
||||
|
||||
`'wasm-unsafe-eval'` is there for one reason: libsodium. It ships a WebAssembly
|
||||
build and a `wasm2js` translation of it in one file, tries WASM first, and
|
||||
silently falls back to the translation if instantiation throws. Under a plain
|
||||
`script-src 'self'` the fallback was taken on every popup load, announced by
|
||||
nothing but an uncaught `CompileError`. Measured on the same Argon2id parameters
|
||||
the vault uses (`OPSLIMIT_INTERACTIVE`, `MEMLIMIT_INTERACTIVE`), WASM derives a
|
||||
key in 141-198ms and `wasm2js` in 3204-3660ms. The work factor is identical — it
|
||||
is set by the ops and memory parameters, not by wall time — so the fallback
|
||||
bought nothing and cost about three and a half seconds on every operation that
|
||||
asks for the password, which is every signature.
|
||||
|
||||
The keyword permits compiling WebAssembly and nothing else: not `eval()` of
|
||||
strings, not inline script, not remote script. Using it requires already
|
||||
executing script in an extension page, which is complete compromise on its own.
|
||||
`'unsafe-eval'` is a different proposition and is not granted.
|
||||
|
||||
The grant is pinned in both directions. `tests/manifest.test.js` asserts the
|
||||
exact token set in both manifests, so dropping `'wasm-unsafe-eval'` (a silent
|
||||
20x regression on the key derivation) and adding anything beyond it both fail
|
||||
`make check`. `tests/vaultBackend.test.js` asserts the unit tests run the WASM
|
||||
backend, and `make test-e2e` compiles a WebAssembly module inside the real popup
|
||||
under the real manifest.
|
||||
|
||||
### DEBUG Mode Policy
|
||||
|
||||
The `DEBUG` constant in the popup JS enables a red "DEBUG / INSECURE" banner and
|
||||
|
||||
26
TODO.md
26
TODO.md
@@ -50,27 +50,6 @@ undefined identifiers, which is how
|
||||
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
|
||||
instead of throwing on the send screen
|
||||
([#234](https://git.eeqj.de/sneak/AutistMask/issues/234)).
|
||||
- 2026-08-12: An unreported `holders_count` is now parsed as `null` rather than
|
||||
`0`, so the low-holder rule declines to judge an unknown count instead of
|
||||
hiding a legitimate token as spam, in both the transaction history and the
|
||||
Send token selector ([#230](https://git.eeqj.de/sneak/AutistMask/issues/230)).
|
||||
- 2026-08-12: Bundled token list documentation no longer states a count. The
|
||||
four "top 250" claims in `README.md` and the "roughly 500" claim in
|
||||
`docs/README.md` are replaced with a description of how the list is actually
|
||||
@@ -78,11 +57,6 @@ undefined identifiers, which is how
|
||||
Ethereum mainnet ERC-20s — with `TOKENS` in `src/shared/tokenList.js` named as
|
||||
the authoritative set
|
||||
([#239](https://git.eeqj.de/sneak/AutistMask/issues/239)).
|
||||
- 2026-08-11: libsodium runs on WebAssembly in the shipped builds —
|
||||
`'wasm-unsafe-eval'` added to both manifest CSPs after measuring the wasm2js
|
||||
fallback at 20x the Argon2id cost, pinned in both directions by
|
||||
`tests/manifest.test.js` and observed in the real popup by the e2e suite
|
||||
([#182](https://git.eeqj.de/sneak/AutistMask/issues/182)).
|
||||
- 2026-08-11: Known-symbol spoof verification became a Settings toggle
|
||||
(`hideSpoofedSymbols`), on by default, governing the transaction-history
|
||||
filter and the fraud-contract learning it feeds
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
"description": "Minimal Ethereum wallet for Chrome",
|
||||
"permissions": ["storage", "activeTab", "alarms"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "src/popup/index.html"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Minimal Ethereum wallet for Firefox",
|
||||
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
||||
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
|
||||
"browser_action": {
|
||||
"default_popup": "src/popup/index.html"
|
||||
},
|
||||
|
||||
@@ -165,12 +165,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,17 +27,8 @@ const {
|
||||
} = require("./send");
|
||||
const { log } = require("../../shared/log");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const exportPrivkey = require("./exportPrivkey");
|
||||
const { walletDefect } = require("../../shared/walletDefects");
|
||||
|
||||
// The defect of the wallet the selected address belongs to, or null. Both the
|
||||
// send and the private-key export path check it before asking for a password,
|
||||
// so a wallet that cannot derive its keys says so instead of failing after the
|
||||
// user has typed one in.
|
||||
function selectedWalletDefect() {
|
||||
if (state.selectedWallet === null) return null;
|
||||
return walletDefect(state.wallets[state.selectedWallet]);
|
||||
}
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
|
||||
let ctx;
|
||||
|
||||
@@ -262,11 +254,6 @@ function init(_ctx) {
|
||||
});
|
||||
|
||||
$("btn-send").addEventListener("click", () => {
|
||||
const defect = selectedWalletDefect();
|
||||
if (defect) {
|
||||
showFlash(defect.shortMessage);
|
||||
return;
|
||||
}
|
||||
const addr =
|
||||
state.wallets[state.selectedWallet].addresses[
|
||||
state.selectedAddress
|
||||
@@ -311,20 +298,81 @@ function init(_ctx) {
|
||||
$("btn-export-privkey").addEventListener("click", () => {
|
||||
moreDropdown.classList.add("hidden");
|
||||
moreBtn.classList.remove("bg-fg", "text-bg");
|
||||
// There is no private key to export for an address this wallet
|
||||
// cannot derive. Without this the export screen would take a
|
||||
// password and then report it as wrong.
|
||||
const defect = selectedWalletDefect();
|
||||
if (defect) {
|
||||
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 };
|
||||
|
||||
@@ -35,7 +35,6 @@ const {
|
||||
} = require("./send");
|
||||
const { log } = require("../../shared/log");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const { walletDefect } = require("../../shared/walletDefects");
|
||||
|
||||
let ctx;
|
||||
|
||||
@@ -339,11 +338,6 @@ function init(_ctx) {
|
||||
});
|
||||
|
||||
$("btn-address-token-send").addEventListener("click", () => {
|
||||
const defect = walletDefect(state.wallets[state.selectedWallet]);
|
||||
if (defect) {
|
||||
showFlash(defect.shortMessage);
|
||||
return;
|
||||
}
|
||||
const addr =
|
||||
state.wallets[state.selectedWallet].addresses[
|
||||
state.selectedAddress
|
||||
|
||||
@@ -7,7 +7,6 @@ const {
|
||||
hideError,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
onViewLeave,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const {
|
||||
@@ -22,7 +21,6 @@ const { ERC20_ABI } = require("../../shared/constants");
|
||||
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
const { walletDefect } = require("../../shared/walletDefects");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const txStatus = require("./txStatus");
|
||||
const uniswap = require("../../shared/uniswap");
|
||||
@@ -282,7 +280,6 @@ function showTxApproval(details) {
|
||||
|
||||
showView("approve-tx");
|
||||
attachCopyHandlers("view-approve-tx");
|
||||
gateOnWalletDefect("approve-tx-error", "btn-approve-tx");
|
||||
}
|
||||
|
||||
function decodeHexMessage(hex) {
|
||||
@@ -382,7 +379,6 @@ function showSignApproval(details) {
|
||||
|
||||
showView("approve-sign");
|
||||
attachCopyHandlers("view-approve-sign");
|
||||
gateOnWalletDefect("approve-sign-error", "btn-approve-sign");
|
||||
}
|
||||
|
||||
function show(id) {
|
||||
@@ -435,20 +431,6 @@ function setSignButtonBusy(busy) {
|
||||
$("btn-approve-sign").classList.toggle("text-muted", busy);
|
||||
}
|
||||
|
||||
// Say so on the approval screen itself, and disable the approve button, when
|
||||
// the active address belongs to a wallet whose keys cannot be derived. Without
|
||||
// this the screen would take a password and fail after deriving it. Reject
|
||||
// stays available; the wallet is not touched. Returns true when it gated.
|
||||
function gateOnWalletDefect(errorId, buttonId) {
|
||||
const active = findActiveWallet();
|
||||
const defect = active ? walletDefect(active.wallet) : null;
|
||||
if (!defect) return false;
|
||||
showError(errorId, defect.shortMessage);
|
||||
$(buttonId).disabled = true;
|
||||
$(buttonId).classList.add("text-muted");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Locate the wallet and the address index owning the currently active
|
||||
// address. Returns null when no wallet holds it.
|
||||
function findActiveWallet() {
|
||||
@@ -462,24 +444,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();
|
||||
@@ -527,14 +492,6 @@ function init(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defect = walletDefect(active.wallet);
|
||||
if (defect) {
|
||||
password = null;
|
||||
showError("approve-tx-error", defect.shortMessage);
|
||||
setTxButtonBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrypt here, in the popup. The password must never cross the
|
||||
// extension messaging boundary; only the signed transaction does.
|
||||
let decryptedSecret;
|
||||
@@ -626,14 +583,6 @@ function init(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defect = walletDefect(active.wallet);
|
||||
if (defect) {
|
||||
password = null;
|
||||
showError("approve-sign-error", defect.shortMessage);
|
||||
setSignButtonBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrypt here, in the popup. The password must never cross the
|
||||
// extension messaging boundary; only the signature does.
|
||||
let decryptedSecret;
|
||||
|
||||
@@ -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 };
|
||||
@@ -21,10 +21,6 @@ const {
|
||||
resetSendValidation,
|
||||
} = require("./send");
|
||||
const { deriveAddressFromXpub } = require("../../shared/wallet");
|
||||
const {
|
||||
walletDefect,
|
||||
walletDefectHtml,
|
||||
} = require("../../shared/walletDefects");
|
||||
const {
|
||||
formatUsd,
|
||||
getPrice,
|
||||
@@ -218,23 +214,25 @@ async function loadHomeTxs(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// The wallet list markup. Pure: it reads state and returns a string, so the
|
||||
// list can be asserted on without a DOM.
|
||||
function walletListHtml() {
|
||||
function render(ctx) {
|
||||
const container = $("wallet-list");
|
||||
if (state.wallets.length === 0) {
|
||||
container.innerHTML =
|
||||
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
|
||||
renderTotalValue();
|
||||
renderActiveAddress();
|
||||
return;
|
||||
}
|
||||
|
||||
let html = "";
|
||||
state.wallets.forEach((wallet, wi) => {
|
||||
const defect = walletDefect(wallet);
|
||||
html += `<div>`;
|
||||
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
|
||||
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
|
||||
// No "+" on a defective wallet: deriving another address from that
|
||||
// xpub would only add one more address the key does not produce
|
||||
// under the standard path.
|
||||
if (!defect && (wallet.type === "hd" || wallet.type === "xprv")) {
|
||||
if (wallet.type === "hd" || wallet.type === "xprv") {
|
||||
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += walletDefectHtml(wallet);
|
||||
|
||||
wallet.addresses.forEach((addr, ai) => {
|
||||
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
|
||||
@@ -262,20 +260,7 @@ function walletListHtml() {
|
||||
|
||||
html += `</div>`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function render(ctx) {
|
||||
const container = $("wallet-list");
|
||||
if (state.wallets.length === 0) {
|
||||
container.innerHTML =
|
||||
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
|
||||
renderTotalValue();
|
||||
renderActiveAddress();
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = walletListHtml();
|
||||
container.innerHTML = html;
|
||||
|
||||
container.querySelectorAll(".address-row").forEach((row) => {
|
||||
row.addEventListener("click", async () => {
|
||||
@@ -363,13 +348,6 @@ function render(ctx) {
|
||||
loadHomeTxs(ctx);
|
||||
}
|
||||
|
||||
// The defect of the wallet the selected address belongs to, or null. Call
|
||||
// after selectActiveAddress().
|
||||
function selectedWalletDefect() {
|
||||
if (state.selectedWallet === null) return null;
|
||||
return walletDefect(state.wallets[state.selectedWallet]);
|
||||
}
|
||||
|
||||
function selectActiveAddress() {
|
||||
for (let wi = 0; wi < state.wallets.length; wi++) {
|
||||
for (let ai = 0; ai < state.wallets[wi].addresses.length; ai++) {
|
||||
@@ -393,13 +371,6 @@ function init(ctx) {
|
||||
showFlash("No active address selected.");
|
||||
return;
|
||||
}
|
||||
// Before the balance check and before any password is asked for: this
|
||||
// wallet cannot sign at all, so the send screen is a dead end.
|
||||
const defect = selectedWalletDefect();
|
||||
if (defect) {
|
||||
showFlash(defect.shortMessage);
|
||||
return;
|
||||
}
|
||||
const addr = currentAddress();
|
||||
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
||||
showFlash("Cannot send \u2014 zero balance.");
|
||||
@@ -425,4 +396,4 @@ function init(ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, render, walletListHtml };
|
||||
module.exports = { init, render };
|
||||
|
||||
@@ -13,7 +13,6 @@ const { state, currentAddress } = require("../../shared/state");
|
||||
let ctx;
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
|
||||
const { isLowHolderCount } = require("../../shared/holders");
|
||||
const { getAddress } = require("ethers");
|
||||
|
||||
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
||||
@@ -133,10 +132,7 @@ function renderSendTokenSelect(addr) {
|
||||
for (const t of addr.tokenBalances || []) {
|
||||
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
|
||||
// does. Otherwise a missing field makes a real asset unspendable.
|
||||
if (state.hideLowHolderTokens && isLowHolderCount(t.holders)) continue;
|
||||
if (state.hideLowHolderTokens && (t.holders || 0) < 1000) continue;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = t.address;
|
||||
opt.textContent = t.symbol;
|
||||
|
||||
@@ -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);
|
||||
} 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.
|
||||
const receipt = await provider.getTransactionReceipt(txHash);
|
||||
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.",
|
||||
);
|
||||
} catch (e) {
|
||||
log.errorf("poll receipt failed:", e.message);
|
||||
}
|
||||
// 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 };
|
||||
|
||||
@@ -12,7 +12,6 @@ const { ERC20_ABI } = require("./constants");
|
||||
const { log, debugFetch } = require("./log");
|
||||
const { deriveAddressFromXpub } = require("./wallet");
|
||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||
|
||||
// Use a static network to skip auto-detection (which can fail and cause
|
||||
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
||||
@@ -71,20 +70,10 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
if (bal === "0.0") continue;
|
||||
|
||||
const tokenAddr = (item.token.address_hash || "").toLowerCase();
|
||||
// null means the explorer reported no count, which is not the
|
||||
// same as a count of zero. This gate is not the low-holder
|
||||
// display filter: it has no user-facing off switch and governs
|
||||
// the whole balance list, so it stays strict and admits a token
|
||||
// only on a reported count — an unreported one is no evidence.
|
||||
// A legitimate token still reaches the list through the known
|
||||
// token list or by the user tracking it, and the null is carried
|
||||
// through to the views, where the two low-holder filters treat
|
||||
// an unknown count as "do not judge" rather than as zero.
|
||||
const holders = parseHoldersCount(item.token.holders_count);
|
||||
const holders = parseInt(item.token.holders_count || "0", 10);
|
||||
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
|
||||
const isTracked = trackedSet.has(tokenAddr);
|
||||
const hasEnoughHolders =
|
||||
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
|
||||
const hasEnoughHolders = holders >= 1000;
|
||||
|
||||
// Skip spam tokens the user never asked to see
|
||||
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
||||
@@ -289,7 +278,6 @@ async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchTokenBalances,
|
||||
refreshBalances,
|
||||
lookupTokenInfo,
|
||||
getProvider,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// Holder counts, and the one rule that decides whether a count is "low".
|
||||
//
|
||||
// The block explorer's holders_count is optional: it is absent on a token it
|
||||
// has only just indexed, and it goes missing on a degraded or changed API.
|
||||
// Absent means the count is unknown. It does not mean the token has no
|
||||
// holders, and collapsing the two hides a token the user really holds as if
|
||||
// it were spam. Every call site reads the count through here so the
|
||||
// distinction cannot be lost again in one place while holding in the others.
|
||||
|
||||
const LOW_HOLDER_THRESHOLD = 1000;
|
||||
|
||||
// Parse an explorer-supplied holders_count into a number, or null when the
|
||||
// explorer did not report one. Anything unparseable is unknown too: a count
|
||||
// we cannot read is not a count of zero.
|
||||
function parseHoldersCount(raw) {
|
||||
if (raw === null || raw === undefined || raw === "") return null;
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// True only for a token the explorer reported as having fewer holders than
|
||||
// the threshold. An unknown count is never low: showing a spam token the
|
||||
// user can see is unusual costs less than hiding an asset they own.
|
||||
function isLowHolderCount(holders) {
|
||||
return holders != null && holders < LOW_HOLDER_THRESHOLD;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LOW_HOLDER_THRESHOLD,
|
||||
parseHoldersCount,
|
||||
isLowHolderCount,
|
||||
};
|
||||
@@ -9,7 +9,6 @@
|
||||
const { formatEther, formatUnits } = require("ethers");
|
||||
const { log, debugFetch } = require("./log");
|
||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
||||
|
||||
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
||||
// over the address, not part of its identity. Every address comparison in
|
||||
@@ -117,10 +116,7 @@ function parseTokenTransfer(tt, addrLower) {
|
||||
contractAddress: normalizeAddress(
|
||||
tt.token?.address_hash || tt.token?.address || "",
|
||||
),
|
||||
// null when the explorer reported no count: unknown, not zero. The
|
||||
// low-holder filter declines to judge a null, so a legitimate token
|
||||
// is not hidden because a field went missing upstream.
|
||||
holders: parseHoldersCount(tt.token?.holders_count),
|
||||
holders: parseInt(tt.token?.holders_count || "0", 10),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -296,13 +292,12 @@ function filterTransactions(txs, filters = {}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter low-holder tokens (<1000) if setting is on. A token whose
|
||||
// holder count the explorer did not report is kept: only a reported
|
||||
// count below the threshold is "low".
|
||||
// Filter low-holder tokens (<1000) if setting is on
|
||||
if (
|
||||
filters.hideLowHolderTokens &&
|
||||
tx.contractAddress &&
|
||||
isLowHolderCount(tx.holders)
|
||||
tx.holders !== null &&
|
||||
tx.holders < 1000
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,80 +1,14 @@
|
||||
// Vault: password-based encryption of secrets using libsodium.
|
||||
// Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption.
|
||||
// All crypto operations are delegated to libsodium — no raw primitives.
|
||||
//
|
||||
// Backend: WebAssembly, deliberately (#182).
|
||||
//
|
||||
// libsodium ships one file containing both a WebAssembly build and a
|
||||
// wasm2js ("asm.js") translation of it. It tries WASM first and, if
|
||||
// instantiation throws, silently swaps in the translation. An extension
|
||||
// CSP of plain script-src 'self' refuses WASM, so every popup load used
|
||||
// to take that fallback — announced by nothing but an uncaught
|
||||
// CompileError in the console.
|
||||
//
|
||||
// Measured here, same Argon2id parameters (OPSLIMIT_INTERACTIVE,
|
||||
// MEMLIMIT_INTERACTIVE = 2 passes over 64MiB), node 22 on this machine:
|
||||
// WASM 141-198ms per derivation, wasm2js 3204-3660ms. The work factor is
|
||||
// identical either way — it is set by the ops/mem parameters, not by wall
|
||||
// time — so the fallback bought no security, it only made every password
|
||||
// operation take three and a half seconds, and the wallet asks for the
|
||||
// password on every signature.
|
||||
//
|
||||
// So both manifests declare 'wasm-unsafe-eval' for extension pages. That
|
||||
// keyword permits compiling WebAssembly and nothing else: not eval() of
|
||||
// strings, not inline script, not remote script. Reaching it requires
|
||||
// already executing script in the extension page, which is total
|
||||
// compromise on its own. 'unsafe-eval' would be a different matter and is
|
||||
// not granted. tests/manifest.test.js pins both policies to exactly
|
||||
// "'self' 'wasm-unsafe-eval'" so neither the grant nor the surrounding
|
||||
// strictness can drift unnoticed.
|
||||
//
|
||||
// The fallback still exists, and a wallet that refuses to decrypt is
|
||||
// worse than a slow one, so it is not disabled — it is made loud:
|
||||
// cryptoBackend() reports which backend this realm can run, ensureReady()
|
||||
// logs an error if it is not WASM, tests/vaultBackend.test.js asserts the
|
||||
// unit tests exercise the WASM backend, and the end-to-end suite asserts
|
||||
// it in the real popup under the real manifest.
|
||||
|
||||
const sodium = require("libsodium-wrappers-sumo");
|
||||
const { log } = require("./log");
|
||||
|
||||
// An empty WebAssembly module: the 8-byte magic number and version header,
|
||||
// no sections. Compiling it asks the cheapest possible form of the only
|
||||
// question that matters here — may this realm compile WebAssembly at all —
|
||||
// which is exactly what a CSP without 'wasm-unsafe-eval' refuses, and
|
||||
// exactly what decides which backend libsodium ends up on.
|
||||
const EMPTY_WASM_MODULE = new Uint8Array([
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
|
||||
// "wasm" or "asmjs": whether this realm may compile WebAssembly, which is
|
||||
// what decides libsodium's backend when the CSP is the reason it cannot —
|
||||
// the case this codebase guards. It probes the realm, not libsodium, so a
|
||||
// fallback taken for some other reason (allocation failure, corrupt module)
|
||||
// would not be caught here; tests/vaultBackend.test.js checks libsodium's
|
||||
// own marker directly.
|
||||
async function cryptoBackend() {
|
||||
try {
|
||||
await WebAssembly.compile(EMPTY_WASM_MODULE);
|
||||
return "wasm";
|
||||
} catch (_) {
|
||||
return "asmjs";
|
||||
}
|
||||
}
|
||||
|
||||
let ready = false;
|
||||
|
||||
async function ensureReady() {
|
||||
if (!ready) {
|
||||
await sodium.ready;
|
||||
if ((await cryptoBackend()) !== "wasm") {
|
||||
log.errorf(
|
||||
"libsodium is running on the wasm2js fallback: this realm " +
|
||||
"refuses to compile WebAssembly, so every password " +
|
||||
"derivation costs roughly 20x what it should. See the " +
|
||||
"backend note in src/shared/vault.js.",
|
||||
);
|
||||
}
|
||||
ready = true;
|
||||
}
|
||||
}
|
||||
@@ -125,4 +59,4 @@ async function decryptWithPassword(encrypted, password) {
|
||||
return sodium.to_string(plaintext);
|
||||
}
|
||||
|
||||
module.exports = { cryptoBackend, decryptWithPassword, encryptWithPassword };
|
||||
module.exports = { encryptWithPassword, decryptWithPassword };
|
||||
|
||||
@@ -120,24 +120,9 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
|
||||
return node.deriveChild(addrIndex);
|
||||
}
|
||||
if (walletData.type === "xprv") {
|
||||
// Checked here rather than through masterXprvOrThrow so the message
|
||||
// fits the situation: nobody is importing anything at signing time,
|
||||
// and this wallet is already in storage. src/shared/walletDefects.js
|
||||
// catches it at list-render time; this is the backstop behind that.
|
||||
const node = parseExtendedKey(decryptedSecret);
|
||||
if (!node || !node.privateKey) {
|
||||
throw new Error(
|
||||
"This wallet's stored key is not a valid extended private " +
|
||||
"key, so it cannot sign.",
|
||||
);
|
||||
}
|
||||
if (node.depth !== MASTER_DEPTH) {
|
||||
throw new Error(
|
||||
"This wallet was imported from an extended private key that " +
|
||||
"is not a master key, so it cannot sign.",
|
||||
);
|
||||
}
|
||||
return node.derivePath(BIP44_ETH_PATH).deriveChild(addrIndex);
|
||||
const node =
|
||||
masterXprvOrThrow(decryptedSecret).derivePath(BIP44_ETH_PATH);
|
||||
return node.deriveChild(addrIndex);
|
||||
}
|
||||
return new Wallet(decryptedSecret);
|
||||
}
|
||||
@@ -157,7 +142,6 @@ function walletHasRecoveryPhrase(walletData) {
|
||||
|
||||
module.exports = {
|
||||
generateMnemonic,
|
||||
parseExtendedKey,
|
||||
deriveAddressFromXpub,
|
||||
hdWalletFromMnemonic,
|
||||
hdWalletFromXprv,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// Wallets already in stored state whose key cannot be used, and the copy that
|
||||
// explains them.
|
||||
//
|
||||
// Refusing a non-master extended private key at import time does nothing for a
|
||||
// wallet imported before that refusal existed. Such a wallet is detected here,
|
||||
// at wallet-list render time, so the user meets the explanation on the list
|
||||
// screen rather than an exception on the send screen. Nothing here modifies or
|
||||
// removes a wallet: the record is the user's data.
|
||||
|
||||
const { parseExtendedKey } = require("./wallet");
|
||||
|
||||
const NON_MASTER_XPRV = "non-master-xprv";
|
||||
|
||||
// An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below
|
||||
// the key that was imported: the current import path derives the absolute
|
||||
// m/44'/60'/0'/0 from a depth-0 key, and the pre-#210 path derived the same
|
||||
// four levels as a relative path beneath whatever depth it was given. A master
|
||||
// import therefore stores a depth-4 xpub and a depth-d import stores depth
|
||||
// d + 4, which makes the stored xpub an exact read on the imported key's
|
||||
// depth — and it is readable without the password, unlike the key itself.
|
||||
const BIP44_ETH_XPUB_DEPTH = 4;
|
||||
|
||||
const DEFECTS = {
|
||||
[NON_MASTER_XPRV]: {
|
||||
id: NON_MASTER_XPRV,
|
||||
heading: "This wallet's addresses were derived incorrectly.",
|
||||
paragraphs: [
|
||||
"This wallet was imported from an extended private key that is " +
|
||||
"not a master key. An earlier version applied the Ethereum " +
|
||||
"derivation path beneath that key instead of from a master " +
|
||||
"key, so the addresses listed here are not the ones that key " +
|
||||
"produces under the standard path.",
|
||||
"Signing and sending are disabled for this wallet. The addresses " +
|
||||
"do descend from the extended private key you imported, so " +
|
||||
"anything they hold is still reachable by software that " +
|
||||
"repeats the same non-standard derivation. Check them in a " +
|
||||
"block explorer before deciding what to do.",
|
||||
"To see the addresses this key produces under the standard path, " +
|
||||
"import the master extended private key, or the recovery " +
|
||||
"phrase it came from, as a new wallet. Nothing here has been " +
|
||||
"changed or removed, and this wallet stays until you delete " +
|
||||
"it yourself.",
|
||||
],
|
||||
// One sentence for the places that have room for one: the flash on a
|
||||
// blocked Send, the inline error on the approval screens.
|
||||
shortMessage:
|
||||
"This wallet cannot sign, because it was imported from an " +
|
||||
"extended private key that is not a master key. The wallet list " +
|
||||
"explains what happened.",
|
||||
},
|
||||
};
|
||||
|
||||
// The defect record for a wallet, or null if there is nothing wrong with it
|
||||
// that this module can see. Read-only.
|
||||
//
|
||||
// A wallet whose xpub will not parse gets null rather than a defect: there is
|
||||
// no basis in that case to tell the user their key was not a master key, and a
|
||||
// wrong explanation is worse than none.
|
||||
function walletDefect(walletData) {
|
||||
if (!walletData || walletData.type !== "xprv") return null;
|
||||
const node = parseExtendedKey(walletData.xpub);
|
||||
if (!node) return null;
|
||||
if (node.depth === BIP44_ETH_XPUB_DEPTH) return null;
|
||||
return DEFECTS[NON_MASTER_XPRV];
|
||||
}
|
||||
|
||||
// The notice block for the wallet list, or "" for a wallet with no defect.
|
||||
// The copy is fixed text from this module, so it needs no escaping.
|
||||
function walletDefectHtml(walletData) {
|
||||
const defect = walletDefect(walletData);
|
||||
if (!defect) return "";
|
||||
let html =
|
||||
'<div class="border border-red-500 border-dashed p-2 my-1 text-xs text-red-500">';
|
||||
html += `<div class="font-bold mb-1">${defect.heading}</div>`;
|
||||
for (const p of defect.paragraphs) {
|
||||
html += `<p class="mb-1">${p}</p>`;
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NON_MASTER_XPRV,
|
||||
walletDefect,
|
||||
walletDefectHtml,
|
||||
};
|
||||
@@ -22,12 +22,18 @@ const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
|
||||
// entry must name the issue that will remove it. This list is the one
|
||||
// concession in an otherwise zero-tolerance policy: an uncaught error is
|
||||
// how this harness caught issue #150 in the first place.
|
||||
//
|
||||
// Empty, and worth keeping that way. Its only entry was the WASM
|
||||
// CompileError libsodium provoked on every popup load, deleted with #182
|
||||
// when both manifests started allowing WASM; the run that used to need it
|
||||
// is now the run that proves the fix.
|
||||
const ALLOWED_ERRORS = [];
|
||||
const ALLOWED_ERRORS = [
|
||||
{
|
||||
// libsodium ships a WASM build and an asm.js fallback. The
|
||||
// extension CSP (script-src 'self', with no wasm-unsafe-eval)
|
||||
// refuses the WASM module on every popup load; libsodium catches
|
||||
// it and falls back to asm.js, so the wallet works. Deciding
|
||||
// which backend actually ships is issue #182, and this entry gets
|
||||
// deleted when that lands.
|
||||
issue: "#182",
|
||||
pattern: /Refused to compile or instantiate WebAssembly module/,
|
||||
},
|
||||
];
|
||||
|
||||
function isAllowed(text) {
|
||||
return ALLOWED_ERRORS.some((a) => a.pattern.test(text));
|
||||
@@ -241,26 +247,6 @@ async function visible(page, selector, timeout = 15000) {
|
||||
await page.waitForSelector(selector, { state: "visible", timeout });
|
||||
}
|
||||
|
||||
// An empty WebAssembly module: magic number and version header, no
|
||||
// sections. Compiling it in the popup asks the one question that decides
|
||||
// libsodium's backend — may this realm compile WebAssembly — of the real
|
||||
// page under the real shipped manifest, which is the only place the
|
||||
// answer can be observed. Kept independent of src/shared/vault.js on
|
||||
// purpose: a bundle asked to grade itself proves less than an outside
|
||||
// observation of the same realm.
|
||||
const EMPTY_WASM_MODULE = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
|
||||
|
||||
async function pageCompilesWasm(page) {
|
||||
return page.evaluate(async (bytes) => {
|
||||
try {
|
||||
await WebAssembly.compile(new Uint8Array(bytes));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}, EMPTY_WASM_MODULE);
|
||||
}
|
||||
|
||||
async function openPopup(ctx, popupUrl) {
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(popupUrl);
|
||||
@@ -307,6 +293,5 @@ module.exports = {
|
||||
launch,
|
||||
openAddressDetail,
|
||||
openPopup,
|
||||
pageCompilesWasm,
|
||||
visible,
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ const {
|
||||
launch,
|
||||
openAddressDetail,
|
||||
openPopup,
|
||||
pageCompilesWasm,
|
||||
visible,
|
||||
} = require("./harness");
|
||||
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
|
||||
@@ -61,27 +60,6 @@ test("popup loads and reaches the welcome view", async (env) => {
|
||||
assert(title === "AutistMask", "unexpected popup title: " + title);
|
||||
});
|
||||
|
||||
// The empirical half of #182. The manifest change is only a claim about
|
||||
// what the CSP permits; this is the observation. Two things have to hold
|
||||
// together, and the run covers both: the popup realm compiles WASM (here),
|
||||
// and no WASM refusal or abort is recorded anywhere in the run — the
|
||||
// harness allowlist that used to excuse exactly that error is now empty,
|
||||
// so a recurrence fails whichever test it lands in rather than being
|
||||
// tolerated. Since libsodium's WASM module is embedded in the bundle and
|
||||
// needs no fetch, a realm that compiles WASM is a realm where libsodium
|
||||
// takes the WASM path, and the next test drives a real vault encryption
|
||||
// through it.
|
||||
test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => {
|
||||
const ok = await pageCompilesWasm(env.page);
|
||||
assert(
|
||||
ok,
|
||||
"the popup refused to compile WebAssembly. The shipped manifest CSP " +
|
||||
"has lost 'wasm-unsafe-eval', so libsodium is back on its wasm2js " +
|
||||
"fallback and every password derivation costs roughly 20x what it " +
|
||||
"should — see the backend note in src/shared/vault.js",
|
||||
);
|
||||
});
|
||||
|
||||
test("wallet creation through the UI reaches the main view", async (env) => {
|
||||
env.phrase = await createWallet(env.page);
|
||||
assert(
|
||||
|
||||
@@ -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,166 +0,0 @@
|
||||
// Tests for src/shared/holders.js and the balance-list spam gate that reads
|
||||
// it (issue #230).
|
||||
//
|
||||
// The rule these pin down: an explorer that reports no holders_count has told
|
||||
// us nothing, and "nothing" must not be recorded as "zero holders". Zero is
|
||||
// the strongest spam signal the wallet has, so handing it out for free turns
|
||||
// a missing field into a hidden asset.
|
||||
|
||||
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: {} } };
|
||||
|
||||
const {
|
||||
LOW_HOLDER_THRESHOLD,
|
||||
parseHoldersCount,
|
||||
isLowHolderCount,
|
||||
} = require("../src/shared/holders");
|
||||
const { fetchTokenBalances } = require("../src/shared/balances");
|
||||
const { debugFetch } = require("../src/shared/log");
|
||||
|
||||
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
|
||||
const HOLDER = "0x66133e8ea0f5d1d612d2502a968757d1048c214a";
|
||||
const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
||||
const NOVEL_TOKEN = "0x1111111111111111111111111111111111111111";
|
||||
|
||||
describe("parseHoldersCount", () => {
|
||||
test("a reported count parses to that number", () => {
|
||||
expect(parseHoldersCount("3500000")).toBe(3500000);
|
||||
expect(parseHoldersCount(3500000)).toBe(3500000);
|
||||
});
|
||||
|
||||
test('a reported "0" parses to 0, which is not null', () => {
|
||||
expect(parseHoldersCount("0")).toBe(0);
|
||||
expect(parseHoldersCount(0)).toBe(0);
|
||||
});
|
||||
|
||||
test("an omitted, null or empty count is unknown", () => {
|
||||
expect(parseHoldersCount(undefined)).toBeNull();
|
||||
expect(parseHoldersCount(null)).toBeNull();
|
||||
expect(parseHoldersCount("")).toBeNull();
|
||||
});
|
||||
|
||||
test("an unparseable count is unknown rather than zero", () => {
|
||||
expect(parseHoldersCount("many")).toBeNull();
|
||||
expect(parseHoldersCount(NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLowHolderCount", () => {
|
||||
test("the threshold is the documented 1,000 holders", () => {
|
||||
expect(LOW_HOLDER_THRESHOLD).toBe(1000);
|
||||
});
|
||||
|
||||
test("a reported count below the threshold is low", () => {
|
||||
expect(isLowHolderCount(0)).toBe(true);
|
||||
expect(isLowHolderCount(999)).toBe(true);
|
||||
});
|
||||
|
||||
test("a reported count at or above the threshold is not low", () => {
|
||||
expect(isLowHolderCount(1000)).toBe(false);
|
||||
expect(isLowHolderCount(1001)).toBe(false);
|
||||
});
|
||||
|
||||
test("an unknown count is not low", () => {
|
||||
expect(isLowHolderCount(null)).toBe(false);
|
||||
expect(isLowHolderCount(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// fetchTokenBalances applies its own spam gate, which is not the low-holder
|
||||
// display filter: it has no setting behind it and decides what the balance
|
||||
// list contains at all. It stays strict on an unknown count — see the
|
||||
// comment at the gate — but must stop recording that unknown as zero.
|
||||
describe("the balance-list spam gate", () => {
|
||||
function respondWith(items) {
|
||||
debugFetch.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => items,
|
||||
}));
|
||||
}
|
||||
|
||||
function item(overrides = {}) {
|
||||
const { token, ...rest } = overrides;
|
||||
return {
|
||||
value: "12500000",
|
||||
...rest,
|
||||
token: {
|
||||
type: "ERC-20",
|
||||
address_hash: NOVEL_TOKEN,
|
||||
symbol: "SPAMTKN",
|
||||
name: "Spam Token",
|
||||
decimals: "6",
|
||||
holders_count: "50000",
|
||||
...token,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
debugFetch.mockReset();
|
||||
});
|
||||
|
||||
test("a token with plenty of reported holders is listed", async () => {
|
||||
respondWith([item()]);
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||
expect(balances).toHaveLength(1);
|
||||
expect(balances[0].holders).toBe(50000);
|
||||
});
|
||||
|
||||
test("a token reporting zero holders is still excluded", async () => {
|
||||
respondWith([item({ token: { holders_count: "0" } })]);
|
||||
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||
});
|
||||
|
||||
test("an unknown holder count does not admit an unvouched token", async () => {
|
||||
respondWith([item({ token: { holders_count: null } })]);
|
||||
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||
});
|
||||
|
||||
// The path that reaches the send selector and the history filter: a token
|
||||
// the user vouched for by tracking it is listed whatever the explorer
|
||||
// says, and it must carry the unknown count through as null, not as the
|
||||
// zero that would then hide it downstream.
|
||||
test("a tracked token with an unknown count is listed with holders null", async () => {
|
||||
respondWith([item({ token: { holders_count: undefined } })]);
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
|
||||
{ address: NOVEL_TOKEN.toUpperCase() },
|
||||
]);
|
||||
expect(balances).toHaveLength(1);
|
||||
expect(balances[0].holders).toBeNull();
|
||||
});
|
||||
|
||||
test("a known-list token with an unknown count is listed with holders null", async () => {
|
||||
respondWith([
|
||||
item({
|
||||
token: {
|
||||
address_hash: USDC_CONTRACT,
|
||||
symbol: "USDC",
|
||||
holders_count: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||
expect(balances).toHaveLength(1);
|
||||
expect(balances[0].holders).toBeNull();
|
||||
});
|
||||
|
||||
test("no test in this file performed a network request", () => {
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
// The shipped Content Security Policy, pinned in both directions.
|
||||
//
|
||||
// This is the anti-regression check for #182. libsodium decides its
|
||||
// backend by trying to compile WebAssembly and catching the failure, so a
|
||||
// CSP that refuses WASM demotes the vault to the wasm2js translation —
|
||||
// roughly 20x slower per Argon2id derivation — and says so only in a
|
||||
// console message nobody reads. Dropping 'wasm-unsafe-eval' from either
|
||||
// manifest therefore has to fail a check, not a log line.
|
||||
//
|
||||
// It is equally a check against loosening. 'wasm-unsafe-eval' is granted
|
||||
// deliberately and narrowly (see the backend note in src/shared/vault.js);
|
||||
// 'unsafe-eval', 'unsafe-inline' and any remote script source are not, and
|
||||
// an exact match on the token set is what keeps the next edit from
|
||||
// smuggling one in alongside.
|
||||
//
|
||||
// build.js copies these files to dist/<target>/manifest.json verbatim, so
|
||||
// what is asserted here is what ships.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
|
||||
|
||||
const EXPECTED_SCRIPT_SRC = ["'self'", "'wasm-unsafe-eval'"];
|
||||
const EXPECTED_OBJECT_SRC = ["'self'"];
|
||||
|
||||
const FORBIDDEN_SOURCES = [
|
||||
"'unsafe-eval'",
|
||||
"'unsafe-inline'",
|
||||
"http:",
|
||||
"https:",
|
||||
"data:",
|
||||
"blob:",
|
||||
"*",
|
||||
];
|
||||
|
||||
function readManifest(name) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(MANIFEST_DIR, name + ".json"), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
// "script-src 'self'; object-src 'self'" -> { "script-src": ["'self'"], ... }
|
||||
function parseCsp(policy) {
|
||||
const directives = {};
|
||||
for (const part of policy.split(";")) {
|
||||
const tokens = part.trim().split(/\s+/).filter(Boolean);
|
||||
if (tokens.length === 0) continue;
|
||||
directives[tokens[0]] = tokens.slice(1);
|
||||
}
|
||||
return directives;
|
||||
}
|
||||
|
||||
function assertPolicy(policy) {
|
||||
const directives = parseCsp(policy);
|
||||
expect(Object.keys(directives).sort()).toEqual([
|
||||
"object-src",
|
||||
"script-src",
|
||||
]);
|
||||
expect(directives["script-src"].slice().sort()).toEqual(
|
||||
EXPECTED_SCRIPT_SRC,
|
||||
);
|
||||
expect(directives["object-src"].slice().sort()).toEqual(
|
||||
EXPECTED_OBJECT_SRC,
|
||||
);
|
||||
for (const source of FORBIDDEN_SOURCES) {
|
||||
expect(directives["script-src"]).not.toContain(source);
|
||||
expect(directives["object-src"]).not.toContain(source);
|
||||
}
|
||||
}
|
||||
|
||||
describe("shipped Content Security Policy", () => {
|
||||
// MV3 takes an object and applies extension_pages to the popup and the
|
||||
// background service worker, which is where libsodium runs.
|
||||
test("chrome MV3 allows WASM and nothing else beyond 'self'", () => {
|
||||
const csp = readManifest("chrome").content_security_policy;
|
||||
expect(typeof csp).toBe("object");
|
||||
expect(Object.keys(csp)).toEqual(["extension_pages"]);
|
||||
assertPolicy(csp.extension_pages);
|
||||
});
|
||||
|
||||
// MV2 takes the policy as a bare string. Firefox does not require
|
||||
// 'wasm-unsafe-eval' for MV2 today — enforcement is report-only and
|
||||
// Bugzilla 1770909 is still open — so that token is future-proofing
|
||||
// for when it lands, not a mandate, and it stays inside Firefox's MV2
|
||||
// base-CSP ceiling. object-src 'self' is the load-bearing half: a
|
||||
// Firefox before 106 rejects an MV2 policy string that omits
|
||||
// object-src and falls back to its own default, discarding everything
|
||||
// declared here. Same policy as Chrome, different manifest shape.
|
||||
test("firefox MV2 allows WASM and nothing else beyond 'self'", () => {
|
||||
const csp = readManifest("firefox").content_security_policy;
|
||||
expect(typeof csp).toBe("string");
|
||||
assertPolicy(csp);
|
||||
});
|
||||
|
||||
// The two targets share one codebase and one crypto path; a policy
|
||||
// that drifts apart between them means one of the two builds is
|
||||
// running a backend nothing tests.
|
||||
test("both targets ship the same policy", () => {
|
||||
const chrome =
|
||||
readManifest("chrome").content_security_policy.extension_pages;
|
||||
const firefox = readManifest("firefox").content_security_policy;
|
||||
expect(firefox).toBe(chrome);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
// Tests for the token filtering in the Send view's token selector
|
||||
// (src/popup/views/send.js).
|
||||
//
|
||||
// The selector decides which of the user's tokens can be spent at all, so
|
||||
// over-filtering here is worse than in the history list: the asset is not
|
||||
// merely hidden, it becomes unspendable through the UI. Issue #230: an
|
||||
// explorer that omits holders_count was read as "zero holders" and the token
|
||||
// disappeared from this list.
|
||||
//
|
||||
// renderSendTokenSelect only ever touches getElementById, createElement,
|
||||
// innerHTML, value, textContent and appendChild, so a small stub document is
|
||||
// enough to drive it; the real DOM behaviour of the view is covered by
|
||||
// tests/e2e/run.js.
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { state } = require("../src/shared/state");
|
||||
const { renderSendTokenSelect } = require("../src/popup/views/send");
|
||||
|
||||
const USDC_CONTRACT = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
const NOVEL_TOKEN = "0x1111111111111111111111111111111111111111";
|
||||
|
||||
let select;
|
||||
|
||||
function installStubDocument() {
|
||||
select = { innerHTML: "", children: [] };
|
||||
select.appendChild = (child) => select.children.push(child);
|
||||
globalThis.document = {
|
||||
getElementById: (id) => (id === "send-token" ? select : null),
|
||||
createElement: () => ({ value: "", textContent: "" }),
|
||||
};
|
||||
}
|
||||
|
||||
// The symbols offered for sending, excluding the hardcoded ETH option that
|
||||
// renderSendTokenSelect writes straight into innerHTML.
|
||||
function offeredTokens() {
|
||||
return select.children.map((opt) => opt.value.toLowerCase());
|
||||
}
|
||||
|
||||
function tokenBalance(overrides) {
|
||||
return {
|
||||
address: NOVEL_TOKEN,
|
||||
symbol: "SPAMTKN",
|
||||
decimals: 18,
|
||||
balance: "12.5",
|
||||
holders: 50000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(tokenBalances) {
|
||||
installStubDocument();
|
||||
renderSendTokenSelect({ address: "0x" + "a".repeat(40), tokenBalances });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.fraudContracts = [];
|
||||
state.hideLowHolderTokens = true;
|
||||
});
|
||||
|
||||
describe("the low-holder rule in the send token selector", () => {
|
||||
test("ETH is always offered", () => {
|
||||
render([]);
|
||||
expect(select.innerHTML).toBe('<option value="ETH">ETH</option>');
|
||||
expect(offeredTokens()).toEqual([]);
|
||||
});
|
||||
|
||||
test("a token with plenty of holders is offered", () => {
|
||||
render([tokenBalance()]);
|
||||
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||
});
|
||||
|
||||
test("a token reporting zero holders is withheld", () => {
|
||||
render([tokenBalance({ holders: 0 })]);
|
||||
expect(offeredTokens()).toEqual([]);
|
||||
});
|
||||
|
||||
test("boundary: 999 holders is withheld, 1000 is offered", () => {
|
||||
render([tokenBalance({ holders: 999 })]);
|
||||
expect(offeredTokens()).toEqual([]);
|
||||
render([tokenBalance({ holders: 1000 })]);
|
||||
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||
});
|
||||
|
||||
// Issue #230: an unknown holder count must not read as zero. A token the
|
||||
// user demonstrably holds — it has a balance — cannot be made unspendable
|
||||
// by a field the block explorer failed to report.
|
||||
test("a token whose holder count is unknown is still offered", () => {
|
||||
render([tokenBalance({ holders: null })]);
|
||||
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||
});
|
||||
|
||||
test("a token balance carrying no holders field at all is offered", () => {
|
||||
const t = tokenBalance();
|
||||
delete t.holders;
|
||||
render([t]);
|
||||
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||
});
|
||||
|
||||
test("the rule is bypassed entirely when the setting is off", () => {
|
||||
state.hideLowHolderTokens = false;
|
||||
render([tokenBalance({ holders: 0 })]);
|
||||
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the other send-selector rules are unaffected", () => {
|
||||
test("a token spoofing a known symbol from a wrong address is withheld", () => {
|
||||
render([
|
||||
tokenBalance({ symbol: "USDC", holders: null }),
|
||||
tokenBalance({ address: USDC_CONTRACT, symbol: "USDC" }),
|
||||
]);
|
||||
expect(offeredTokens()).toEqual([USDC_CONTRACT.toLowerCase()]);
|
||||
});
|
||||
|
||||
test("a blocklisted fraud contract is withheld even with an unknown count", () => {
|
||||
state.fraudContracts = [NOVEL_TOKEN.toUpperCase()];
|
||||
render([tokenBalance({ holders: null })]);
|
||||
expect(offeredTokens()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1473,65 +1473,6 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
||||
});
|
||||
|
||||
// Regression guards (#230): the explorer's holders_count is optional. A
|
||||
// missing field means the count is unknown; it does not mean the token
|
||||
// has no holders. Recording the two as the same number both hides a
|
||||
// legitimate token and makes the `holders !== null` guard in
|
||||
// filterTransactions unreachable for token transfers.
|
||||
describe("an unreported holders_count is unknown, not zero", () => {
|
||||
function spamTransferWithToken(token) {
|
||||
return [
|
||||
{
|
||||
transaction_hash: "0x" + "9".repeat(64),
|
||||
block_number: 21000070,
|
||||
timestamp: TS,
|
||||
from: { hash: ORDINARY_PEER },
|
||||
to: { hash: VICTIM },
|
||||
total: { value: "1500500000", decimals: "6" },
|
||||
token: token,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const OMITTED = {
|
||||
symbol: NOVEL_SPAM_SYMBOL,
|
||||
address_hash: NOVEL_SPAM_CONTRACT,
|
||||
};
|
||||
const NULLED = { ...OMITTED, holders_count: null };
|
||||
const ZERO = { ...OMITTED, holders_count: "0" };
|
||||
|
||||
test("an omitted holders_count parses to null", async () => {
|
||||
respondWith([], spamTransferWithToken(OMITTED));
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
expect(txs[0].holders).toBeNull();
|
||||
});
|
||||
|
||||
test("a null holders_count parses to null", async () => {
|
||||
respondWith([], spamTransferWithToken(NULLED));
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
expect(txs[0].holders).toBeNull();
|
||||
});
|
||||
|
||||
test("the transfer survives the low-holder filter", async () => {
|
||||
respondWith([], spamTransferWithToken(OMITTED));
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
expect(filterTransactions(txs, filters()).transactions).toEqual(
|
||||
txs,
|
||||
);
|
||||
});
|
||||
|
||||
// The regression this fix could cause: a token that genuinely
|
||||
// reports zero holders must keep being filtered. Unlike the fake
|
||||
// "ETH" fixture above, this symbol is not in the token list, so the
|
||||
// holder count is the only rule that can catch it.
|
||||
test('a reported holders_count of "0" still parses to 0 and is filtered', async () => {
|
||||
respondWith([], spamTransferWithToken(ZERO));
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
expect(txs[0].holders).toBe(0);
|
||||
expect(filterTransactions(txs, filters()).transactions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("failed responses yield an empty list rather than throwing", async () => {
|
||||
debugFetch.mockImplementation(async () => ({
|
||||
ok: false,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
// The unit tests must exercise the libsodium backend that actually ships
|
||||
// (#182). Before this, they could not: node compiles WebAssembly happily,
|
||||
// the extension CSP refused it, and so the browser silently ran the
|
||||
// wasm2js translation while every test ran the WASM build.
|
||||
//
|
||||
// With 'wasm-unsafe-eval' in both manifests the two agree, and these tests
|
||||
// hold that agreement in place from the node side. tests/manifest.test.js
|
||||
// holds up the CSP end of it, and the end-to-end suite observes the real
|
||||
// popup.
|
||||
|
||||
const { cryptoBackend } = require("../src/shared/vault");
|
||||
|
||||
// The module libsodium-wrappers-sumo itself requires and drives. Not a new
|
||||
// dependency: it is inspected here, never used to perform crypto, because
|
||||
// it is the only thing that can say which backend is loaded.
|
||||
const SODIUM_CORE = "libsodium-sumo";
|
||||
|
||||
describe("libsodium backend", () => {
|
||||
test("this realm compiles WebAssembly, so the tests run the WASM build", async () => {
|
||||
await expect(cryptoBackend()).resolves.toBe("wasm");
|
||||
});
|
||||
|
||||
test("libsodium did not swap in the wasm2js fallback", async () => {
|
||||
const core = require(SODIUM_CORE);
|
||||
await require("libsodium-wrappers-sumo").ready;
|
||||
// useBackupModule is the entry point to the fallback; taking it
|
||||
// replaces the module's exports with the translation's, and the
|
||||
// entry point goes with them. Still present after ready means the
|
||||
// WASM module is the one in place. The test below is what keeps
|
||||
// that inference honest.
|
||||
expect(typeof core.useBackupModule).toBe("function");
|
||||
});
|
||||
|
||||
// Deliberately last, and deliberately destructive: it takes the
|
||||
// fallback, which replaces the loaded module for the rest of this
|
||||
// file. Jest gives each test file its own module registry, so nothing
|
||||
// outside sees it.
|
||||
//
|
||||
// Without this, the check above would be a claim about libsodium's
|
||||
// internals with nothing holding it to account: if a future version
|
||||
// kept useBackupModule on the fallback module too, the marker would
|
||||
// quietly become true in both backends and the test would pass while
|
||||
// measuring nothing. Forcing the fallback and watching the marker
|
||||
// disappear is what makes its presence mean something.
|
||||
test("the fallback marker distinguishes the two backends", async () => {
|
||||
const core = require(SODIUM_CORE);
|
||||
await require("libsodium-wrappers-sumo").ready;
|
||||
expect(typeof core.useBackupModule).toBe("function");
|
||||
await core.useBackupModule();
|
||||
expect(typeof core.useBackupModule).toBe("undefined");
|
||||
});
|
||||
});
|
||||
@@ -1,290 +0,0 @@
|
||||
// Tests for the stored-state half of the non-master extended key problem.
|
||||
//
|
||||
// Refusing a non-master xprv at import time does nothing for a wallet that is
|
||||
// already in storage: the import that created it ran before the refusal
|
||||
// existed. Such a wallet used to sign for the wrong tree and now throws on the
|
||||
// send screen instead. These tests pin down that it is named and explained in
|
||||
// the wallet list, that nothing on the way there throws, and that a wallet
|
||||
// imported from a real master key is untouched by any of it.
|
||||
|
||||
const { HDNodeWallet, Mnemonic } = require("ethers");
|
||||
|
||||
const wallet = require("../src/shared/wallet");
|
||||
const {
|
||||
walletDefect,
|
||||
walletDefectHtml,
|
||||
NON_MASTER_XPRV,
|
||||
} = require("../src/shared/walletDefects");
|
||||
|
||||
// BIP-39 test vector phrase, published; never used for real funds.
|
||||
const VECTOR_PHRASE =
|
||||
"test test test test test test test test test test test junk";
|
||||
|
||||
function seedNode(phrase) {
|
||||
return HDNodeWallet.fromSeed(Mnemonic.fromPhrase(phrase, "").computeSeed());
|
||||
}
|
||||
|
||||
// The master (depth-0) key, which is what the import flow accepts today.
|
||||
function masterXprv(phrase) {
|
||||
return seedNode(phrase).extendedKey;
|
||||
}
|
||||
|
||||
// The account-level (depth-3) key m/44'/60'/0'. A normal thing for a user to
|
||||
// hold, and what the import flow used to accept.
|
||||
function accountXprv(phrase) {
|
||||
return seedNode(phrase).derivePath("m/44'/60'/0'").extendedKey;
|
||||
}
|
||||
|
||||
// The wallet record the CURRENT import path writes for a master key: the
|
||||
// neutered m/44'/60'/0'/0 node, four levels below a depth-0 key.
|
||||
function healthyXprvWallet(name = "Master") {
|
||||
const { xpub, firstAddress } = wallet.hdWalletFromXprv(
|
||||
masterXprv(VECTOR_PHRASE),
|
||||
);
|
||||
return {
|
||||
name,
|
||||
type: "xprv",
|
||||
xpub,
|
||||
nextIndex: 1,
|
||||
encryptedSecret: "irrelevant-to-these-tests",
|
||||
addresses: [{ address: firstAddress, balance: "0.0000" }],
|
||||
};
|
||||
}
|
||||
|
||||
// The wallet record the PRE-#210 import path wrote for an account-level key:
|
||||
// the same four levels, but derived as a relative path *beneath* the key, so
|
||||
// the stored xpub sits at depth 3 + 4 = 7. Built here the way the old code
|
||||
// built it rather than by calling the module under test, which now refuses.
|
||||
function brokenXprvWallet(name = "Imported xprv") {
|
||||
const node = HDNodeWallet.fromExtendedKey(
|
||||
accountXprv(VECTOR_PHRASE),
|
||||
).derivePath("44'/60'/0'/0");
|
||||
return {
|
||||
name,
|
||||
type: "xprv",
|
||||
xpub: node.neuter().extendedKey,
|
||||
nextIndex: 1,
|
||||
encryptedSecret: "irrelevant-to-these-tests",
|
||||
addresses: [
|
||||
{ address: node.deriveChild(0).address, balance: "0.0000" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("the fixtures are what the two import paths actually produced", () => {
|
||||
test("a master import stores a depth-4 xpub", () => {
|
||||
expect(
|
||||
HDNodeWallet.fromExtendedKey(healthyXprvWallet().xpub).depth,
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
test("the pre-fix account-level import stored a depth-7 xpub", () => {
|
||||
expect(
|
||||
HDNodeWallet.fromExtendedKey(brokenXprvWallet().xpub).depth,
|
||||
).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("walletDefect", () => {
|
||||
test("names the defect on a stored non-master xprv wallet", () => {
|
||||
const defect = walletDefect(brokenXprvWallet());
|
||||
|
||||
expect(defect).not.toBeNull();
|
||||
expect(defect.id).toBe(NON_MASTER_XPRV);
|
||||
});
|
||||
|
||||
test("a depth-0 xprv wallet has no defect", () => {
|
||||
expect(walletDefect(healthyXprvWallet())).toBeNull();
|
||||
});
|
||||
|
||||
test("hd and key wallets are never assessed", () => {
|
||||
expect(
|
||||
walletDefect({ type: "hd", xpub: brokenXprvWallet().xpub }),
|
||||
).toBe(null);
|
||||
expect(walletDefect({ type: "key" })).toBeNull();
|
||||
});
|
||||
|
||||
test("an xprv wallet whose xpub cannot be parsed makes no claim", () => {
|
||||
// No basis to say the key was non-master, so nothing is asserted
|
||||
// about it rather than guessing.
|
||||
expect(walletDefect({ type: "xprv", xpub: "not-a-key" })).toBeNull();
|
||||
expect(walletDefect({ type: "xprv" })).toBeNull();
|
||||
});
|
||||
|
||||
test("nothing about the wallet record is modified by the check", () => {
|
||||
const w = brokenXprvWallet();
|
||||
const before = JSON.stringify(w);
|
||||
|
||||
walletDefect(w);
|
||||
|
||||
expect(JSON.stringify(w)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the explanatory copy", () => {
|
||||
const defect = walletDefect(brokenXprvWallet());
|
||||
|
||||
test("every sentence of it is a full sentence", () => {
|
||||
for (const text of [defect.heading, ...defect.paragraphs]) {
|
||||
expect(text).toMatch(/^[A-Z]/);
|
||||
expect(text.trimEnd()).toMatch(/\.$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("it says what was derived wrongly and that these are not the standard addresses", () => {
|
||||
const body = defect.paragraphs.join(" ");
|
||||
|
||||
expect(body).toContain("not a master key");
|
||||
expect(body).toMatch(/standard path/);
|
||||
});
|
||||
|
||||
test("it does not claim the funds are safe and does not claim a loss", () => {
|
||||
const all = [defect.heading, ...defect.paragraphs].join(" ");
|
||||
|
||||
expect(all).not.toMatch(/\bsafe\b/i);
|
||||
expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i);
|
||||
});
|
||||
|
||||
test("it says the wallet is not deleted and what the user can do", () => {
|
||||
const body = defect.paragraphs.join(" ");
|
||||
|
||||
expect(body).toMatch(/until you delete it yourself/);
|
||||
expect(body).toMatch(/recovery phrase/);
|
||||
});
|
||||
|
||||
test("it uses the project's vocabulary", () => {
|
||||
const all = [
|
||||
defect.heading,
|
||||
...defect.paragraphs,
|
||||
defect.shortMessage,
|
||||
].join(" ");
|
||||
|
||||
expect(all).not.toMatch(/seed phrase|mnemonic|passphrase/i);
|
||||
expect(all).not.toMatch(/\baccounts?\b/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("walletDefectHtml", () => {
|
||||
test("renders the heading and every paragraph for a defective wallet", () => {
|
||||
const defect = walletDefect(brokenXprvWallet());
|
||||
const html = walletDefectHtml(brokenXprvWallet());
|
||||
|
||||
expect(html).toContain(defect.heading);
|
||||
for (const p of defect.paragraphs) {
|
||||
expect(html).toContain(p);
|
||||
}
|
||||
});
|
||||
|
||||
test("renders nothing at all for a healthy wallet", () => {
|
||||
expect(walletDefectHtml(healthyXprvWallet())).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wallet list", () => {
|
||||
let home;
|
||||
let state;
|
||||
|
||||
beforeAll(() => {
|
||||
global.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
runtime: { sendMessage: () => {} },
|
||||
};
|
||||
home = require("../src/popup/views/home");
|
||||
state = require("../src/shared/state").state;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.wallets = [];
|
||||
state.activeAddress = null;
|
||||
});
|
||||
|
||||
test("a stored depth-3 xprv wallet renders the explanation", () => {
|
||||
state.wallets = [brokenXprvWallet("Imported xprv")];
|
||||
|
||||
const html = home.walletListHtml();
|
||||
|
||||
expect(html).toContain(walletDefect(state.wallets[0]).heading);
|
||||
expect(html).toContain("Imported xprv");
|
||||
});
|
||||
|
||||
test("it does not offer to derive further addresses from that wallet", () => {
|
||||
state.wallets = [brokenXprvWallet()];
|
||||
|
||||
expect(home.walletListHtml()).not.toContain("btn-add-address");
|
||||
});
|
||||
|
||||
test("a normal depth-0 xprv wallet renders exactly as it did before", () => {
|
||||
state.wallets = [healthyXprvWallet("Master")];
|
||||
|
||||
const html = home.walletListHtml();
|
||||
|
||||
expect(html).not.toContain(walletDefect(brokenXprvWallet()).heading);
|
||||
expect(html).toContain("btn-add-address");
|
||||
expect(html).toContain(state.wallets[0].addresses[0].address);
|
||||
});
|
||||
|
||||
test("the defective wallet's notice does not bleed onto a healthy one", () => {
|
||||
state.wallets = [brokenXprvWallet("Broken"), healthyXprvWallet("Fine")];
|
||||
|
||||
const html = home.walletListHtml();
|
||||
const healthyPart = html.slice(html.indexOf("Fine"));
|
||||
|
||||
expect(html).toContain(walletDefect(state.wallets[0]).heading);
|
||||
expect(healthyPart).not.toContain(
|
||||
walletDefect(state.wallets[0]).heading,
|
||||
);
|
||||
expect(healthyPart).toContain("btn-add-address");
|
||||
});
|
||||
});
|
||||
|
||||
describe("no path throws an unhandled error for a defective wallet", () => {
|
||||
test("address derivation from the stored xpub still works", () => {
|
||||
// The stored xpub is at a non-standard depth but is a valid extended
|
||||
// key; deriving from it is what the list render already does.
|
||||
const w = brokenXprvWallet();
|
||||
|
||||
expect(() => wallet.deriveAddressFromXpub(w.xpub, 0)).not.toThrow();
|
||||
expect(wallet.deriveAddressFromXpub(w.xpub, 0)).toBe(
|
||||
w.addresses[0].address,
|
||||
);
|
||||
});
|
||||
|
||||
test("the wallet list renders without throwing", () => {
|
||||
const { state } = require("../src/shared/state");
|
||||
const home = require("../src/popup/views/home");
|
||||
state.wallets = [brokenXprvWallet()];
|
||||
|
||||
expect(() => home.walletListHtml()).not.toThrow();
|
||||
|
||||
state.wallets = [];
|
||||
});
|
||||
|
||||
test("signing refuses with the named defect rather than a bare failure", () => {
|
||||
// getSignerForAddress is the backstop behind the UI gate. It must
|
||||
// still refuse, and it must say why in a sentence the user can read.
|
||||
let thrown = null;
|
||||
try {
|
||||
wallet.getSignerForAddress(
|
||||
{ type: "xprv" },
|
||||
0,
|
||||
accountXprv(VECTOR_PHRASE),
|
||||
);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
|
||||
expect(thrown).not.toBeNull();
|
||||
expect(thrown.message).toMatch(/master key/);
|
||||
expect(thrown.message.trimEnd()).toMatch(/\.$/);
|
||||
});
|
||||
|
||||
test("a healthy xprv wallet signs as it always did", () => {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "xprv" },
|
||||
0,
|
||||
masterXprv(VECTOR_PHRASE),
|
||||
);
|
||||
|
||||
expect(signer.address).toBe(healthyXprvWallet().addresses[0].address);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user