Compare commits
4 Commits
48f1edae57
...
4e2ca87a06
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e2ca87a06 | |||
| bd4bdcafc7 | |||
| ce4a0d7b8d | |||
| bf1dbec87c |
57
README.md
57
README.md
@@ -123,16 +123,17 @@ 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, 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, 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.
|
||||
|
||||
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
|
||||
@@ -435,7 +436,11 @@ 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.
|
||||
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.
|
||||
- An **address** holds ETH and ERC-20 tokens.
|
||||
- The user can have multiple wallets, each with multiple addresses (HD) or a
|
||||
single address (key).
|
||||
@@ -1068,6 +1073,36 @@ 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
|
||||
|
||||
23
TODO.md
23
TODO.md
@@ -44,6 +44,24 @@ undefined identifiers, which is how
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-12: Approval verification became an allowlist — transaction type
|
||||
restricted to 0/1/2 so an EIP-7702 delegation can no longer ride along on an
|
||||
approved transfer, every consequential field compared, the artifact
|
||||
re-serialized from the checked fields alone and its exact bytes required to be
|
||||
the canonical encoding of what was broadcast. One approval now yields at most
|
||||
one broadcast, and every path that retires a pending approval — popup close,
|
||||
active-address change, a late reject — goes through a single chokepoint that
|
||||
refuses to settle an attempt already claimed for signing and broadcast
|
||||
([#174](https://git.eeqj.de/sneak/AutistMask/issues/174)).
|
||||
- 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
|
||||
@@ -51,6 +69,11 @@ 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,6 +5,9 @@
|
||||
"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,6 +4,7 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,16 @@ const {
|
||||
} = require("../shared/state");
|
||||
const { refreshBalances, getProvider } = require("../shared/balances");
|
||||
const { debugFetch, log } = require("../shared/log");
|
||||
const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
|
||||
const {
|
||||
verifySignedTx,
|
||||
verifySignature,
|
||||
failureIsRetryable,
|
||||
describeTxFailure,
|
||||
TX_STAGE_SIGN,
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
TX_STAGE_INFLIGHT,
|
||||
} = require("../shared/approvalVerify");
|
||||
const {
|
||||
isPhishingDomain,
|
||||
refreshPhishingListOnSchedule,
|
||||
@@ -107,6 +116,55 @@ function resetPopupUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
// Settle a pending approval: hand `result` to the promise the requesting page
|
||||
// is waiting on and retire the approval. This is the ONLY place an approval is
|
||||
// resolved or removed — the popup closing, an active-address switch, a reject
|
||||
// from the popup and the attempt that signs and broadcasts all come through
|
||||
// here — because a settlement that bypasses the claim below is a fund-loss bug
|
||||
// and enumerating the call sites has repeatedly missed one.
|
||||
//
|
||||
// A claimed approval belongs to the attempt holding the claim, and only that
|
||||
// attempt may settle it. Anything else settling first would leave the attempt
|
||||
// running to completion against an already-settled promise: the transaction
|
||||
// reaches the chain while the page is told "User rejected the request", and the
|
||||
// user's natural response is to send it again at a fresh nonce.
|
||||
//
|
||||
// Returns false when the approval is gone or claimed by someone else, so the
|
||||
// caller can refuse instead of assuming it settled.
|
||||
function settleApproval(id, result, options) {
|
||||
const approval = pendingApprovals[id];
|
||||
if (!approval) return false;
|
||||
const holdsClaim = !!(options && options.holdsClaim);
|
||||
if (approval.attemptInFlight && !holdsClaim) return false;
|
||||
delete pendingApprovals[id];
|
||||
approval.resolve(result);
|
||||
resetPopupUrl();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Take exclusive hold of a pending approval for one attempt, or refuse.
|
||||
//
|
||||
// An approval that failed retryably has to stay in pendingApprovals, so its
|
||||
// presence cannot be the interlock against a second attempt; this flag is. It
|
||||
// is set synchronously, before the handler's first await, so a second response
|
||||
// carrying the same id — a reloaded approval window re-rendering a live
|
||||
// Approve button, a popup that emits the message twice — finds the attempt
|
||||
// already running instead of starting an independent verify and broadcast.
|
||||
// Without it one approval can put two transactions on the chain: with the
|
||||
// ordinary dApp approval shape the page fixes no nonce, so two artifacts
|
||||
// signed at different nonces both verify.
|
||||
function claimApproval(approval) {
|
||||
if (approval.attemptInFlight) return false;
|
||||
approval.attemptInFlight = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Release an approval whose attempt failed in a way the user can retry.
|
||||
// Nothing was broadcast, so the next attempt may claim it.
|
||||
function releaseApproval(approval) {
|
||||
approval.attemptInFlight = false;
|
||||
}
|
||||
|
||||
// Open approval in a separate popup window.
|
||||
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
|
||||
// not from a user gesture) and the fallback for site-connection approvals.
|
||||
@@ -215,8 +273,7 @@ runtime.onConnect.addListener((port) => {
|
||||
// Keep pending — user can reopen the toolbar popup
|
||||
return;
|
||||
}
|
||||
approval.resolve({ approved: false, remember: false });
|
||||
delete pendingApprovals[id];
|
||||
settleApproval(id, { approved: false, remember: false });
|
||||
}
|
||||
resetPopupUrl();
|
||||
});
|
||||
@@ -547,15 +604,21 @@ async function broadcastAccountsChanged() {
|
||||
for (const key of Object.keys(connectedSites)) {
|
||||
delete connectedSites[key];
|
||||
}
|
||||
// Reject and close any pending approval popups so they don't hang
|
||||
// Reject and close any pending approval popups so they don't hang. An
|
||||
// approval an attempt has already claimed is left alone entirely: it is
|
||||
// being signed and broadcast right now, and neither rejecting it to the
|
||||
// page nor closing the window it is reporting into is survivable.
|
||||
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
||||
if (approval.type === "tx" || approval.type === "sign") {
|
||||
approval.resolve({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
} else {
|
||||
approval.resolve({ approved: false, remember: false });
|
||||
}
|
||||
const rejection =
|
||||
approval.type === "tx" || approval.type === "sign"
|
||||
? {
|
||||
error: {
|
||||
code: 4001,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
}
|
||||
: { approved: false, remember: false };
|
||||
if (!settleApproval(id, rejection)) continue;
|
||||
if (approval.windowId) {
|
||||
windowsApi.remove(approval.windowId, () => {
|
||||
if (runtime.lastError) {
|
||||
@@ -563,7 +626,6 @@ async function broadcastAccountsChanged() {
|
||||
}
|
||||
});
|
||||
}
|
||||
delete pendingApprovals[id];
|
||||
}
|
||||
resetPopupUrl();
|
||||
const s = await getState();
|
||||
@@ -679,23 +741,26 @@ if (runtime.onStartup) {
|
||||
}
|
||||
startBackgroundJobs();
|
||||
|
||||
// When approval window is closed without a response, treat as rejection
|
||||
// When approval window is closed without a response, treat as rejection.
|
||||
// "Without a response" is the operative part: the popup stays open across the
|
||||
// verify and broadcast it is waiting on, so a user closing an apparently-hung
|
||||
// window is an ordinary event with an attempt already in flight behind it.
|
||||
// settleApproval() refuses those, which leaves the attempt to report its real
|
||||
// outcome to the page.
|
||||
if (windowsApi && windowsApi.onRemoved) {
|
||||
windowsApi.onRemoved.addListener((windowId) => {
|
||||
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
||||
if (approval.windowId === windowId) {
|
||||
if (approval.type === "tx" || approval.type === "sign") {
|
||||
approval.resolve({
|
||||
error: {
|
||||
code: 4001,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
approval.resolve({ approved: false, remember: false });
|
||||
}
|
||||
delete pendingApprovals[id];
|
||||
}
|
||||
if (approval.windowId !== windowId) continue;
|
||||
const rejection =
|
||||
approval.type === "tx" || approval.type === "sign"
|
||||
? {
|
||||
error: {
|
||||
code: 4001,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
}
|
||||
: { approved: false, remember: false };
|
||||
settleApproval(id, rejection);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -761,14 +826,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
}
|
||||
|
||||
if (msg.type === "AUTISTMASK_APPROVAL_RESPONSE") {
|
||||
const approval = pendingApprovals[msg.id];
|
||||
if (approval) {
|
||||
approval.resolve({
|
||||
approved: msg.approved,
|
||||
remember: msg.remember,
|
||||
});
|
||||
delete pendingApprovals[msg.id];
|
||||
}
|
||||
settleApproval(msg.id, {
|
||||
approved: msg.approved,
|
||||
remember: msg.remember,
|
||||
});
|
||||
resetPopupUrl();
|
||||
return false;
|
||||
}
|
||||
@@ -776,21 +837,50 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === "AUTISTMASK_TX_RESPONSE") {
|
||||
const approval = pendingApprovals[msg.id];
|
||||
if (!approval) return false;
|
||||
delete pendingApprovals[msg.id];
|
||||
resetPopupUrl();
|
||||
|
||||
// A reject arriving while an attempt holds the approval is refused,
|
||||
// not honoured: the attempt is on its way to broadcasting the
|
||||
// transaction, and resolving 4001 here would tell the page the request
|
||||
// was rejected while it goes out.
|
||||
if (!msg.approved) {
|
||||
approval.resolve({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
if (
|
||||
!settleApproval(msg.id, {
|
||||
error: {
|
||||
code: 4001,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
})
|
||||
) {
|
||||
sendResponse({
|
||||
error: "This transaction is already being sent.",
|
||||
retryable: false,
|
||||
stage: TX_STAGE_BROADCAST,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The popup signs; it reports back here when it could not. Fail the
|
||||
// request the same way this handler used to when it did the signing.
|
||||
// The popup signs; it reports back here when it could not. Keep the
|
||||
// approval so the user can correct the problem and try again with the
|
||||
// transaction they already saw.
|
||||
if (msg.error) {
|
||||
approval.resolve({ error: { message: msg.error } });
|
||||
sendResponse({ error: msg.error });
|
||||
const outcome = describeTxFailure(TX_STAGE_SIGN, msg.error);
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
retryable: outcome.retryable,
|
||||
stage: TX_STAGE_SIGN,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exactly one broadcast per approval, whatever the popup sends.
|
||||
if (!claimApproval(approval)) {
|
||||
sendResponse({
|
||||
error: "This transaction is already being sent.",
|
||||
retryable: false,
|
||||
stage: TX_STAGE_BROADCAST,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -800,22 +890,63 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
const activeAddress = await getActiveAddress();
|
||||
// The popup holds the secret, but the background stays the
|
||||
// authority on what is broadcast: the raw transaction must be
|
||||
// the approved one, signed by the approved address.
|
||||
// the approved one, signed by the approved address, on the
|
||||
// network that is selected.
|
||||
verifySignedTx(
|
||||
msg.rawSignedTx,
|
||||
approval.txParams,
|
||||
activeAddress,
|
||||
currentNetwork().chainId,
|
||||
);
|
||||
} catch (e) {
|
||||
// A signed transaction that is not the approved one is not
|
||||
// retried against that approval; it is refused outright.
|
||||
// Anything else that failed before the check ran is the
|
||||
// user's to retry.
|
||||
const outcome = describeTxFailure(TX_STAGE_VERIFY, e);
|
||||
if (outcome.spendApproval) {
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{ error: { message: outcome.error } },
|
||||
{ holdsClaim: true },
|
||||
);
|
||||
} else {
|
||||
releaseApproval(approval);
|
||||
}
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
retryable: outcome.retryable,
|
||||
stage: TX_STAGE_VERIFY,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
||||
approval.resolve({ txHash: tx.hash });
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{ txHash: tx.hash },
|
||||
{ holdsClaim: true },
|
||||
);
|
||||
sendResponse({ txHash: tx.hash });
|
||||
} catch (e) {
|
||||
const errMsg = e.shortMessage || e.message;
|
||||
approval.resolve({
|
||||
error: { message: errMsg },
|
||||
// Terminal, never retried: the node may have accepted the
|
||||
// transaction and still failed to answer, and the popup's
|
||||
// retry re-signs at a freshly fetched nonce rather than
|
||||
// re-broadcasting these bytes. Retrying would send the
|
||||
// approved transfer a second time.
|
||||
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{ error: { message: outcome.error } },
|
||||
{ holdsClaim: true },
|
||||
);
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
retryable: outcome.retryable,
|
||||
stage: TX_STAGE_BROADCAST,
|
||||
});
|
||||
sendResponse({ error: errMsg });
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
@@ -824,21 +955,43 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === "AUTISTMASK_SIGN_RESPONSE") {
|
||||
const approval = pendingApprovals[msg.id];
|
||||
if (!approval) return false;
|
||||
delete pendingApprovals[msg.id];
|
||||
resetPopupUrl();
|
||||
|
||||
// Same as the transaction path: a reject cannot retire an approval an
|
||||
// attempt already holds.
|
||||
if (!msg.approved) {
|
||||
approval.resolve({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
if (
|
||||
!settleApproval(msg.id, {
|
||||
error: {
|
||||
code: 4001,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
})
|
||||
) {
|
||||
sendResponse({
|
||||
error: "This request is already being signed.",
|
||||
retryable: false,
|
||||
stage: TX_STAGE_INFLIGHT,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The popup signs; it reports back here when it could not. Fail the
|
||||
// request the same way this handler used to when it did the signing.
|
||||
// The popup signs; it reports back here when it could not. Keep the
|
||||
// approval so the user can correct the problem and try again with the
|
||||
// message they already saw.
|
||||
if (msg.error) {
|
||||
approval.resolve({ error: { message: msg.error } });
|
||||
sendResponse({ error: msg.error });
|
||||
sendResponse({ error: msg.error, retryable: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exactly one signature handed back per approval.
|
||||
if (!claimApproval(approval)) {
|
||||
sendResponse({
|
||||
error: "This request is already being signed.",
|
||||
retryable: false,
|
||||
stage: TX_STAGE_INFLIGHT,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -851,14 +1004,21 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
// address.
|
||||
const signature = msg.signature;
|
||||
verifySignature(approval.signParams, signature, activeAddress);
|
||||
approval.resolve({ signature });
|
||||
settleApproval(msg.id, { signature }, { holdsClaim: true });
|
||||
sendResponse({ signature });
|
||||
} catch (e) {
|
||||
const errMsg = e.shortMessage || e.message;
|
||||
approval.resolve({
|
||||
error: { message: errMsg },
|
||||
});
|
||||
sendResponse({ error: errMsg });
|
||||
const retryable = failureIsRetryable(e);
|
||||
if (!retryable) {
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{ error: { message: errMsg } },
|
||||
{ holdsClaim: true },
|
||||
);
|
||||
} else {
|
||||
releaseApproval(approval);
|
||||
}
|
||||
sendResponse({ error: errMsg, retryable });
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
|
||||
@@ -29,6 +29,16 @@ const { log } = require("../../shared/log");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
const { walletDefect } = require("../../shared/walletDefects");
|
||||
|
||||
// The defect of the wallet the selected address belongs to, or null. Both the
|
||||
// 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]);
|
||||
}
|
||||
|
||||
let ctx;
|
||||
|
||||
@@ -254,6 +264,11 @@ 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
|
||||
@@ -298,6 +313,14 @@ 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;
|
||||
}
|
||||
pushCurrentView();
|
||||
const wallet = state.wallets[state.selectedWallet];
|
||||
const addr = wallet.addresses[state.selectedAddress];
|
||||
|
||||
@@ -35,6 +35,7 @@ const {
|
||||
} = require("./send");
|
||||
const { log } = require("../../shared/log");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const { walletDefect } = require("../../shared/walletDefects");
|
||||
|
||||
let ctx;
|
||||
|
||||
@@ -338,6 +339,11 @@ 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
|
||||
|
||||
@@ -21,7 +21,9 @@ 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 { describeSigningFailure } = require("../../shared/approvalVerify");
|
||||
const txStatus = require("./txStatus");
|
||||
const uniswap = require("../../shared/uniswap");
|
||||
const runtime =
|
||||
@@ -280,6 +282,7 @@ function showTxApproval(details) {
|
||||
|
||||
showView("approve-tx");
|
||||
attachCopyHandlers("view-approve-tx");
|
||||
gateOnWalletDefect("approve-tx-error", "btn-approve-tx");
|
||||
}
|
||||
|
||||
function decodeHexMessage(hex) {
|
||||
@@ -379,6 +382,7 @@ function showSignApproval(details) {
|
||||
|
||||
showView("approve-sign");
|
||||
attachCopyHandlers("view-approve-sign");
|
||||
gateOnWalletDefect("approve-sign-error", "btn-approve-sign");
|
||||
}
|
||||
|
||||
function show(id) {
|
||||
@@ -431,6 +435,20 @@ 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() {
|
||||
@@ -492,6 +510,14 @@ 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;
|
||||
@@ -546,10 +572,20 @@ function init(ctx) {
|
||||
runtime.sendMessage(payload, (response) => {
|
||||
if (response && response.txHash) {
|
||||
txStatus.showWait(pendingTxDetails, response.txHash);
|
||||
return;
|
||||
}
|
||||
// A retryable failure leaves the approval pending in the
|
||||
// background, so stay on this screen with a live button rather
|
||||
// than sending the user to a dead end.
|
||||
const outcome = describeSigningFailure(
|
||||
response,
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
if (outcome.retryable) {
|
||||
showError("approve-tx-error", outcome.message);
|
||||
setTxButtonBusy(false);
|
||||
} else {
|
||||
const msg =
|
||||
(response && response.error) || "Transaction failed.";
|
||||
txStatus.showError(pendingTxDetails, null, msg);
|
||||
txStatus.showError(pendingTxDetails, null, outcome.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -583,6 +619,14 @@ 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;
|
||||
@@ -644,11 +688,18 @@ function init(ctx) {
|
||||
runtime.sendMessage(payload, (response) => {
|
||||
if (response && response.signature) {
|
||||
window.close();
|
||||
} else {
|
||||
const msg = (response && response.error) || "Signing failed.";
|
||||
showError("approve-sign-error", msg);
|
||||
setSignButtonBusy(false);
|
||||
return;
|
||||
}
|
||||
// The button comes back only when the approval is still pending in
|
||||
// the background; otherwise it stays disabled and the message says
|
||||
// why, because a control that cannot succeed must not look like it
|
||||
// can.
|
||||
const outcome = describeSigningFailure(
|
||||
response,
|
||||
"The message could not be signed.",
|
||||
);
|
||||
showError("approve-sign-error", outcome.message);
|
||||
if (outcome.retryable) setSignButtonBusy(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ const {
|
||||
resetSendValidation,
|
||||
} = require("./send");
|
||||
const { deriveAddressFromXpub } = require("../../shared/wallet");
|
||||
const {
|
||||
walletDefect,
|
||||
walletDefectHtml,
|
||||
} = require("../../shared/walletDefects");
|
||||
const {
|
||||
formatUsd,
|
||||
getPrice,
|
||||
@@ -214,25 +218,23 @@ async function loadHomeTxs(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// The wallet list markup. Pure: it reads state and returns a string, so the
|
||||
// list can be asserted on without a DOM.
|
||||
function walletListHtml() {
|
||||
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>`;
|
||||
if (wallet.type === "hd" || wallet.type === "xprv") {
|
||||
// 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")) {
|
||||
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}">`;
|
||||
@@ -260,7 +262,20 @@ function render(ctx) {
|
||||
|
||||
html += `</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
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.querySelectorAll(".address-row").forEach((row) => {
|
||||
row.addEventListener("click", async () => {
|
||||
@@ -348,6 +363,13 @@ 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++) {
|
||||
@@ -371,6 +393,13 @@ 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.");
|
||||
@@ -396,4 +425,4 @@ function init(ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, render };
|
||||
module.exports = { init, render, walletListHtml };
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
@@ -132,7 +133,10 @@ function renderSendTokenSelect(addr) {
|
||||
for (const t of addr.tokenBalances || []) {
|
||||
if (isSpoofedToken(t)) continue;
|
||||
if (fraudSet.has(t.address.toLowerCase())) continue;
|
||||
if (state.hideLowHolderTokens && (t.holders || 0) < 1000) 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;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = t.address;
|
||||
opt.textContent = t.symbol;
|
||||
|
||||
@@ -7,17 +7,144 @@
|
||||
// the signer from the artifact and checks it against the approval it is
|
||||
// holding before acting on it. All recovery is delegated to ethers.
|
||||
//
|
||||
// The check is an allowlist, in both directions, because a denylist cannot be
|
||||
// correct against a transaction format that keeps gaining fields:
|
||||
//
|
||||
// - only transaction types 0, 1 and 2 are accepted. Every later EIP-2718 type
|
||||
// adds a field with consequences of its own — EIP-7702's authorizationList
|
||||
// rewrites the code at the signer's own account, EIP-4844's blob
|
||||
// commitments carry a separate fee — and a check that enumerates the fields
|
||||
// it refuses admits every one of them by default.
|
||||
// - after the per-field comparisons, the artifact is rebuilt from those
|
||||
// checked fields and nothing else, and the two are compared byte for byte.
|
||||
// Anything the artifact carries that this module does not name is absent
|
||||
// from the rebuild and changes the bytes, so the final assertion is that
|
||||
// the artifact *is* the approved transaction, not merely that it is not one
|
||||
// of the tampered shapes that were thought of.
|
||||
// - every comparison runs against the decode, but the string handed to
|
||||
// broadcastTransaction() is the artifact. So the artifact is also required
|
||||
// to be the canonical re-encoding of its own decode, which is what makes
|
||||
// the checked transaction and the broadcast bytes the same object rather
|
||||
// than two things that merely decode alike.
|
||||
//
|
||||
// Every consequential field is compared, and a mismatch is a refusal to act,
|
||||
// never a warning: what the user approved is what gets broadcast, or nothing
|
||||
// does.
|
||||
//
|
||||
// Fields the approval does not carry are not treated as zero. The popup
|
||||
// populates nonce, gas limit, fee and chain id through populateTransaction()
|
||||
// when the requesting page did not fix them, so there is no approved value to
|
||||
// compare against; treating absent as zero would refuse every legitimate
|
||||
// transaction. Those fields are instead held to the absolute ceilings below,
|
||||
// and the chain id is always checked against the selected network rather than
|
||||
// against the approval alone, which is what makes a cross-chain replay
|
||||
// impossible.
|
||||
//
|
||||
// Every failure message is a full sentence, because these strings are shown to
|
||||
// the user and returned to the dApp.
|
||||
|
||||
const {
|
||||
Transaction,
|
||||
accessListify,
|
||||
getAddress,
|
||||
getBytes,
|
||||
verifyMessage,
|
||||
verifyTypedData,
|
||||
} = require("ethers");
|
||||
|
||||
// The only transaction types this wallet signs: legacy, EIP-2930 and
|
||||
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
|
||||
// be an artifact of an approval this wallet raised.
|
||||
const ALLOWED_TX_TYPES = [0, 1, 2];
|
||||
|
||||
// The serialized fields of each allowed type, which is also the complete set
|
||||
// of fields the checks below compare or bound. The artifact is rebuilt from
|
||||
// exactly these at the end of verification and compared byte for byte, so a
|
||||
// field outside this table cannot ride along unexamined.
|
||||
const SERIALIZED_FIELDS = {
|
||||
0: ["chainId", "nonce", "gasPrice", "gasLimit", "to", "value", "data"],
|
||||
1: [
|
||||
"chainId",
|
||||
"nonce",
|
||||
"gasPrice",
|
||||
"gasLimit",
|
||||
"to",
|
||||
"value",
|
||||
"data",
|
||||
"accessList",
|
||||
],
|
||||
2: [
|
||||
"chainId",
|
||||
"nonce",
|
||||
"maxPriorityFeePerGas",
|
||||
"maxFeePerGas",
|
||||
"gasLimit",
|
||||
"to",
|
||||
"value",
|
||||
"data",
|
||||
"accessList",
|
||||
],
|
||||
};
|
||||
|
||||
// Fields no allowed type may carry. The type allowlist already excludes every
|
||||
// type that defines them, and the structural check at the end of verification
|
||||
// would catch them anyway; they are named here so that an artifact carrying
|
||||
// one is refused with a message that says what it was.
|
||||
const FORBIDDEN_FIELDS = [
|
||||
{
|
||||
key: "authorizationList",
|
||||
message:
|
||||
"The signed transaction would hand the signing account over to another contract, which was not approved.",
|
||||
},
|
||||
{
|
||||
key: "blobVersionedHashes",
|
||||
message:
|
||||
"The signed transaction carries blob commitments, which were not approved.",
|
||||
},
|
||||
{
|
||||
key: "blobs",
|
||||
message:
|
||||
"The signed transaction carries blobs, which were not approved.",
|
||||
},
|
||||
{
|
||||
key: "maxFeePerBlobGas",
|
||||
message:
|
||||
"The signed transaction carries a blob gas fee, which was not approved.",
|
||||
},
|
||||
];
|
||||
|
||||
// Above the block gas limit of every supported network (see networks.js), so
|
||||
// no transaction that could ever be included is refused by it.
|
||||
const MAX_GAS_LIMIT = 100000000n;
|
||||
|
||||
// 100,000 gwei per gas: orders of magnitude above the highest fee either
|
||||
// supported network has produced, and low enough to catch a fee that would
|
||||
// hand the validator the balance.
|
||||
const MAX_FEE_PER_GAS = 100000000000000n;
|
||||
|
||||
// A refusal to act on an artifact: it is not the thing that was approved, so
|
||||
// the approval it was offered against is spent and must not be retried. Every
|
||||
// throw in this module is one of these; the background distinguishes them from
|
||||
// transient failures (a busy node, a failed broadcast), which leave the
|
||||
// approval standing so the user can try again.
|
||||
class ApprovalMismatchError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "ApprovalMismatchError";
|
||||
this.approvalMismatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
function refuse(message) {
|
||||
return new ApprovalMismatchError(message);
|
||||
}
|
||||
|
||||
// Whether a signing failure leaves the approval usable. Anything that is not a
|
||||
// mismatch is the user's to correct and retry.
|
||||
function failureIsRetryable(err) {
|
||||
return !(err && err.approvalMismatch === true);
|
||||
}
|
||||
|
||||
// Case-insensitive address comparison that tolerates absent values on either
|
||||
// side. Two absent addresses compare equal (contract creation has no `to`).
|
||||
function sameAddress(a, b) {
|
||||
@@ -31,11 +158,64 @@ function sameAddress(a, b) {
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the approval fixed a value for a field at all.
|
||||
function present(v) {
|
||||
return v !== null && v !== undefined && v !== "";
|
||||
}
|
||||
|
||||
// Whether a field carries anything at all. An empty array is nothing: ethers
|
||||
// reports an absent access list on a type 2 transaction as `[]`.
|
||||
function carriesValue(v) {
|
||||
if (!present(v)) return false;
|
||||
if (Array.isArray(v)) return v.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normalize a quantity that must be present, refusing anything that is not a
|
||||
// number: an approval carrying junk in a fee field cannot be compared, and an
|
||||
// uncomparable field is a refusal rather than a pass.
|
||||
function normalizeQuantity(v, label) {
|
||||
try {
|
||||
return BigInt(v);
|
||||
} catch {
|
||||
throw refuse(
|
||||
"The approved " +
|
||||
label +
|
||||
" is not a number, so it cannot be" +
|
||||
" compared with the signed transaction.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a transaction value (hex string, decimal string, number or
|
||||
// bigint) to a bigint. An absent value is zero, matching ethers.
|
||||
// bigint) to a bigint. An absent value is zero, matching ethers. The value is
|
||||
// page-controlled, so it goes through the same refusal as every other
|
||||
// quantity rather than throwing a raw BigInt conversion error.
|
||||
function normalizeValue(v) {
|
||||
if (v === null || v === undefined || v === "") return 0n;
|
||||
return BigInt(v);
|
||||
if (!present(v)) return 0n;
|
||||
return normalizeQuantity(v, "value");
|
||||
}
|
||||
|
||||
// Normalize an access list to a comparable string. An absent or empty list is
|
||||
// the empty string, so absent and `[]` are the same thing.
|
||||
function normalizeAccessList(v) {
|
||||
if (!carriesValue(v)) return "";
|
||||
let list;
|
||||
try {
|
||||
list = accessListify(v);
|
||||
} catch {
|
||||
throw refuse(
|
||||
"The approved access list is not a valid access list, so it cannot be compared with the signed transaction.",
|
||||
);
|
||||
}
|
||||
return list
|
||||
.map(
|
||||
(entry) =>
|
||||
String(entry.address).toLowerCase() +
|
||||
":" +
|
||||
entry.storageKeys.map((k) => String(k).toLowerCase()).join(","),
|
||||
)
|
||||
.join(";");
|
||||
}
|
||||
|
||||
// Normalize call data to a lowercase hex string. Absent data is "0x".
|
||||
@@ -44,44 +224,216 @@ function normalizeData(v) {
|
||||
return String(v).toLowerCase();
|
||||
}
|
||||
|
||||
// Quantity fields the requesting page may fix in the approval. Each is
|
||||
// compared exactly when the approval carries it, and left to the ceilings
|
||||
// above when it does not.
|
||||
const APPROVED_QUANTITIES = [
|
||||
{
|
||||
key: "nonce",
|
||||
label: "nonce",
|
||||
message: "The signed transaction does not carry the approved nonce.",
|
||||
},
|
||||
{
|
||||
key: "gasLimit",
|
||||
label: "gas limit",
|
||||
message:
|
||||
"The signed transaction does not carry the approved gas limit.",
|
||||
},
|
||||
{
|
||||
key: "gasPrice",
|
||||
label: "gas price",
|
||||
message:
|
||||
"The signed transaction does not carry the approved gas price.",
|
||||
},
|
||||
{
|
||||
key: "maxFeePerGas",
|
||||
label: "maximum fee per gas",
|
||||
message:
|
||||
"The signed transaction does not carry the approved maximum fee per gas.",
|
||||
},
|
||||
{
|
||||
key: "maxPriorityFeePerGas",
|
||||
label: "maximum priority fee per gas",
|
||||
message:
|
||||
"The signed transaction does not carry the approved maximum priority fee per gas.",
|
||||
},
|
||||
];
|
||||
|
||||
// Refuse a field only a transaction type this wallet does not sign can carry.
|
||||
// The type allowlist keeps these unreachable in production, which is exactly
|
||||
// what they are for; it also means nothing else exercises them, so this is
|
||||
// exported and tested on its own rather than left to be believed.
|
||||
function assertNoForbiddenFields(parsed) {
|
||||
for (const field of FORBIDDEN_FIELDS) {
|
||||
if (carriesValue(parsed[field.key])) throw refuse(field.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Closing structural check. Rebuild the transaction from the fields the
|
||||
// comparisons cover, and nothing else, then compare the unsigned bytes. Every
|
||||
// field carried by the artifact but absent from the rebuild changes the
|
||||
// serialization, so this refuses anything this module does not account for —
|
||||
// including a field a future ethers learns to parse onto an allowed type —
|
||||
// instead of waving it through by not naming it. Also exported for its own
|
||||
// test: nothing reachable today can make the bytes differ.
|
||||
function assertNothingUnchecked(parsed) {
|
||||
let rebuilt;
|
||||
try {
|
||||
const fields = { type: parsed.type };
|
||||
for (const key of SERIALIZED_FIELDS[parsed.type]) {
|
||||
fields[key] = parsed[key];
|
||||
}
|
||||
rebuilt = Transaction.from(fields);
|
||||
} catch {
|
||||
throw refuse(
|
||||
"The signed transaction could not be rebuilt from the fields that were checked, so it cannot be shown to be the approved transaction.",
|
||||
);
|
||||
}
|
||||
if (rebuilt.unsignedSerialized !== parsed.unsignedSerialized) {
|
||||
throw refuse(
|
||||
"The signed transaction carries data beyond the fields that were checked against the approval.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the closing check, and the one that makes it bind on the
|
||||
// bytes that actually leave: every comparison above runs against the decode,
|
||||
// so on its own the rebuild proves only that the transaction ethers understood
|
||||
// is the approved one. What the background hands to broadcastTransaction() is
|
||||
// the artifact string itself. Requiring the artifact to be exactly the
|
||||
// canonical re-encoding of its own decode closes the gap between the two —
|
||||
// no encoding the decoder normalizes away (a leading zero byte on an RLP
|
||||
// quantity, say) can differ from what was checked. Hex case is not part of the
|
||||
// encoding, so only that is normalized before comparing.
|
||||
function assertCanonicalBytes(parsed, rawSignedTx) {
|
||||
if (parsed.serialized !== String(rawSignedTx).toLowerCase()) {
|
||||
throw refuse(
|
||||
"The signed transaction is not encoded canonically, so the bytes that would be broadcast are not the bytes that were checked.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Assert that a raw signed transaction is the transaction the user approved,
|
||||
// signed by the address the approval was raised for. Returns the parsed
|
||||
// ethers Transaction on success, throws otherwise.
|
||||
function verifySignedTx(rawSignedTx, txParams, expectedFrom) {
|
||||
// signed by the address the approval was raised for, on the network that is
|
||||
// selected. Returns the parsed ethers Transaction on success, throws
|
||||
// otherwise.
|
||||
function verifySignedTx(rawSignedTx, txParams, expectedFrom, selectedChainId) {
|
||||
if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) {
|
||||
throw new Error("The signed transaction is missing or malformed.");
|
||||
throw refuse("The signed transaction is missing or malformed.");
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = Transaction.from(rawSignedTx);
|
||||
} catch {
|
||||
throw new Error("The signed transaction could not be decoded.");
|
||||
throw refuse("The signed transaction could not be decoded.");
|
||||
}
|
||||
|
||||
if (!parsed.from) {
|
||||
throw new Error("The signed transaction carries no valid signature.");
|
||||
throw refuse("The signed transaction carries no valid signature.");
|
||||
}
|
||||
if (!sameAddress(parsed.from, expectedFrom)) {
|
||||
throw new Error(
|
||||
throw refuse(
|
||||
"The signed transaction was signed by a different address than the one that was approved.",
|
||||
);
|
||||
}
|
||||
|
||||
// Before any field is looked at: the type decides which fields exist at
|
||||
// all, so an unrecognised type is refused outright rather than compared
|
||||
// field by field against an approval that cannot describe it.
|
||||
if (!ALLOWED_TX_TYPES.includes(parsed.type)) {
|
||||
throw refuse(
|
||||
"The signed transaction is of a type this wallet does not sign, so what it would do beyond the approved transfer cannot be checked.",
|
||||
);
|
||||
}
|
||||
assertNoForbiddenFields(parsed);
|
||||
|
||||
// The selected network, not the artifact, is the authority on which chain
|
||||
// this may be broadcast to; without it nothing can be verified.
|
||||
if (!present(selectedChainId)) {
|
||||
throw refuse(
|
||||
"The selected network is unknown, so the signed transaction cannot be checked against it.",
|
||||
);
|
||||
}
|
||||
if (parsed.chainId !== normalizeQuantity(selectedChainId, "network")) {
|
||||
throw refuse(
|
||||
"The signed transaction is for a different network than the one that is selected.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
present(txParams.chainId) &&
|
||||
parsed.chainId !== normalizeQuantity(txParams.chainId, "network")
|
||||
) {
|
||||
throw refuse(
|
||||
"The signed transaction is for a different network than the one that was approved.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!sameAddress(parsed.to, txParams.to)) {
|
||||
throw new Error(
|
||||
throw refuse(
|
||||
"The signed transaction does not go to the approved recipient.",
|
||||
);
|
||||
}
|
||||
if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) {
|
||||
throw new Error(
|
||||
throw refuse(
|
||||
"The signed transaction does not carry the approved value.",
|
||||
);
|
||||
}
|
||||
if (normalizeData(parsed.data) !== normalizeData(txParams.data)) {
|
||||
throw new Error(
|
||||
throw refuse(
|
||||
"The signed transaction does not carry the approved call data.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
normalizeAccessList(parsed.accessList) !==
|
||||
normalizeAccessList(txParams.accessList)
|
||||
) {
|
||||
throw refuse(
|
||||
"The signed transaction does not carry the approved access list.",
|
||||
);
|
||||
}
|
||||
|
||||
// An approval that fixed EIP-1559 fees must not be signed as a legacy
|
||||
// transaction, and vice versa: the fee the user agreed to is only
|
||||
// meaningful under the mechanism it was quoted in.
|
||||
const approvedEip1559 =
|
||||
present(txParams.maxFeePerGas) ||
|
||||
present(txParams.maxPriorityFeePerGas);
|
||||
const approvedLegacy = present(txParams.gasPrice);
|
||||
const signedEip1559 = parsed.type === 2;
|
||||
if (
|
||||
(approvedEip1559 && !signedEip1559) ||
|
||||
(approvedLegacy && signedEip1559)
|
||||
) {
|
||||
throw refuse(
|
||||
"The signed transaction does not use the approved fee mechanism.",
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of APPROVED_QUANTITIES) {
|
||||
if (!present(txParams[field.key])) continue;
|
||||
const approved = normalizeQuantity(txParams[field.key], field.label);
|
||||
if (normalizeQuantity(parsed[field.key], field.label) !== approved) {
|
||||
throw refuse(field.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.gasLimit > MAX_GAS_LIMIT) {
|
||||
throw refuse(
|
||||
"The signed transaction sets a gas limit no network this wallet supports can accept.",
|
||||
);
|
||||
}
|
||||
for (const key of ["gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) {
|
||||
const fee = parsed[key];
|
||||
if (fee !== null && fee !== undefined && fee > MAX_FEE_PER_GAS) {
|
||||
throw refuse(
|
||||
"The signed transaction sets a fee per gas far above any plausible value.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertNothingUnchecked(parsed);
|
||||
assertCanonicalBytes(parsed, rawSignedTx);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
@@ -91,7 +443,7 @@ function verifySignedTx(rawSignedTx, txParams, expectedFrom) {
|
||||
// address on success, throws otherwise.
|
||||
function verifySignature(signParams, signature, expectedFrom) {
|
||||
if (typeof signature !== "string" || !signature.startsWith("0x")) {
|
||||
throw new Error("The signature is missing or malformed.");
|
||||
throw refuse("The signature is missing or malformed.");
|
||||
}
|
||||
|
||||
let recovered;
|
||||
@@ -109,11 +461,11 @@ function verifySignature(signParams, signature, expectedFrom) {
|
||||
recovered = verifyTypedData(domain, types, message, signature);
|
||||
}
|
||||
} catch {
|
||||
throw new Error("The signature could not be verified.");
|
||||
throw refuse("The signature could not be verified.");
|
||||
}
|
||||
|
||||
if (!sameAddress(recovered, expectedFrom)) {
|
||||
throw new Error(
|
||||
throw refuse(
|
||||
"The signature was produced by a different address than the one that was approved.",
|
||||
);
|
||||
}
|
||||
@@ -121,4 +473,98 @@ function verifySignature(signParams, signature, expectedFrom) {
|
||||
return recovered;
|
||||
}
|
||||
|
||||
module.exports = { verifySignedTx, verifySignature, sameAddress };
|
||||
// The stage a transaction approval failed at. Which stage it is decides
|
||||
// whether the approval survives the failure.
|
||||
const TX_STAGE_SIGN = "sign";
|
||||
const TX_STAGE_VERIFY = "verify";
|
||||
const TX_STAGE_BROADCAST = "broadcast";
|
||||
// Not a failure of this request at all: a second response arrived for an
|
||||
// approval an attempt already holds. The first attempt is still running and
|
||||
// may yet succeed, so the one thing the popup must not say is "start again
|
||||
// from the site".
|
||||
const TX_STAGE_INFLIGHT = "inflight";
|
||||
|
||||
function errorText(err) {
|
||||
if (typeof err === "string" && err !== "") return err;
|
||||
if (err && (err.shortMessage || err.message)) {
|
||||
return err.shortMessage || err.message;
|
||||
}
|
||||
return "The transaction could not be sent.";
|
||||
}
|
||||
|
||||
// What the background does with a pending transaction approval after a failed
|
||||
// attempt: what it tells the popup, and whether the approval is spent
|
||||
// (resolved to the requesting page as an error and deleted) or left standing
|
||||
// so the user can try the transaction they already saw again.
|
||||
//
|
||||
// - sign: the popup could not produce an artifact, almost always a wrong
|
||||
// password. Nothing left the extension, so the approval stands.
|
||||
// - verify: a mismatch is a refusal and spends the approval — an artifact
|
||||
// that is not the approved transaction must never be retried against that
|
||||
// approval. Anything else failed before the check ran and is retryable.
|
||||
// - broadcast: always terminal. A broadcast that throws after the node
|
||||
// accepted the transaction is routine (a timeout, a dropped response, a
|
||||
// node answering "already known"), and the popup's retry does not
|
||||
// re-broadcast these bytes — it re-runs populateTransaction() and signs
|
||||
// again at a freshly fetched pending-tag nonce. Retrying would therefore
|
||||
// put a second transaction on the chain for one approval.
|
||||
function describeTxFailure(stage, err) {
|
||||
const error = errorText(err);
|
||||
const retryable =
|
||||
stage === TX_STAGE_SIGN ||
|
||||
(stage === TX_STAGE_VERIFY && failureIsRetryable(err));
|
||||
return { error, retryable, spendApproval: !retryable };
|
||||
}
|
||||
|
||||
// What the popup shows and does after the background reports a failed signing
|
||||
// attempt. A retryable failure leaves the approval pending in the background,
|
||||
// so the button goes back to being usable; a refusal spent the approval, and
|
||||
// the popup says so rather than offering a button that cannot succeed.
|
||||
//
|
||||
// A failed broadcast gets its own wording: the transaction may already be on
|
||||
// the network, so telling the user to start again from the site is exactly the
|
||||
// wrong instruction.
|
||||
function describeSigningFailure(response, fallbackMessage) {
|
||||
let message = (response && response.error) || fallbackMessage;
|
||||
if (!/[.!?]$/.test(message)) message += ".";
|
||||
const retryable = !!(response && response.retryable);
|
||||
const stage = response && response.stage;
|
||||
if (!retryable) {
|
||||
if (stage === TX_STAGE_BROADCAST) {
|
||||
message +=
|
||||
" The transaction may still have reached the network." +
|
||||
" Check the account before sending it again.";
|
||||
} else if (stage === TX_STAGE_INFLIGHT) {
|
||||
message +=
|
||||
" The first attempt is still running and may still succeed." +
|
||||
" Wait for it rather than starting again.";
|
||||
} else {
|
||||
message +=
|
||||
" This request can no longer be signed. Please start it" +
|
||||
" again from the site.";
|
||||
}
|
||||
}
|
||||
return { message, retryable };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifySignedTx,
|
||||
verifySignature,
|
||||
assertNoForbiddenFields,
|
||||
assertNothingUnchecked,
|
||||
assertCanonicalBytes,
|
||||
sameAddress,
|
||||
failureIsRetryable,
|
||||
describeTxFailure,
|
||||
describeSigningFailure,
|
||||
ApprovalMismatchError,
|
||||
ALLOWED_TX_TYPES,
|
||||
SERIALIZED_FIELDS,
|
||||
FORBIDDEN_FIELDS,
|
||||
TX_STAGE_SIGN,
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
TX_STAGE_INFLIGHT,
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ 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).
|
||||
@@ -70,10 +71,20 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
if (bal === "0.0") continue;
|
||||
|
||||
const tokenAddr = (item.token.address_hash || "").toLowerCase();
|
||||
const holders = parseInt(item.token.holders_count || "0", 10);
|
||||
// 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 isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
|
||||
const isTracked = trackedSet.has(tokenAddr);
|
||||
const hasEnoughHolders = holders >= 1000;
|
||||
const hasEnoughHolders =
|
||||
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
|
||||
|
||||
// Skip spam tokens the user never asked to see
|
||||
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
||||
@@ -278,6 +289,7 @@ async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchTokenBalances,
|
||||
refreshBalances,
|
||||
lookupTokenInfo,
|
||||
getProvider,
|
||||
|
||||
32
src/shared/holders.js
Normal file
32
src/shared/holders.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// 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,6 +9,7 @@
|
||||
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
|
||||
@@ -116,7 +117,10 @@ function parseTokenTransfer(tt, addrLower) {
|
||||
contractAddress: normalizeAddress(
|
||||
tt.token?.address_hash || tt.token?.address || "",
|
||||
),
|
||||
holders: parseInt(tt.token?.holders_count || "0", 10),
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -292,12 +296,13 @@ function filterTransactions(txs, filters = {}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter low-holder tokens (<1000) if setting is on
|
||||
// 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".
|
||||
if (
|
||||
filters.hideLowHolderTokens &&
|
||||
tx.contractAddress &&
|
||||
tx.holders !== null &&
|
||||
tx.holders < 1000
|
||||
isLowHolderCount(tx.holders)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,80 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -59,4 +125,4 @@ async function decryptWithPassword(encrypted, password) {
|
||||
return sodium.to_string(plaintext);
|
||||
}
|
||||
|
||||
module.exports = { encryptWithPassword, decryptWithPassword };
|
||||
module.exports = { cryptoBackend, decryptWithPassword, encryptWithPassword };
|
||||
|
||||
@@ -120,9 +120,24 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
|
||||
return node.deriveChild(addrIndex);
|
||||
}
|
||||
if (walletData.type === "xprv") {
|
||||
const node =
|
||||
masterXprvOrThrow(decryptedSecret).derivePath(BIP44_ETH_PATH);
|
||||
return node.deriveChild(addrIndex);
|
||||
// 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);
|
||||
}
|
||||
return new Wallet(decryptedSecret);
|
||||
}
|
||||
@@ -142,6 +157,7 @@ function walletHasRecoveryPhrase(walletData) {
|
||||
|
||||
module.exports = {
|
||||
generateMnemonic,
|
||||
parseExtendedKey,
|
||||
deriveAddressFromXpub,
|
||||
hdWalletFromMnemonic,
|
||||
hdWalletFromXprv,
|
||||
|
||||
86
src/shared/walletDefects.js
Normal file
86
src/shared/walletDefects.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -1,8 +1,28 @@
|
||||
const { Network, Transaction, Wallet } = require("ethers");
|
||||
const {
|
||||
Network,
|
||||
Transaction,
|
||||
Wallet,
|
||||
decodeRlp,
|
||||
encodeRlp,
|
||||
} = require("ethers");
|
||||
const {
|
||||
verifySignedTx,
|
||||
verifySignature,
|
||||
assertNoForbiddenFields,
|
||||
assertNothingUnchecked,
|
||||
assertCanonicalBytes,
|
||||
sameAddress,
|
||||
failureIsRetryable,
|
||||
describeTxFailure,
|
||||
describeSigningFailure,
|
||||
ALLOWED_TX_TYPES,
|
||||
SERIALIZED_FIELDS,
|
||||
FORBIDDEN_FIELDS,
|
||||
TX_STAGE_SIGN,
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
} = require("../src/shared/approvalVerify");
|
||||
const { getSignerForAddress } = require("../src/shared/wallet");
|
||||
|
||||
@@ -18,6 +38,10 @@ const other = new Wallet(OTHER_KEY);
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
|
||||
// The chain id of the selected network, as networks.js carries it.
|
||||
const SELECTED = "0x1";
|
||||
const SEPOLIA = "0xaa36a7";
|
||||
|
||||
// Approved parameters as a dApp would supply them over eth_sendTransaction.
|
||||
const TX_PARAMS = {
|
||||
from: signer.address,
|
||||
@@ -27,25 +51,38 @@ const TX_PARAMS = {
|
||||
gas: "0x5208",
|
||||
};
|
||||
|
||||
// The values populateTransaction() fills in when the dApp fixed none of them.
|
||||
const POPULATED = {
|
||||
chainId: 1,
|
||||
nonce: 7,
|
||||
gasLimit: 100000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
type: 2,
|
||||
};
|
||||
|
||||
// Build a signable transaction from approved params. The popup does the same
|
||||
// thing through populateTransaction(); here the fields are fixed so the test
|
||||
// needs no provider.
|
||||
function txFor(params) {
|
||||
// needs no provider. `overrides` stands in for what a tampered or misbuilt
|
||||
// popup would put on the wire.
|
||||
function txFor(params, overrides) {
|
||||
return {
|
||||
chainId: 1,
|
||||
nonce: 7,
|
||||
gasLimit: 100000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
type: 2,
|
||||
...POPULATED,
|
||||
to: params.to,
|
||||
value: params.value === undefined ? 0n : BigInt(params.value),
|
||||
data: params.data || "0x",
|
||||
...(overrides || {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function signedFor(params, withWallet) {
|
||||
return (withWallet || signer).signTransaction(txFor(params));
|
||||
async function signedFor(params, withWallet, overrides) {
|
||||
return (withWallet || signer).signTransaction(txFor(params, overrides));
|
||||
}
|
||||
|
||||
// Sign the approved transaction with one field changed from what was
|
||||
// populated, which is the shape of every tamper case below.
|
||||
async function signedWith(overrides) {
|
||||
return signedFor(TX_PARAMS, signer, overrides);
|
||||
}
|
||||
|
||||
describe("sameAddress", () => {
|
||||
@@ -71,7 +108,7 @@ describe("sameAddress", () => {
|
||||
describe("verifySignedTx", () => {
|
||||
test("accepts the approved transaction signed by the approved address", async () => {
|
||||
const raw = await signedFor(TX_PARAMS);
|
||||
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address);
|
||||
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||
expect(parsed.from).toBe(signer.address);
|
||||
expect(parsed.hash).toBe(Transaction.from(raw).hash);
|
||||
});
|
||||
@@ -79,14 +116,16 @@ describe("verifySignedTx", () => {
|
||||
test("accepts a contract creation with no recipient", async () => {
|
||||
const params = { to: undefined, value: "0x0", data: "0x600160005500" };
|
||||
const raw = await signedFor(params);
|
||||
expect(() => verifySignedTx(raw, params, signer.address)).not.toThrow();
|
||||
expect(() =>
|
||||
verifySignedTx(raw, params, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts an absent value as zero", async () => {
|
||||
const approved = { to: RECIPIENT, data: "0x" };
|
||||
const raw = await signedFor(approved);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address),
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -94,7 +133,7 @@ describe("verifySignedTx", () => {
|
||||
const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
|
||||
const raw = await signedFor(approved);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address),
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -103,9 +142,9 @@ describe("verifySignedTx", () => {
|
||||
...TX_PARAMS,
|
||||
to: OTHER_RECIPIENT,
|
||||
});
|
||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
||||
/approved recipient/,
|
||||
);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/approved recipient/);
|
||||
});
|
||||
|
||||
test("rejects an inflated value", async () => {
|
||||
@@ -113,48 +152,48 @@ describe("verifySignedTx", () => {
|
||||
...TX_PARAMS,
|
||||
value: "0x4563918244f40000",
|
||||
});
|
||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
||||
/approved value/,
|
||||
);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/approved value/);
|
||||
});
|
||||
|
||||
test("rejects substituted call data", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
|
||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
||||
/approved call data/,
|
||||
);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/approved call data/);
|
||||
});
|
||||
|
||||
test("rejects a transaction signed by a different address", async () => {
|
||||
const raw = await signedFor(TX_PARAMS, other);
|
||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
||||
/different address/,
|
||||
);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/different address/);
|
||||
});
|
||||
|
||||
test("rejects an unsigned transaction", () => {
|
||||
const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
|
||||
expect(() =>
|
||||
verifySignedTx(unsigned, TX_PARAMS, signer.address),
|
||||
verifySignedTx(unsigned, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/no valid signature/);
|
||||
});
|
||||
|
||||
test("rejects a missing or malformed payload", () => {
|
||||
expect(() =>
|
||||
verifySignedTx(undefined, TX_PARAMS, signer.address),
|
||||
verifySignedTx(undefined, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/missing or malformed/);
|
||||
expect(() => verifySignedTx("nope", TX_PARAMS, signer.address)).toThrow(
|
||||
/missing or malformed/,
|
||||
);
|
||||
expect(() =>
|
||||
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address),
|
||||
verifySignedTx("nope", TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/missing or malformed/);
|
||||
expect(() =>
|
||||
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/could not be decoded/);
|
||||
});
|
||||
|
||||
test("every rejection message is a full sentence", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||
try {
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address);
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
||||
@@ -162,6 +201,606 @@ describe("verifySignedTx", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// One case per consequential field: the field alone differs from what was
|
||||
// approved, and that alone must refuse the signature.
|
||||
describe("verifySignedTx field comparison", () => {
|
||||
test("rejects a chain id that is not the selected network", async () => {
|
||||
const raw = await signedWith({ chainId: 11155111 });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/different network than the one that is selected/);
|
||||
});
|
||||
|
||||
test("rejects a chain id that is not the approved one", async () => {
|
||||
// Selected network and signed chain id agree; the dApp asked for a
|
||||
// different chain, so the artifact is not what was approved.
|
||||
const approved = { ...TX_PARAMS, chainId: SEPOLIA };
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/different network than the one that was approved/);
|
||||
});
|
||||
|
||||
test("refuses when the selected network is unknown", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, undefined),
|
||||
).toThrow(/selected network is unknown/);
|
||||
});
|
||||
|
||||
test("rejects a substituted nonce", async () => {
|
||||
const approved = { ...TX_PARAMS, nonce: 7 };
|
||||
const raw = await signedWith({ nonce: 8 });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved nonce/);
|
||||
});
|
||||
|
||||
test("rejects a substituted gas limit", async () => {
|
||||
const approved = { ...TX_PARAMS, gasLimit: "0x186a0" };
|
||||
const raw = await signedWith({ gasLimit: 250000n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved gas limit/);
|
||||
});
|
||||
|
||||
test("rejects a substituted maximum fee per gas", async () => {
|
||||
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
|
||||
const raw = await signedWith({ maxFeePerGas: 900000000000n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved maximum fee per gas/);
|
||||
});
|
||||
|
||||
test("rejects a substituted maximum priority fee per gas", async () => {
|
||||
const approved = { ...TX_PARAMS, maxPriorityFeePerGas: "0x3b9aca00" };
|
||||
const raw = await signedWith({ maxPriorityFeePerGas: 1500000000n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved maximum priority fee per gas/);
|
||||
});
|
||||
|
||||
test("rejects a substituted legacy gas price", async () => {
|
||||
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
|
||||
const legacy = {
|
||||
type: 0,
|
||||
gasPrice: 9000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
};
|
||||
const raw = await signedWith(legacy);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved gas price/);
|
||||
});
|
||||
|
||||
test("rejects an approved legacy fee signed as an EIP-1559 fee", async () => {
|
||||
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved fee mechanism/);
|
||||
});
|
||||
|
||||
test("rejects an approved EIP-1559 fee signed as a legacy fee", async () => {
|
||||
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
|
||||
const raw = await signedWith({
|
||||
type: 0,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved fee mechanism/);
|
||||
});
|
||||
|
||||
test("rejects a gas limit above anything a supported network accepts", async () => {
|
||||
const raw = await signedWith({ gasLimit: MAX_GAS_LIMIT + 1n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/gas limit no network this wallet supports/);
|
||||
});
|
||||
|
||||
test("rejects an absurd fee per gas the approval never fixed", async () => {
|
||||
const raw = await signedWith({
|
||||
maxFeePerGas: MAX_FEE_PER_GAS + 1n,
|
||||
maxPriorityFeePerGas: MAX_FEE_PER_GAS + 1n,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/fee per gas far above any plausible value/);
|
||||
});
|
||||
|
||||
test("every field mismatch is a refusal, not a warning", async () => {
|
||||
const raw = await signedWith({ nonce: 8 });
|
||||
try {
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, nonce: 7 },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(e.approvalMismatch).toBe(true);
|
||||
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The transaction type decides which fields exist, so an artifact of a type
|
||||
// this wallet does not sign carries consequences the approval cannot describe
|
||||
// and none of the field comparisons can see. The approval used here is the
|
||||
// ordinary dApp shape with no fee fields — the common case, since
|
||||
// populateTransaction() fills them — which is exactly the case the
|
||||
// fee-mechanism check cannot catch by accident.
|
||||
describe("verifySignedTx transaction type", () => {
|
||||
const BARE_APPROVAL = {
|
||||
from: signer.address,
|
||||
to: RECIPIENT,
|
||||
value: "0x2386f26fc10000",
|
||||
data: "0x",
|
||||
};
|
||||
|
||||
// An EIP-7702 artifact that pays the approved amount to the approved
|
||||
// recipient and, in the same transaction, installs the attacker's code at
|
||||
// the signer's own account for good. Every field the approval screen shows
|
||||
// matches; only the type and the authorization list do not.
|
||||
test("refuses a type 4 artifact that delegates the signer's own account", async () => {
|
||||
const authorization = await signer.authorize({
|
||||
address: OTHER_RECIPIENT,
|
||||
chainId: 1,
|
||||
nonce: 8,
|
||||
});
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||
type: 4,
|
||||
authorizationList: [authorization],
|
||||
});
|
||||
const parsed = Transaction.from(raw);
|
||||
expect(parsed.type).toBe(4);
|
||||
expect(parsed.authorizationList[0].address).toBe(OTHER_RECIPIENT);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
|
||||
).toThrow(/type this wallet does not sign/);
|
||||
});
|
||||
|
||||
test("refuses a type 3 blob artifact", async () => {
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||
type: 3,
|
||||
maxFeePerBlobGas: 1000000000n,
|
||||
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
|
||||
});
|
||||
expect(Transaction.from(raw).type).toBe(3);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
|
||||
).toThrow(/type this wallet does not sign/);
|
||||
});
|
||||
|
||||
test("refuses every type outside the allowlist, not just the known ones", async () => {
|
||||
for (const type of [3, 4]) {
|
||||
expect(ALLOWED_TX_TYPES).not.toContain(type);
|
||||
}
|
||||
expect(ALLOWED_TX_TYPES).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
test("a type refusal is a refusal, not a warning", async () => {
|
||||
const authorization = await signer.authorize({
|
||||
address: OTHER_RECIPIENT,
|
||||
chainId: 1,
|
||||
nonce: 8,
|
||||
});
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||
type: 4,
|
||||
authorizationList: [authorization],
|
||||
});
|
||||
try {
|
||||
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(e.approvalMismatch).toBe(true);
|
||||
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts a legacy type 0 transaction", async () => {
|
||||
const approved = { ...BARE_APPROVAL, gasPrice: "0x77359400" };
|
||||
const raw = await signedFor(approved, signer, {
|
||||
type: 0,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts a type 1 transaction whose access list is the approved one", async () => {
|
||||
const accessList = [{ address: OTHER_RECIPIENT, storageKeys: [] }];
|
||||
const approved = {
|
||||
...BARE_APPROVAL,
|
||||
gasPrice: "0x77359400",
|
||||
accessList,
|
||||
};
|
||||
const raw = await signedFor(approved, signer, {
|
||||
type: 1,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
accessList,
|
||||
});
|
||||
expect(Transaction.from(raw).type).toBe(1);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("refuses an access list the approval never carried", async () => {
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||
accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }],
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
|
||||
).toThrow(/approved access list/);
|
||||
});
|
||||
|
||||
test("treats an absent access list and an empty one as the same thing", async () => {
|
||||
const approved = { ...BARE_APPROVAL, accessList: [] };
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// The allowlist is only exhaustive while it accounts for every field an
|
||||
// artifact can carry. These tests are what makes that claim checkable rather
|
||||
// than asserted.
|
||||
describe("verifySignedTx exhaustiveness", () => {
|
||||
// Every accessor ethers exposes on a parsed transaction, and where this
|
||||
// module deals with it. If an ethers upgrade adds a transaction field,
|
||||
// this fails and forces a decision about it instead of letting it default
|
||||
// to unchecked.
|
||||
test("every field ethers can parse is accounted for", () => {
|
||||
const derived = [
|
||||
// Recovered from the signature or computed from the payload, not
|
||||
// independent content: covered by the signer check and by the
|
||||
// fields below.
|
||||
"from",
|
||||
"fromPublicKey",
|
||||
"hash",
|
||||
"serialized",
|
||||
"signature",
|
||||
"type",
|
||||
"typeName",
|
||||
"unsignedHash",
|
||||
"unsignedSerialized",
|
||||
// Blob sidecar machinery, meaningful only alongside `blobs`,
|
||||
// which is refused outright.
|
||||
"kzg",
|
||||
"blobWrapperVersion",
|
||||
];
|
||||
const accounted = new Set([
|
||||
...derived,
|
||||
...FORBIDDEN_FIELDS.map((f) => f.key),
|
||||
...Object.values(SERIALIZED_FIELDS).flat(),
|
||||
]);
|
||||
const exposed = Object.getOwnPropertyNames(Transaction.prototype)
|
||||
.filter((name) => {
|
||||
const d = Object.getOwnPropertyDescriptor(
|
||||
Transaction.prototype,
|
||||
name,
|
||||
);
|
||||
return d && typeof d.get === "function";
|
||||
})
|
||||
.sort();
|
||||
expect(exposed.filter((name) => !accounted.has(name))).toEqual([]);
|
||||
});
|
||||
|
||||
// The two layers behind the type allowlist. Nothing reachable through
|
||||
// verifySignedTx can trip either of them while the allowlist holds — that
|
||||
// is what they are for — so they are exercised directly rather than taken
|
||||
// on trust.
|
||||
test("a forbidden field is refused even on an allowed type", async () => {
|
||||
const authorization = await signer.authorize({
|
||||
address: OTHER_RECIPIENT,
|
||||
chainId: 1,
|
||||
nonce: 8,
|
||||
});
|
||||
const carriers = {
|
||||
authorizationList: [authorization],
|
||||
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
|
||||
blobs: ["0x00"],
|
||||
maxFeePerBlobGas: 1n,
|
||||
};
|
||||
for (const key of Object.keys(carriers)) {
|
||||
expect(FORBIDDEN_FIELDS.map((f) => f.key)).toContain(key);
|
||||
let thrown;
|
||||
try {
|
||||
assertNoForbiddenFields({ type: 2, [key]: carriers[key] });
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown.approvalMismatch).toBe(true);
|
||||
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
|
||||
}
|
||||
expect(() => assertNoForbiddenFields({ type: 2 })).not.toThrow();
|
||||
});
|
||||
|
||||
// Stands in for a future ethers that parses a field this module does not
|
||||
// know about onto an allowed type: every field the module checks is
|
||||
// identical, and the bytes are not.
|
||||
test("an artifact carrying more than the checked fields is refused", async () => {
|
||||
const parsed = Transaction.from(await signedWith({}));
|
||||
const smuggled = { type: parsed.type };
|
||||
for (const key of SERIALIZED_FIELDS[parsed.type]) {
|
||||
smuggled[key] = parsed[key];
|
||||
}
|
||||
smuggled.unsignedSerialized = parsed.unsignedSerialized + "ff";
|
||||
expect(() => assertNothingUnchecked(smuggled)).toThrow(
|
||||
/beyond the fields that were checked/,
|
||||
);
|
||||
expect(() => assertNothingUnchecked(parsed)).not.toThrow();
|
||||
});
|
||||
|
||||
// The closing check rebuilds the artifact from the fields the module
|
||||
// compared and compares the bytes, so an artifact carrying anything else
|
||||
// is refused without the module having to name it. Assert the rebuild is
|
||||
// faithful for every accepted shape, since a rebuild that dropped a
|
||||
// legitimate field would refuse honest transactions.
|
||||
test("an accepted artifact of each allowed type rebuilds byte for byte", async () => {
|
||||
const shapes = [
|
||||
{
|
||||
approved: { ...TX_PARAMS, gasPrice: "0x77359400" },
|
||||
overrides: {
|
||||
type: 0,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
approved: {
|
||||
...TX_PARAMS,
|
||||
gasPrice: "0x77359400",
|
||||
accessList: [
|
||||
{
|
||||
address: RECIPIENT,
|
||||
storageKeys: ["0x" + "11".repeat(32)],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: {
|
||||
type: 1,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
accessList: [
|
||||
{
|
||||
address: RECIPIENT,
|
||||
storageKeys: ["0x" + "11".repeat(32)],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ approved: TX_PARAMS, overrides: {} },
|
||||
];
|
||||
for (const shape of shapes) {
|
||||
const raw = await signedFor(
|
||||
shape.approved,
|
||||
signer,
|
||||
shape.overrides,
|
||||
);
|
||||
const parsed = verifySignedTx(
|
||||
raw,
|
||||
shape.approved,
|
||||
signer.address,
|
||||
SELECTED,
|
||||
);
|
||||
const fields = { type: parsed.type };
|
||||
for (const key of SERIALIZED_FIELDS[parsed.type]) {
|
||||
fields[key] = parsed[key];
|
||||
}
|
||||
expect(Transaction.from(fields).unsignedSerialized).toBe(
|
||||
parsed.unsignedSerialized,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Every comparison above runs against the decode, but the string that is
|
||||
// handed to broadcastTransaction() is the artifact. An encoding the decoder
|
||||
// normalizes away therefore checks as one transaction and broadcasts as
|
||||
// different bytes, so the artifact must be the canonical encoding of itself.
|
||||
describe("verifySignedTx canonical encoding", () => {
|
||||
// Re-encode a signed type-2 artifact with a leading zero byte on the RLP
|
||||
// value field. It decodes to exactly the approved transaction — same
|
||||
// value, same signer, same everything the field comparisons look at — and
|
||||
// it is not the same string.
|
||||
async function nonCanonical() {
|
||||
const raw = await signedWith({});
|
||||
const items = decodeRlp("0x" + raw.slice(4));
|
||||
// type 2 payload order: chainId, nonce, maxPriorityFeePerGas,
|
||||
// maxFeePerGas, gasLimit, to, value, data, accessList, then the
|
||||
// signature.
|
||||
const padded = items.slice();
|
||||
padded[6] = "0x00" + items[6].slice(2);
|
||||
return "0x02" + encodeRlp(padded).slice(2);
|
||||
}
|
||||
|
||||
test("the mutation decodes to the approved transaction and is not it", async () => {
|
||||
const raw = await signedWith({});
|
||||
const mutated = await nonCanonical();
|
||||
const parsed = Transaction.from(mutated);
|
||||
expect(mutated).not.toBe(raw);
|
||||
expect(mutated.length).toBeGreaterThan(raw.length);
|
||||
expect(parsed.value).toBe(BigInt(TX_PARAMS.value));
|
||||
expect(parsed.from).toBe(signer.address);
|
||||
expect(parsed.serialized).not.toBe(mutated);
|
||||
});
|
||||
|
||||
test("refuses an artifact that is not its own canonical encoding", async () => {
|
||||
const mutated = await nonCanonical();
|
||||
expect(() =>
|
||||
verifySignedTx(mutated, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/not encoded canonically/);
|
||||
});
|
||||
|
||||
test("assertCanonicalBytes accepts what ethers itself produced", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
assertCanonicalBytes(Transaction.from(raw), raw),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("hex case is not part of the encoding", async () => {
|
||||
const raw = await signedWith({});
|
||||
const upper = "0x" + raw.slice(2).toUpperCase();
|
||||
expect(() =>
|
||||
verifySignedTx(upper, TX_PARAMS, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// The approval and the artifact spell the same values differently. None of
|
||||
// these differences is tampering, so none may refuse the signature.
|
||||
describe("verifySignedTx normalization", () => {
|
||||
test("accepts a decimal chain id against a hex selected network", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, 1),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, "1"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts an approved chain id written in hex", async () => {
|
||||
const raw = await signedWith({});
|
||||
const approved = { ...TX_PARAMS, chainId: "0x1" };
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts a hex nonce against a numeric one", async () => {
|
||||
const raw = await signedWith({ nonce: 7 });
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, nonce: "0x7" },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts a decimal gas limit against a hex one", async () => {
|
||||
const raw = await signedWith({ gasLimit: 100000n });
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, gasLimit: "100000" },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts fee fields spelled as hex, decimal, number and bigint", async () => {
|
||||
const raw = await signedWith({});
|
||||
for (const maxFee of [
|
||||
"0x77359400",
|
||||
"2000000000",
|
||||
2000000000,
|
||||
2000000000n,
|
||||
]) {
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, maxFeePerGas: maxFee },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts an approval that fixes no nonce, gas or fee at all", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts an approval whose recipient case differs", async () => {
|
||||
const raw = await signedWith({});
|
||||
const approved = { ...TX_PARAMS, to: RECIPIENT.toLowerCase() };
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts absent call data against 0x", async () => {
|
||||
const approved = { to: RECIPIENT, value: "0x0" };
|
||||
const raw = await signedFor({ ...approved, data: "0x" });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("refuses an approved quantity that is not a number", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, maxFeePerGas: "cheap" },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).toThrow(/is not a number/);
|
||||
});
|
||||
|
||||
// The value is page-controlled. A refusal is correct; a raw BigInt
|
||||
// conversion error is not, because it is not a mismatch, so it would be
|
||||
// reported retryable and leave the approval unspent behind a live button
|
||||
// that can never succeed.
|
||||
test("refuses an approved value that is not a number, as a mismatch", async () => {
|
||||
const raw = await signedWith({});
|
||||
for (const value of ["cheap", 1.5, "1e18", {}]) {
|
||||
let thrown;
|
||||
try {
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, value },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown.approvalMismatch).toBe(true);
|
||||
expect(thrown.message).toMatch(/approved value is not a number/);
|
||||
expect(failureIsRetryable(thrown)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses an approved access list that is not an access list", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, accessList: ["nope"] },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).toThrow(/not a valid access list/);
|
||||
});
|
||||
});
|
||||
|
||||
const TYPED_DATA = JSON.stringify({
|
||||
domain: {
|
||||
name: "AutistMask Test",
|
||||
@@ -281,6 +920,147 @@ describe("verifySignature", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// What happens after a signing attempt fails: the background keeps the
|
||||
// approval for anything the user can correct, and the popup only offers the
|
||||
// button again when it did.
|
||||
describe("signing failure and retry", () => {
|
||||
test("a failure that is not a mismatch leaves the approval retryable", () => {
|
||||
expect(failureIsRetryable(new Error("The node is unreachable."))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(failureIsRetryable(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test("a mismatch spends the approval", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||
try {
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(failureIsRetryable(e)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("a retryable failure keeps the button usable and says only what failed", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{ error: "The node rejected the transaction.", retryable: true },
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
expect(outcome.retryable).toBe(true);
|
||||
expect(outcome.message).toBe("The node rejected the transaction.");
|
||||
});
|
||||
|
||||
test("a refusal tells the user to start again from the site", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{
|
||||
error: "The signed transaction does not go to the approved recipient.",
|
||||
retryable: false,
|
||||
},
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.message).toMatch(/start it again from the site\.$/);
|
||||
});
|
||||
|
||||
test("a refusal for an attempt already running does not say to start again", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{
|
||||
error: "This request is already being signed.",
|
||||
retryable: false,
|
||||
stage: "inflight",
|
||||
},
|
||||
"The message could not be signed.",
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.message).not.toMatch(/start it again from the site/);
|
||||
expect(outcome.message).toMatch(/first attempt is still running/);
|
||||
});
|
||||
|
||||
test("a response the background never sent is treated as a spent approval", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
undefined,
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.message).toMatch(/^The transaction could not be sent\./);
|
||||
});
|
||||
|
||||
test("every failure message is a full sentence", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{ error: "The node is on fire", retryable: true },
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
expect(outcome.message).toMatch(/^[A-Z].*\.$/);
|
||||
});
|
||||
|
||||
test("a popup that could not sign leaves the approval standing", () => {
|
||||
const outcome = describeTxFailure(
|
||||
TX_STAGE_SIGN,
|
||||
"That password is incorrect. Please try again.",
|
||||
);
|
||||
expect(outcome.retryable).toBe(true);
|
||||
expect(outcome.spendApproval).toBe(false);
|
||||
expect(outcome.error).toMatch(/password is incorrect/);
|
||||
});
|
||||
|
||||
test("a mismatch found at verification spends the approval", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||
let outcome;
|
||||
try {
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||
} catch (e) {
|
||||
outcome = describeTxFailure(TX_STAGE_VERIFY, e);
|
||||
}
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.spendApproval).toBe(true);
|
||||
});
|
||||
|
||||
test("a failure before the check ran is still retryable", () => {
|
||||
const outcome = describeTxFailure(
|
||||
TX_STAGE_VERIFY,
|
||||
new Error("The wallet state could not be read."),
|
||||
);
|
||||
expect(outcome.retryable).toBe(true);
|
||||
expect(outcome.spendApproval).toBe(false);
|
||||
});
|
||||
|
||||
// A broadcast that throws after the node took the transaction is routine:
|
||||
// a timeout, a dropped response, a node answering "already known". The
|
||||
// popup's retry does not re-broadcast the same bytes — it re-populates and
|
||||
// re-signs at a freshly fetched nonce — so a retryable broadcast failure
|
||||
// would put the approved transfer on the chain twice.
|
||||
test("a failed broadcast is terminal, whatever the node said", () => {
|
||||
for (const message of [
|
||||
"already known",
|
||||
"timeout of 30000ms exceeded",
|
||||
"could not coalesce error",
|
||||
"replacement transaction underpriced",
|
||||
]) {
|
||||
const outcome = describeTxFailure(
|
||||
TX_STAGE_BROADCAST,
|
||||
new Error(message),
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.spendApproval).toBe(true);
|
||||
expect(outcome.error).toBe(message);
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed broadcast does not tell the user to send it again", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{
|
||||
error: "The node did not answer.",
|
||||
retryable: false,
|
||||
stage: TX_STAGE_BROADCAST,
|
||||
},
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.message).toMatch(/may still have reached the network/);
|
||||
expect(outcome.message).not.toMatch(/start it again from the site/);
|
||||
});
|
||||
});
|
||||
|
||||
// End-to-end over the messaging boundary, without a browser: run the exact
|
||||
// sequence the approval popup runs, then hand the artifact to the exact check
|
||||
// the background runs before it broadcasts or resolves. Only what the popup
|
||||
@@ -314,7 +1094,12 @@ describe("popup signing sequence to background verification", () => {
|
||||
|
||||
test("a populated, signed transaction is accepted and broadcastable", async () => {
|
||||
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
||||
const parsed = verifySignedTx(rawSignedTx, TX_PARAMS, signer.address);
|
||||
const parsed = verifySignedTx(
|
||||
rawSignedTx,
|
||||
TX_PARAMS,
|
||||
signer.address,
|
||||
SELECTED,
|
||||
);
|
||||
expect(parsed.nonce).toBe(7);
|
||||
expect(parsed.chainId).toBe(1n);
|
||||
expect(parsed.gasLimit).toBe(21000n);
|
||||
@@ -349,7 +1134,14 @@ describe("popup signing sequence to background verification", () => {
|
||||
to: OTHER_RECIPIENT,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address),
|
||||
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/approved recipient/);
|
||||
});
|
||||
|
||||
test("the background rejects a transaction populated on another network", async () => {
|
||||
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
||||
expect(() =>
|
||||
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SEPOLIA),
|
||||
).toThrow(/different network than the one that is selected/);
|
||||
});
|
||||
});
|
||||
|
||||
679
tests/backgroundApproval.test.js
Normal file
679
tests/backgroundApproval.test.js
Normal file
@@ -0,0 +1,679 @@
|
||||
// The background's approval message wiring, driven end to end: a dApp
|
||||
// eth_sendTransaction raises a pending approval, and the popup answers it with
|
||||
// AUTISTMASK_TX_RESPONSE / AUTISTMASK_SIGN_RESPONSE.
|
||||
//
|
||||
// What this exists for is the duplicate response. The handler verifies and
|
||||
// broadcasts asynchronously, and the approval deliberately survives a
|
||||
// retryable failure so the user can try again with the transaction they
|
||||
// already saw — which means the entry being present is not by itself proof
|
||||
// that no attempt is running. A second response carrying the same id (a
|
||||
// reloaded approval window re-rendering a live Approve button, a popup that
|
||||
// emits the message twice) must not start a second verify and broadcast: with
|
||||
// the ordinary dApp approval shape the page fixes no nonce, so two artifacts
|
||||
// signed at different nonces both verify, and the approved transfer would go
|
||||
// out twice.
|
||||
|
||||
const { Wallet } = require("ethers");
|
||||
|
||||
const SIGNER_KEY =
|
||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
||||
const signer = new Wallet(SIGNER_KEY);
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
const ORIGIN = "https://dapp.example";
|
||||
const HOSTNAME = "dapp.example";
|
||||
const EXT_URL = "chrome-extension://autistmask/";
|
||||
|
||||
// What the dApp asks for: no nonce, no gas, no fees. This is the shape that
|
||||
// makes a duplicate broadcast possible at all.
|
||||
const TX_PARAMS = {
|
||||
from: signer.address,
|
||||
to: RECIPIENT,
|
||||
value: "0x2386f26fc10000",
|
||||
data: "0x",
|
||||
};
|
||||
|
||||
// The fields the popup's populateTransaction() would fill in. The nonce is a
|
||||
// parameter because the duplicate case turns on the two artifacts differing
|
||||
// in exactly the field nothing constrains.
|
||||
function populated(nonce) {
|
||||
return {
|
||||
type: 2,
|
||||
chainId: 1,
|
||||
nonce,
|
||||
gasLimit: 100000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
to: TX_PARAMS.to,
|
||||
value: BigInt(TX_PARAMS.value),
|
||||
data: TX_PARAMS.data,
|
||||
};
|
||||
}
|
||||
|
||||
function signedAtNonce(nonce) {
|
||||
return signer.signTransaction(populated(nonce));
|
||||
}
|
||||
|
||||
// A promise whose settlement the test controls, so a broadcast can be held in
|
||||
// flight while the second response arrives.
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
// Load the background worker against stubbed browser and network APIs and
|
||||
// return the handles the tests drive it through. Everything that would touch
|
||||
// the network or the browser's own schedulers is mocked; the approval
|
||||
// verification is the real module, because that is what the handler under
|
||||
// test is wired to.
|
||||
function loadBackground(options) {
|
||||
const opts = options || {};
|
||||
jest.resetModules();
|
||||
|
||||
const broadcastTransaction = jest.fn();
|
||||
const loadState = jest.fn(opts.loadState || (async () => {}));
|
||||
|
||||
jest.doMock("../src/shared/state", () => ({
|
||||
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
|
||||
loadState,
|
||||
saveState: jest.fn(async () => {}),
|
||||
currentNetwork: () => ({ chainId: "0x1" }),
|
||||
}));
|
||||
jest.doMock("../src/shared/balances", () => ({
|
||||
getProvider: () => ({ broadcastTransaction }),
|
||||
refreshBalances: jest.fn(async () => {}),
|
||||
}));
|
||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||
isPhishingDomain: () => false,
|
||||
refreshPhishingListOnSchedule: jest.fn(async () => {}),
|
||||
initPhishingList: jest.fn(async () => {}),
|
||||
}));
|
||||
jest.doMock("../src/shared/alarms", () => ({
|
||||
BALANCE_REFRESH_ALARM: "balance",
|
||||
PHISHING_REFRESH_ALARM: "phishing",
|
||||
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
||||
ensureRecurringAlarms: jest.fn(async () => {}),
|
||||
registerAlarmHandlers: jest.fn(),
|
||||
}));
|
||||
|
||||
const persisted = {
|
||||
wallets: [
|
||||
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
|
||||
],
|
||||
rpcUrl: "https://rpc.invalid",
|
||||
activeAddress: signer.address,
|
||||
allowedSites: { [signer.address]: [HOSTNAME] },
|
||||
deniedSites: {},
|
||||
};
|
||||
|
||||
let messageListener = null;
|
||||
let windowRemovedListener = null;
|
||||
const created = [];
|
||||
const removed = [];
|
||||
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(async () => ({ autistmask: persisted })),
|
||||
set: jest.fn(async () => {}),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
getURL: (path) => EXT_URL + path,
|
||||
onMessage: {
|
||||
addListener: (fn) => {
|
||||
messageListener = fn;
|
||||
},
|
||||
},
|
||||
onConnect: { addListener: () => {} },
|
||||
lastError: null,
|
||||
},
|
||||
windows: {
|
||||
getLastFocused: (cb) => cb(null),
|
||||
create: (options2, cb) => {
|
||||
created.push(options2);
|
||||
cb({ id: created.length });
|
||||
},
|
||||
remove: (id, cb) => {
|
||||
removed.push(id);
|
||||
if (cb) cb();
|
||||
},
|
||||
// Captured, not swallowed: closing the approval window is the
|
||||
// event that used to retire an approval out from under a live
|
||||
// broadcast, and a no-op stub here hides exactly that.
|
||||
onRemoved: {
|
||||
addListener: (fn) => {
|
||||
windowRemovedListener = fn;
|
||||
},
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
query: (q, cb) => cb([]),
|
||||
sendMessage: () => {},
|
||||
},
|
||||
action: { setPopup: () => {} },
|
||||
};
|
||||
|
||||
require("../src/background/index");
|
||||
|
||||
// Send a message the way the browser would, and hand back whatever the
|
||||
// handler passed to sendResponse.
|
||||
function send(msg, sender) {
|
||||
const sendResponse = jest.fn();
|
||||
const kept = messageListener(msg, sender || {}, sendResponse);
|
||||
return { sendResponse, kept };
|
||||
}
|
||||
|
||||
// Raise a pending transaction approval the way a dApp does, and dig the
|
||||
// approval id back out of the popup URL the background opened.
|
||||
function requestTx() {
|
||||
let rpcResult = null;
|
||||
const sendResponse = jest.fn((r) => {
|
||||
rpcResult = r;
|
||||
});
|
||||
messageListener(
|
||||
{
|
||||
type: "AUTISTMASK_RPC",
|
||||
method: "eth_sendTransaction",
|
||||
params: [TX_PARAMS],
|
||||
},
|
||||
{ origin: ORIGIN },
|
||||
sendResponse,
|
||||
);
|
||||
return {
|
||||
id: () => new URL(created[0].url).searchParams.get("approval"),
|
||||
result: () => rpcResult,
|
||||
};
|
||||
}
|
||||
|
||||
// The user closes the approval popup. `created` is index-aligned with the
|
||||
// ids the window stub hands back, so window 1 is the first popup opened.
|
||||
function closeWindow(windowId) {
|
||||
windowRemovedListener(windowId);
|
||||
}
|
||||
|
||||
return {
|
||||
send,
|
||||
requestTx,
|
||||
closeWindow,
|
||||
broadcastTransaction,
|
||||
loadState,
|
||||
created,
|
||||
removed,
|
||||
fromPopup: { url: EXT_URL + "src/popup/index.html" },
|
||||
};
|
||||
}
|
||||
|
||||
// Let the handler's promise chain run to the next suspension point.
|
||||
async function settle() {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe("one approval, one broadcast", () => {
|
||||
test("a second AUTISTMASK_TX_RESPONSE for the same id does not broadcast again", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
expect(id).toBeTruthy();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
|
||||
// The popup answers. Verification passes and the broadcast is held
|
||||
// open, which is the whole window the second message arrives in.
|
||||
const first = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A reloaded approval window signs the same approval again. Nothing
|
||||
// in the approval fixes a nonce, so this artifact verifies just as
|
||||
// well as the first one.
|
||||
const second = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(8),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(second.sendResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.stringMatching(/already being sent/),
|
||||
retryable: false,
|
||||
}),
|
||||
);
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("the same artifact sent twice broadcasts once", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
const raw = await signedAtNonce(7);
|
||||
const msg = {
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: raw,
|
||||
};
|
||||
|
||||
bg.send(msg, { url: bg.fromPopup.url });
|
||||
bg.send(msg, { url: bg.fromPopup.url });
|
||||
await settle();
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a response arriving after the broadcast finished finds nothing to send", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
const late = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(8),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(late.sendResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a second AUTISTMASK_SIGN_RESPONSE for the same id is refused", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
// Hold the transaction approval in flight, then answer it a second
|
||||
// time as if it were a sign approval: the sign handler must apply the
|
||||
// same interlock rather than running its own verification.
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
const second = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
signature: "0x00",
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(second.sendResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.stringMatching(/already being signed/),
|
||||
retryable: false,
|
||||
}),
|
||||
);
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// The interlock must not cost the retry the approval exists to allow.
|
||||
describe("the interlock releases a failed attempt", () => {
|
||||
test("a retryable failure before the broadcast leaves the approval usable", async () => {
|
||||
let failNext = true;
|
||||
const bg = loadBackground({
|
||||
loadState: async () => {
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
throw new Error("storage unavailable");
|
||||
}
|
||||
},
|
||||
});
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const first = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
||||
expect(first.sendResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ retryable: true }),
|
||||
);
|
||||
|
||||
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
||||
const retry = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(retry.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
});
|
||||
|
||||
test("a mismatched artifact spends the approval outright", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
// Signed for a different recipient than the one that was approved.
|
||||
const wrong = await signer.signTransaction({
|
||||
...populated(7),
|
||||
to: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
||||
});
|
||||
const first = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: wrong,
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(first.sendResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ retryable: false, stage: "verify" }),
|
||||
);
|
||||
|
||||
const retry = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
||||
expect(retry.sendResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// The claim is what makes one approval one broadcast, so it has to hold
|
||||
// against everything else that retires an approval, not just against a second
|
||||
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
|
||||
// promise 4001 while the attempt behind it ran to completion: the transaction
|
||||
// reached the chain and the page was told the user rejected it, which invites
|
||||
// the user to send it a second time at a fresh nonce.
|
||||
describe("a claimed approval outlives every other retirement path", () => {
|
||||
// The approval popup stays open across the broadcast it is waiting on, so
|
||||
// a user closing an apparently-hung window needs no adversary at all.
|
||||
test("closing the approval window mid-broadcast still reports the result", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
const first = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The user closes the window while the broadcast is still open.
|
||||
bg.closeWindow(1);
|
||||
await settle();
|
||||
expect(pending.result()).toBeNull();
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("switching the active address mid-broadcast still reports the result", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The user switches account in the toolbar popup, which rejects and
|
||||
// force-closes every pending approval.
|
||||
bg.send(
|
||||
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(pending.result()).toBeNull();
|
||||
// The window an in-flight attempt reports into is left standing too.
|
||||
expect(bg.removed).toEqual([]);
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a reject arriving mid-broadcast is refused, not honoured", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
const reject = bg.send(
|
||||
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(pending.result()).toBeNull();
|
||||
expect(reject.sendResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
retryable: false,
|
||||
stage: "broadcast",
|
||||
}),
|
||||
);
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// The refusals above must not cost the rejection its ordinary meaning.
|
||||
test("with no attempt running, closing the window still rejects", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
|
||||
bg.closeWindow(1);
|
||||
await settle();
|
||||
|
||||
expect(pending.result()).toEqual({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("with no attempt running, an active-address switch still rejects and closes", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
|
||||
bg.send(
|
||||
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(pending.result()).toEqual({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
expect(bg.removed).toEqual([1]);
|
||||
});
|
||||
|
||||
// A sign approval held by a running verification is the same shape, and
|
||||
// the refusal must not tell the user to start again from the site while
|
||||
// the first attempt may still hand back a signature.
|
||||
test("a reject during a sign attempt is refused with the in-flight stage", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
const reject = bg.send(
|
||||
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(reject.sendResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ retryable: false, stage: "inflight" }),
|
||||
);
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("popup-only messages", () => {
|
||||
test("a page sender cannot answer an approval", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const spoof = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: ORIGIN + "/index.html" },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
||||
expect(spoof.sendResponse).toHaveBeenCalledWith({
|
||||
error: "Unauthorized sender",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,18 +22,12 @@ 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.
|
||||
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/,
|
||||
},
|
||||
];
|
||||
//
|
||||
// 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 = [];
|
||||
|
||||
function isAllowed(text) {
|
||||
return ALLOWED_ERRORS.some((a) => a.pattern.test(text));
|
||||
@@ -247,6 +241,26 @@ 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);
|
||||
@@ -293,5 +307,6 @@ module.exports = {
|
||||
launch,
|
||||
openAddressDetail,
|
||||
openPopup,
|
||||
pageCompilesWasm,
|
||||
visible,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
launch,
|
||||
openAddressDetail,
|
||||
openPopup,
|
||||
pageCompilesWasm,
|
||||
visible,
|
||||
} = require("./harness");
|
||||
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
|
||||
@@ -60,6 +61,27 @@ 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(
|
||||
|
||||
166
tests/holders.test.js
Normal file
166
tests/holders.test.js
Normal file
@@ -0,0 +1,166 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
105
tests/manifest.test.js
Normal file
105
tests/manifest.test.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
123
tests/sendTokenSelect.test.js
Normal file
123
tests/sendTokenSelect.test.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// 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,6 +1473,65 @@ 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,
|
||||
|
||||
52
tests/vaultBackend.test.js
Normal file
52
tests/vaultBackend.test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
290
tests/walletDefects.test.js
Normal file
290
tests/walletDefects.test.js
Normal file
@@ -0,0 +1,290 @@
|
||||
// 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