Compare commits

...

4 Commits

Author SHA1 Message Date
baeeb6938f fix: answer the page when a background handler throws (closes #280)
All checks were successful
check / check (push) Successful in 28s
handleRpc(...).then(sendResponse) had no .catch(), and sendResponse is the only
thing that settles the dApp's window.ethereum.request() promise. Any throw
inside handleRpc therefore sent nothing back: the content script posted nothing,
and the page's promise stayed pending forever with no error and no timeout,
indistinguishable from a slow wallet. handleRpc does real work -- state loads,
provider calls, transaction population, approval plumbing -- so "it does not
throw today" was not a property anyone was maintaining.

A rejected handleRpc now answers { code: -32603, message }. -32603 is the
JSON-RPC internal error EIP-1474 defines and EIP-1193 defers to for RPC-layer
failures; no EIP-1193 4xxx code describes "the wallet broke" and none was
invented for it. Nothing is stripped or overwritten by this: every deliberately
coded rejection the wallet emits (4001, 4100, 4902) is a returned value, not a
throw, so it travels the resolved path and never reaches this catch. The cause
is not put in the message: the page gets a stable sentence, the background
console gets the method and the throw, so the failure is visible rather than
swallowed.

The two async IIFEs behind AUTISTMASK_TX_RESPONSE and AUTISTMASK_SIGN_RESPONSE
are the same shape one level down. Every statement is inside a try, but a throw
from one of the catch blocks escapes as an unhandled rejection and neither the
popup nor the page is answered. Each gets a last-resort .catch() that settles
the approval through settleApproval() -- the existing chokepoint, with no new
delete or resolve -- and answers the popup. The transaction one tracks the phase
it is in and reports that: an escape from the verify catch runs before
broadcastTransaction() is ever called, so it says the request is gone rather
than that it may still have reached the network, and only an escape from the
broadcast catch keeps the warning about a second send. Every other message
handler on the path is synchronous and cannot leave a promise pending.

Each of the four tests is driven by a real failure rather than a hook in the
handler: a rejecting extension-storage read, which getState() awaits unguarded,
and a failure classifier that throws while classifying a genuine verification or
broadcast failure. All four were demonstrated failing against the unfixed code,
the RPC one with sendResponse at zero calls, which is precisely the page-side
hang. The two transaction cases also assert the sentence describeSigningFailure
builds for each stage, which is the copy the user reads.
2026-08-17 06:41:16 +00:00
c06765ef8f fix: one transaction approval at a time, and honest copy for a nonce collision (closes #271)
All checks were successful
check / check (push) Successful in 28s
2026-08-17 08:38:26 +02:00
e07efb710a fix: an address holding only unpriced tokens is no longer totalled at $0.00 (closes #261)
Some checks failed
check / check (push) Has been cancelled
2026-08-17 08:38:10 +02:00
d9d50f05d2 test: assert the #150 and #151 items the harness did not cover (closes #188)
All checks were successful
check / check (push) Successful in 27s
Covers the four DoD items #188 scoped: the AddToken quick-pick populating the address field, Back out of AddToken unwinding the persisted navigation stack exactly once, a native ETH transfer rendering in TransactionDetail with no token contract row, and tap-to-copy reading the real clipboard back.

The native path needed a fixture: the Blockscout normal-transactions endpoint answered [] unconditionally, so there was no non-ERC-20 row to open.

Each assertion demonstrated to discriminate by mutation, one break at a time with the pre-existing tests staying green: a double viewStack push, a no-op quick-pick handler, an un-hidden token contract row, the ERC-20 branch forced onto a native transaction, and a dropped clipboard write each turn exactly the corresponding test red and no other.

Four further DoD items from #150 and #151 remain outside this scope and are tracked in #295.
2026-08-17 08:10:37 +02:00
16 changed files with 1879 additions and 156 deletions

View File

@@ -538,6 +538,27 @@ Both are click-copyable. Truncating to 4 decimals in summary views is acceptable
for scannability, but the detail view must never discard precision — it is the for scannability, but the detail view must never discard precision — it is the
one place the user can always use to verify exact details. one place the user can always use to verify exact details.
#### Partial USD totals
Prices are fetched for the top 25 tokens only, so an address can hold assets the
extension has no price for. Worth zero and worth an unknown amount are different
facts and are never collapsed into one number. `getAddressValue()` in
`src/shared/prices.js` returns `{ usd, partial }` — the value of the priced
holdings, and whether an unpriced holding was left out of it — and every screen
renders it through `formatAddressTotal()`, so the wording cannot drift:
- Nothing knowable (testnet, or before the first price fetch): no total line.
- Everything priced: `Total: $5,500.00`.
- Part priced: `Total: $3,000.00 plus unpriced tokens` — the figure is real as
far as it goes and is kept, named as a floor rather than the total.
- Nothing priced but something held: `Total: unpriced tokens only`. No figure,
because the only figure available would be the `$0.00` sum of an empty set,
and on the address-removal confirmation that sits directly under "This address
holds a balance."
The per-token balance lines are unaffected: each shows its quantity, and a USD
column that is blank for a token with no price.
#### Language & Labeling #### Language & Labeling
All user-facing text avoids unnecessary jargon wherever possible: All user-facing text avoids unnecessary jargon wherever possible:
@@ -673,7 +694,9 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- **When**: At least one wallet exists. This is the root screen. - **When**: At least one wallet exists. This is the root screen.
- **Elements**: - **Elements**:
- Active address ETH balance (large) + USD value in parentheses - Active address ETH balance (large) + USD value in parentheses
- "Total:" USD value across ETH and every token shown for the active address - "Total:" USD value across ETH and every token shown for the active
address, written by `formatAddressTotal()` — see
[Partial USD totals](#partial-usd-totals)
- Active address (color dot, full address, etherscan link, tap to copy) - Active address (color dot, full address, etherscan link, tap to copy)
- Send / Receive quick-action buttons, both acting on the active address - Send / Receive quick-action buttons, both acting on the active address
- ETH/USD price display - ETH/USD price display
@@ -735,7 +758,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- Title: "Wallet Name — Address N" - Title: "Wallet Name — Address N"
- ENS name (if resolved, bold above the address) - ENS name (if resolved, bold above the address)
- Full address (color dot, etherscan link, tap to copy) - Full address (color dot, etherscan link, tap to copy)
- USD total for address - USD total for address (see [Partial USD totals](#partial-usd-totals))
- Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal - Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
places, USD inline). Each balance row is clickable → **AddressToken** places, USD inline). Each balance row is clickable → **AddressToken**
- Send / Receive / + Token buttons and a "···" menu button - Send / Receive / + Token buttons and a "···" menu button
@@ -1101,11 +1124,13 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
xprv wallet has no recovery phrase to re-import. xprv wallet has no recovery phrase to re-import.
- A warning when the address holds anything, ETH or any tracked ERC-20, - A warning when the address holds anything, ETH or any tracked ERC-20,
followed by the holdings themselves via `balanceLinesForAddress()` and the followed by the holdings themselves via `balanceLinesForAddress()` and the
USD total via `getAddressValueUsd()`. The sentence names no figure of its USD total via `formatAddressTotal()` (see
own: the lines round to four decimals, so a sentence built from a rounded [Partial USD totals](#partial-usd-totals)). The sentence names no figure
number would report `0.0000 ETH` for an address holding real money. The of its own: the lines round to four decimals, so a sentence built from a
predicate is `addressHoldsFunds()` in `src/popup/views/helpers.js`, rounded number would report `0.0000 ETH` for an address holding real
unrounded and token-aware. A balance is a warning, never a refusal. money. The predicate is `addressHoldsFunds()` in
`src/popup/views/helpers.js`, unrounded and token-aware. A balance is a
warning, never a refusal.
- The rule that a wallet always keeps at least one address, and that - The rule that a wallet always keeps at least one address, and that
removing the last one means deleting the wallet from Settings removing the last one means deleting the wallet from Settings
- Error line - Error line
@@ -1175,7 +1200,11 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
opening the window, so the screen shows a complete transaction and the signed opening the window, so the screen shows a complete transaction and the signed
artifact can be compared with it field for field. A request that cannot be artifact can be compared with it field for field. A request that cannot be
populated — unreachable node, reverting gas estimate — opens no window and is populated — unreachable node, reverting gas estimate — opens no window and is
failed back to the site. failed back to the site. Only one transaction approval exists at a time:
populating fixes the nonce, so a second `eth_sendTransaction` arriving while
one is unanswered is refused with EIP-1193 code `-32002` rather than being
populated at the same nonce. It opens no window and takes no nonce, and the
site can send it again once the pending one is answered.
- **Elements**: - **Elements**:
- "Transaction Request" heading - "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing - Phishing warning banner (shown when the hostname is on the phishing

78
TODO.md
View File

@@ -45,6 +45,22 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-17: An address total no longer reports `$0.00` for holdings it cannot
price. Prices exist for the top 25 tokens only, so the priced-only sum was
printed as the total and an address holding nothing but unpriced ERC-20s was
shown as worth nothing — directly under "This address holds a balance." on the
address-removal confirmation. `getAddressValue()` in `src/shared/prices.js`
now returns `{ usd, partial }`, keeping worth-zero and worth-an-unknown-amount
apart the way an absent `holders_count` is kept apart from a count of zero,
and every screen renders it through the one `formatAddressTotal()`: the figure
when it covers everything, the figure marked `plus unpriced tokens` when it
covers part, and `Total: unpriced tokens only` when it would cover nothing.
Home, AddressDetail and the removal confirmation all read it, and
`getWalletValue()`/`getTotalValue()` carry `partial` up. Covered by
`tests/addressValue.test.js` — the only-unpriced, genuinely-zero and
fully-priced cases at the helper and at both call sites that return their
markup — demonstrated failing first
([#261](https://git.eeqj.de/sneak/AutistMask/issues/261)).
- 2026-08-17: `README.md` no longer advertises a defect the wallet does not - 2026-08-17: `README.md` no longer advertises a defect the wallet does not
have. The End-to-End Tests section listed the EIP-1193 code being dropped in have. The End-to-End Tests section listed the EIP-1193 code being dropped in
the last hop into the page as a standing limit of the dApp coverage; that the last hop into the page as a standing limit of the dApp coverage; that
@@ -54,6 +70,68 @@ undefined identifiers, which is how
stubbed RPC and the unobservable toolbar popup — were checked against the stubbed RPC and the unobservable toolbar popup — were checked against the
current `src/content/inpage.js` and `tests/e2e/` and left as they are current `src/content/inpage.js` and `tests/e2e/` and left as they are
([#285](https://git.eeqj.de/sneak/AutistMask/issues/285)). ([#285](https://git.eeqj.de/sneak/AutistMask/issues/285)).
- 2026-08-17: One transaction approval at a time. Populating in the background
before the window opens is what makes the displayed object the verified
object, and it also fixes the nonce: two `eth_sendTransaction` calls populated
concurrently took the same nonce from a node that had seen neither broadcast,
and the second could then never be sent, because the only way to give it a
fresh nonce is to populate it again after the user has read the old one off
the screen. A second request is now refused with EIP-1193 `-32002` while one
is unanswered — the slot is taken immediately before population, after the
authorization checks, so no second nonce is allocated, no second window opens,
and a page the wallet refuses anyway cannot hold the slot against the
connected site. The slot is freed at `settleApproval()`, the single point an
approval is retired, so every path that ends an approval ends the hold with
it; an approval whose window is gone and whose attempt has failed is settled
there rather than left waiting on a window that no longer exists, and an
approval window that could not be opened at all is answered with `-32603`
instead of holding the page's promise open. Signature approvals are not gated,
consuming no nonce. A collision that does happen is also reported accurately
now: a broadcast the node refused for the nonce, and an approval carrying a
nonce this worker has already broadcast for that address on that chain (caught
before the node is asked at all), both say the transaction did not reach the
network and to send it again, instead of warning that it may have sent. The
record is keyed by chain as well as address, because nonce spaces are per
chain and low nonces overlap across them. `already known` deliberately keeps
the ambiguous wording, because a node that says it has the transaction has it
([#271](https://git.eeqj.de/sneak/AutistMask/issues/271)).
- 2026-08-14: The parts of the
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) definition of done the
e2e suite did not cover are asserted. It had only shown that the two screens
open without throwing. Now: the Add Token round trip leaves the navigation
stack exactly as it found it, read out of extension storage rather than
inferred from which screen is up, so an orphaned entry — the second-order
damage of #150 — is caught where it happens rather than one Back press later;
a common-token quick-pick puts its contract address in the field; the native
ETH detail path renders with its own type, value and raw quantity and with the
token contract row still hidden, against a new `seedNativeTransfer` fixture,
since the normal-transactions endpoint answered `[]` unconditionally and there
was no non-ERC-20 row to open; and tapping the token contract address puts it
on the real clipboard, read back after a sentinel write. Each of the four was
demonstrated failing against a deliberately broken build
([#188](https://git.eeqj.de/sneak/AutistMask/issues/188)).
- 2026-08-14: A background message handler that throws now rejects the page
instead of hanging it. `handleRpc(...).then(sendResponse)` had no `.catch()`,
and `sendResponse` is the only thing that settles the dApp's
`window.ethereum.request()` promise — so any throw inside `handleRpc` left
that promise pending forever, with no error and no timeout, indistinguishable
from a slow wallet. It now answers `{ code: -32603, message }` (the JSON-RPC
internal error EIP-1474 defines and EIP-1193 defers to; no EIP-1193 4xxx code
describes "the wallet broke" and none was invented) and logs the method and
the throw to the background console rather than swallowing them. The two async
IIFEs behind `AUTISTMASK_TX_RESPONSE` and `AUTISTMASK_SIGN_RESPONSE` were the
same shape one level down — every statement inside a `try`, but a throw out of
a `catch` block escaping unhandled — and each got a last-resort `.catch()`
settling the approval through `settleApproval()` and answering the popup. The
transaction one tracks which phase it escaped from and reports that, so an
escape before `broadcastTransaction()` says the request is gone rather than
that it may still have reached the network. Every other handler on the path is
synchronous. All four are driven by real failures — a rejecting storage read,
and a failure classifier that throws while classifying a genuine verification
or broadcast failure — and were demonstrated failing first, the RPC one with
`sendResponse` at zero calls
([#280](https://git.eeqj.de/sneak/AutistMask/issues/280)).
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js` - 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
rebuilt every failure as `new Error(error.message)`, so the code the rebuilt every failure as `new Error(error.message)`, so the code the
background produced and the content script relayed intact was dropped in the background produced and the content script relayed intact was dropped in the

View File

@@ -24,6 +24,7 @@ const {
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT, TX_STAGE_INFLIGHT,
TX_STAGE_NONCE,
} = require("../shared/approvalVerify"); } = require("../shared/approvalVerify");
const { prepareApprovalTx } = require("../shared/approvalTx"); const { prepareApprovalTx } = require("../shared/approvalTx");
const { const {
@@ -57,6 +58,124 @@ const connectedSites = {};
// Pending approval requests: { id: { origin, hostname, resolve } } // Pending approval requests: { id: { origin, hostname, resolve } }
const pendingApprovals = {}; const pendingApprovals = {};
// One transaction approval at a time, wallet-wide.
//
// The transaction a site asks for is populated before its approval window
// opens, so that the object the user is shown is the object the signed
// artifact is verified against. Populating fixes the nonce. Two requests
// populated concurrently therefore take the SAME nonce — the node reports the
// same pending count to both, neither having been broadcast — and whichever is
// broadcast second is refused by the network for a nonce it can never be
// re-signed at, because re-signing it would mean signing something other than
// what was displayed.
//
// So the second request is refused while the first is unanswered. It is
// refused before anything is populated, so no second nonce is allocated at
// all, and while the page is still waiting with nothing on screen. The
// alternatives were considered and rejected in
// https://git.eeqj.de/sneak/AutistMask/issues/271: populating again at Confirm
// puts a nonce on screen that is not the nonce that gets signed, and
// allocating around in-flight approvals makes the wallet's own bookkeeping the
// authority on a nonce the network has not accepted, which an abandoned
// approval then leaves a hole in.
//
// Sign approvals are not gated: a signature consumes no nonce.
//
// The slot is null when free, and otherwise the handle of the request holding
// it. Once that request has raised its approval the handle carries the
// approval's id, so that retiring the approval frees the slot: every exit from
// pendingApprovals goes through settleApproval(), which makes that one hook
// complete. The holder's own finally is the backstop for the interval before
// the approval exists.
let txApprovalSlot = null;
// EIP-1474 "resource unavailable": the standard code for a request that is
// refused because another one is already pending.
const TX_APPROVAL_PENDING_CODE = -32002;
// True at every moment this can be sent: the slot is taken immediately before
// the transaction is populated, so the other request is either being prepared
// or on screen. It does not claim the other one is displayed yet, because for
// the length of one network round trip it is not.
const TX_APPROVAL_PENDING_MESSAGE =
"AutistMask handles one transaction at a time, and another one is" +
" already in progress, so this one was not sent. Please finish that" +
" transaction, then send this one again.";
// Take the slot, or refuse. Nothing awaits between the test and the set, so
// two requests that reach this in the same tick cannot both pass it — the
// position of the call in the handler is irrelevant to that, which is why it
// sits after the authorization checks. A page the wallet is going to refuse
// anyway must not be able to take the slot away from the connected site.
function reserveTxApprovalSlot() {
if (txApprovalSlot) return null;
txApprovalSlot = { approvalId: null };
return txApprovalSlot;
}
// Free the slot, if this handle is still the one holding it.
function releaseTxApprovalSlot(handle) {
if (handle && txApprovalSlot !== handle) return;
txApprovalSlot = null;
}
// Free the slot held on behalf of a retired approval. Called from
// settleApproval() for every approval, and a no-op for the ones the slot was
// not taken for.
function releaseTxApprovalSlotFor(approvalId) {
if (txApprovalSlot && txApprovalSlot.approvalId === approvalId) {
txApprovalSlot = null;
}
}
// Nonces this worker has already handed to the node, per chain and address.
// This is the wallet's own knowledge that a nonce is spent, and it is checked
// before a broadcast rather than after: a node's pending count can lag a
// transaction it has itself just accepted, and a request populated inside that
// window would otherwise be signed and sent at a nonce this wallet has already
// used.
//
// The chain is part of the key because nonce spaces are per chain and the
// wallet switches networks. Without it a nonce spent on one chain would refuse
// that nonce on every other chain — and low nonces overlap across chains as a
// matter of course, so the refusal would be both routine and false.
//
// The record dies with the worker, which is correct rather than merely
// convenient: after a restart the node's count is the only answer available,
// and a transaction of this wallet's that the node has forgotten is one the
// user does want to be able to send again.
const broadcastNonces = {};
function broadcastNoncesFor(chainId, address) {
const key =
String(chainId).toLowerCase() +
":" +
String(address || "").toLowerCase();
if (!broadcastNonces[key]) broadcastNonces[key] = new Set();
return broadcastNonces[key];
}
// An approved transaction's nonce as a decimal string, or null if it cannot be
// read as a number. Verification refuses an unreadable nonce before this is
// ever reached; null here only keeps the record from holding junk.
function approvedNonce(approvedTx) {
try {
return BigInt(approvedTx.nonce).toString();
} catch {
return null;
}
}
// What the page is told when a request failed in a way the wallet has no
// specific answer for. -32603 is the JSON-RPC internal error EIP-1474 defines
// and EIP-1193 defers to for RPC-layer failures; no EIP-1193 4xxx code
// describes "the wallet broke", and one is not invented here. The cause is
// logged rather than put in the message: the page gets a stable sentence, the
// background console gets the throw.
const INTERNAL_ERROR_CODE = -32603;
const INTERNAL_ERROR_MESSAGE =
"AutistMask could not complete this request because of an internal error.";
async function getState() { async function getState() {
const result = await storageApi.get("autistmask"); const result = await storageApi.get("autistmask");
return ( return (
@@ -148,11 +267,41 @@ function settleApproval(id, result, options) {
const holdsClaim = !!(options && options.holdsClaim); const holdsClaim = !!(options && options.holdsClaim);
if (approval.attemptInFlight && !holdsClaim) return false; if (approval.attemptInFlight && !holdsClaim) return false;
delete pendingApprovals[id]; delete pendingApprovals[id];
// The transaction-approval slot is held for exactly as long as the
// approval it was taken for is alive, and this is the one place an
// approval stops being alive.
releaseTxApprovalSlotFor(id);
approval.resolve(result); approval.resolve(result);
resetPopupUrl(); resetPopupUrl();
return true; return true;
} }
// What a pending approval resolves to when it is given up on rather than
// answered: the window was closed, or could not be opened at all. A tx or sign
// approval answers the requesting page in EIP-1193 shape; a site-connection
// approval answers the connection handler in its own.
function abandonedResult(approval, code, message) {
if (approval.type === "tx" || approval.type === "sign") {
return { error: { code, message } };
}
return { approved: false, remember: false };
}
// A window the user closed without answering is a refusal by the user, which
// is 4001 and the wording every other rejection path already uses.
const APPROVAL_REJECTED_CODE = 4001;
const APPROVAL_REJECTED_MESSAGE = "User rejected the request.";
// The window could not be opened, so the user was never asked. This is the
// wallet failing, not the user refusing, so it does not claim to be a
// rejection: -32603 is the JSON-RPC code for the wallet's own internal
// failure, and the page is told plainly that nothing was shown.
const APPROVAL_WINDOW_FAILED_CODE = -32603;
const APPROVAL_WINDOW_FAILED_MESSAGE =
"AutistMask could not open its approval window, so this request was not" +
" shown to you and nothing was sent.";
// Take exclusive hold of a pending approval for one attempt, or refuse. // Take exclusive hold of a pending approval for one attempt, or refuse.
// //
// An approval that failed retryably has to stay in pendingApprovals, so its // An approval that failed retryably has to stay in pendingApprovals, so its
@@ -172,8 +321,26 @@ function claimApproval(approval) {
// Release an approval whose attempt failed in a way the user can retry. // Release an approval whose attempt failed in a way the user can retry.
// Nothing was broadcast, so the next attempt may claim it. // Nothing was broadcast, so the next attempt may claim it.
//
// Unless the window it would be retried in is already gone. The user closed it
// while the attempt was running and settleApproval() declined then, correctly,
// because the attempt still owned the approval; the attempt has now failed, so
// nothing owns it and nothing can reach it. Left standing it would hold the
// requesting page's promise open forever and, with it, the transaction
// approval slot. It is settled here as the rejection the closed window
// already meant.
function releaseApproval(approval) { function releaseApproval(approval) {
approval.attemptInFlight = false; approval.attemptInFlight = false;
if (approval.windowClosed) {
settleApproval(
approval.id,
abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
),
);
}
} }
// Open approval in a separate popup window. // Open approval in a separate popup window.
@@ -200,9 +367,35 @@ function openApprovalWindow(id) {
); );
} }
windowsApi.create(opts, (win) => { windowsApi.create(opts, (win) => {
if (win) { const approval = pendingApprovals[id];
pendingApprovals[id].windowId = win.id; if (!approval) {
// Settled while the window was opening — an address switch,
// say. Nothing is waiting on it, and a window showing an
// approval that no longer exists is not left on screen.
if (win) {
windowsApi.remove(win.id, () => {
if (runtime.lastError) {
// window already closed
}
});
}
return;
} }
if (!win) {
// No window means no way to ever answer this approval, and an
// approval nothing can answer holds the requesting page's
// promise open forever. Settle it now instead.
settleApproval(
id,
abandonedResult(
approval,
APPROVAL_WINDOW_FAILED_CODE,
APPROVAL_WINDOW_FAILED_MESSAGE,
),
);
return;
}
approval.windowId = win.id;
}); });
}); });
} }
@@ -212,7 +405,7 @@ function openApprovalWindow(id) {
function requestApproval(origin, hostname) { function requestApproval(origin, hostname) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { origin, hostname, resolve }; pendingApprovals[id] = { id, origin, hostname, resolve };
if (actionApi && typeof actionApi.openPopup === "function") { if (actionApi && typeof actionApi.openPopup === "function") {
actionApi.setPopup({ actionApi.setPopup({
@@ -243,10 +436,13 @@ function requestApproval(origin, hostname) {
// it is pinned here rather than read again at signing time — an address switch // it is pinned here rather than read again at signing time — an address switch
// between approval and signing must refuse, not sign from an account this // between approval and signing must refuse, not sign from an account this
// screen never named. // screen never named.
function requestTxApproval(origin, hostname, approvedTx, approvedFrom) { // `slot` is the transaction-approval slot its caller holds. Handing the
// approval's id to it is what makes retiring the approval free the slot.
function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
id,
origin, origin,
hostname, hostname,
approvedTx, approvedTx,
@@ -254,6 +450,7 @@ function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
resolve, resolve,
type: "tx", type: "tx",
}; };
if (slot) slot.approvalId = id;
openApprovalWindow(id); openApprovalWindow(id);
}); });
@@ -267,6 +464,7 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
id,
origin, origin,
hostname, hostname,
signParams, signParams,
@@ -585,31 +783,68 @@ async function handleRpc(method, params, origin) {
} }
if (method === "eth_sendTransaction") { if (method === "eth_sendTransaction") {
const s = await getState(); return await handleSendTransaction(params, origin);
const activeAddress = await getActiveAddress(); }
if (!activeAddress)
return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin); // Proxy safe read-only methods to the RPC node
const allowed = s.allowedSites[activeAddress] || []; if (PROXY_METHODS.includes(method)) {
if ( try {
!allowed.includes(hostname) && const result = await proxyRpc(method, params);
!connectedSites[origin + ":" + activeAddress] return { result };
) { } catch (e) {
return { error: { code: 4100, message: "Unauthorized" } }; return { error: { message: e.message } };
} }
}
const txParams = params?.[0] || {}; return { error: { message: "Unsupported method: " + method } };
if (namesAnotherAddress(txParams.from, activeAddress)) { }
return {
error: {
code: 4100,
message:
"This site asked to send from an address that is not the active one.",
},
};
}
// The body of eth_sendTransaction, from the connection check through to the
// user's decision. It takes the single transaction-approval slot once it knows
// it is going to populate a transaction, and holds it until the requesting
// page has its answer.
async function handleSendTransaction(params, origin) {
const s = await getState();
const activeAddress = await getActiveAddress();
if (!activeAddress) return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
const txParams = params?.[0] || {};
if (namesAnotherAddress(txParams.from, activeAddress)) {
return {
error: {
code: 4100,
message:
"This site asked to send from an address that is not the active one.",
},
};
}
// Everything above refuses without populating anything, so the slot is
// taken here rather than at the top of the handler: a page the wallet was
// never going to serve must not be able to hold the slot and make the
// connected site's own transaction fail as "already in progress". The
// reservation is atomic because nothing awaits between its test and its
// set, not because of where it sits.
const slot = reserveTxApprovalSlot();
if (!slot) {
return {
error: {
code: TX_APPROVAL_PENDING_CODE,
message: TX_APPROVAL_PENDING_MESSAGE,
},
};
}
try {
// Populate here, before any window opens, so that the transaction the // Populate here, before any window opens, so that the transaction the
// user is shown is a complete one and is the same object the signed // user is shown is a complete one and is the same object the signed
// artifact is checked against. A failure raises no approval at all and // artifact is checked against. A failure raises no approval at all and
@@ -644,22 +879,17 @@ async function handleRpc(method, params, origin) {
hostname, hostname,
approvedTx, approvedTx,
activeAddress, activeAddress,
slot,
); );
if (decision.error) return { error: decision.error }; if (decision.error) return { error: decision.error };
return { result: decision.txHash }; return { result: decision.txHash };
} finally {
// Retiring the approval has normally freed the slot already, through
// settleApproval(); this covers the paths that return before an
// approval exists at all, and frees nothing if another request has
// since taken the slot.
releaseTxApprovalSlot(slot);
} }
// Proxy safe read-only methods to the RPC node
if (PROXY_METHODS.includes(method)) {
try {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
return { error: { message: e.message } };
}
}
return { error: { message: "Unsupported method: " + method } };
} }
// Broadcast chainChanged to all tabs when the network is switched. // Broadcast chainChanged to all tabs when the network is switched.
@@ -694,15 +924,11 @@ async function broadcastAccountsChanged() {
// being signed and broadcast right now, and neither rejecting it to the // being signed and broadcast right now, and neither rejecting it to the
// page nor closing the window it is reporting into is survivable. // page nor closing the window it is reporting into is survivable.
for (const [id, approval] of Object.entries(pendingApprovals)) { for (const [id, approval] of Object.entries(pendingApprovals)) {
const rejection = const rejection = abandonedResult(
approval.type === "tx" || approval.type === "sign" approval,
? { APPROVAL_REJECTED_CODE,
error: { APPROVAL_REJECTED_MESSAGE,
code: 4001, );
message: "User rejected the request.",
},
}
: { approved: false, remember: false };
if (!settleApproval(id, rejection)) continue; if (!settleApproval(id, rejection)) continue;
if (approval.windowId) { if (approval.windowId) {
windowsApi.remove(approval.windowId, () => { windowsApi.remove(approval.windowId, () => {
@@ -831,21 +1057,19 @@ startBackgroundJobs();
// verify and broadcast it is waiting on, so a user closing an apparently-hung // 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. // window is an ordinary event with an attempt already in flight behind it.
// settleApproval() refuses those, which leaves the attempt to report its real // settleApproval() refuses those, which leaves the attempt to report its real
// outcome to the page. // outcome to the page — and the window is recorded as gone, so that an attempt
// which then fails retryably settles instead of waiting in a window that no
// longer exists.
if (windowsApi && windowsApi.onRemoved) { if (windowsApi && windowsApi.onRemoved) {
windowsApi.onRemoved.addListener((windowId) => { windowsApi.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) { for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue; if (approval.windowId !== windowId) continue;
const rejection = const rejection = abandonedResult(
approval.type === "tx" || approval.type === "sign" approval,
? { APPROVAL_REJECTED_CODE,
error: { APPROVAL_REJECTED_MESSAGE,
code: 4001, );
message: "User rejected the request.", if (!settleApproval(id, rejection)) approval.windowClosed = true;
},
}
: { approved: false, remember: false };
settleApproval(id, rejection);
} }
}); });
} }
@@ -865,9 +1089,26 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// keep fallback // keep fallback
} }
} }
handleRpc(msg.method, msg.params, trustedOrigin).then((response) => { handleRpc(msg.method, msg.params, trustedOrigin)
sendResponse(response); .then((response) => {
}); sendResponse(response);
})
.catch((err) => {
// Without this the page's window.ethereum.request() promise
// stays pending forever: no response is sent, the content
// script posts nothing back, and the dApp cannot tell the
// failure from a slow wallet. handleRpc does real work —
// state loads, provider calls, transaction population — so
// "it does not throw today" is not a property anyone is
// maintaining.
log.errorf("RPC request failed:", msg.method, err);
sendResponse({
error: {
code: INTERNAL_ERROR_CODE,
message: INTERNAL_ERROR_MESSAGE,
},
});
});
return true; return true;
} }
@@ -959,7 +1200,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ sendResponse({
error: outcome.error, error: outcome.error,
retryable: outcome.retryable, retryable: outcome.retryable,
stage: TX_STAGE_SIGN, stage: outcome.stage,
}); });
return false; return false;
} }
@@ -974,9 +1215,20 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return false; return false;
} }
// Which phase the last-resort .catch() below reports. Everything up to
// the broadcastTransaction() call provably never reached the network,
// so an escape from there must not tell the user it might have.
let lastResortStage = TX_STAGE_VERIFY;
(async () => { (async () => {
// The chain this attempt is on, read once. Verification below
// refuses an artifact signed for any other chain, and the nonce
// record is both consulted and written under this one, so a
// network switch part-way through cannot make the check and the
// record disagree about which chain the nonce was spent on.
let chainId;
try { try {
await loadState(); await loadState();
chainId = currentNetwork().chainId;
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
// An address switch between approval and signing refuses. The // An address switch between approval and signing refuses. The
// approval named one account; signing from whichever account // approval named one account; signing from whichever account
@@ -999,7 +1251,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
msg.rawSignedTx, msg.rawSignedTx,
approval.approvedTx, approval.approvedTx,
approval.approvedFrom, approval.approvedFrom,
currentNetwork().chainId, chainId,
); );
} catch (e) { } catch (e) {
// A signed transaction that is not the approved one is not // A signed transaction that is not the approved one is not
@@ -1019,14 +1271,40 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ sendResponse({
error: outcome.error, error: outcome.error,
retryable: outcome.retryable, retryable: outcome.retryable,
stage: TX_STAGE_VERIFY, stage: outcome.stage,
});
return;
}
// A nonce this worker has already broadcast for this address on
// this chain. The node is not asked: it has answered once already,
// and the wallet holding the receipt of that answer is what makes
// this failure one the user can be told did not reach the network.
// A nonce spent on another chain is not spent here — the chains
// count separately, and refusing across them would block ordinary
// use with a message that is not true.
const nonce = approvedNonce(approval.approvedTx);
const spent = broadcastNoncesFor(chainId, approval.approvedFrom);
if (nonce !== null && spent.has(nonce)) {
const outcome = describeTxFailure(TX_STAGE_NONCE, null);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ holdsClaim: true },
);
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: outcome.stage,
}); });
return; return;
} }
try { try {
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
lastResortStage = TX_STAGE_BROADCAST;
const tx = await provider.broadcastTransaction(msg.rawSignedTx); const tx = await provider.broadcastTransaction(msg.rawSignedTx);
if (nonce !== null) spent.add(nonce);
settleApproval( settleApproval(
msg.id, msg.id,
{ txHash: tx.hash }, { txHash: tx.hash },
@@ -1039,6 +1317,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// tell a transaction that never left from one already in the // tell a transaction that never left from one already in the
// mempool. The page has been given its outcome for this // mempool. The page has been given its outcome for this
// request; a second attempt would report a second one. // request; a second attempt would report a second one.
//
// Unless the node blamed the nonce, which is the one answer
// that says plainly it did not take the transaction:
// describeTxFailure() reclassifies that, and the stage it
// returns is the one reported.
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e); const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
settleApproval( settleApproval(
msg.id, msg.id,
@@ -1048,10 +1331,31 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ sendResponse({
error: outcome.error, error: outcome.error,
retryable: outcome.retryable, retryable: outcome.retryable,
stage: TX_STAGE_BROADCAST, stage: outcome.stage,
}); });
} }
})(); })().catch((e) => {
// Every statement above is inside a try, but a throw from one of
// the catch blocks escapes as an unhandled rejection and neither
// the popup nor the page is ever answered. Settle both, through
// the same chokepoint as every other retirement.
log.errorf("transaction approval response failed:", e);
settleApproval(
msg.id,
{
error: {
code: INTERNAL_ERROR_CODE,
message: INTERNAL_ERROR_MESSAGE,
},
},
{ holdsClaim: true },
);
sendResponse({
error: INTERNAL_ERROR_MESSAGE,
retryable: false,
stage: lastResortStage,
});
});
return true; return true;
} }
@@ -1135,7 +1439,25 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
} }
sendResponse({ error: errMsg, retryable }); sendResponse({ error: errMsg, retryable });
} }
})(); })().catch((e) => {
// Same shape as the transaction path: a throw out of the catch
// block above would leave the popup and the page both waiting.
log.errorf("sign approval response failed:", e);
settleApproval(
msg.id,
{
error: {
code: INTERNAL_ERROR_CODE,
message: INTERNAL_ERROR_MESSAGE,
},
},
{ holdsClaim: true },
);
sendResponse({
error: INTERNAL_ERROR_MESSAGE,
retryable: false,
});
});
return true; return true;
} }

View File

@@ -13,7 +13,7 @@ const {
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const { state, currentAddress, saveState } = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,
@@ -64,7 +64,7 @@ function show() {
}); });
$("address-line").dataset.full = addr.address; $("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line")); attachCopyHandlers($("address-line"));
const usdTotal = formatUsd(getAddressValueUsd(addr)); const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = usdTotal || " "; $("address-usd-total").innerHTML = usdTotal || " ";
const ensEl = $("address-ens"); const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element // ENS is now shown inside renderAddressHtml, hide the separate element

View File

@@ -18,11 +18,7 @@ const {
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const { state, currentAddress, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { const { formatUsd, getPrice } = require("../../shared/prices");
formatUsd,
getPrice,
getAddressValueUsd,
} = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,

View File

@@ -17,7 +17,7 @@ const {
addressHoldsFunds, addressHoldsFunds,
balanceLinesForAddress, balanceLinesForAddress,
} = require("./helpers"); } = require("./helpers");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const { walletHasRecoveryPhrase } = require("../../shared/wallet"); const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { const {
@@ -84,16 +84,16 @@ function recoveryPathText(wallet) {
// own: the rendered lines round to four decimals, so a sentence built from a // own: the rendered lines round to four decimals, so a sentence built from a
// rounded number would report "0.0000 ETH" for an address holding real money. // rounded number would report "0.0000 ETH" for an address holding real money.
// The lines below it carry the amounts, in the same format as Home and // The lines below it carry the amounts, in the same format as Home and
// AddressDetail, followed by the USD total when prices are known (null on // AddressDetail, followed by the USD total when there is one to give — no
// testnet and before the first price fetch, where the line is left off rather // total line at all on testnet or before the first price fetch, and no figure
// than printed as $0.00). // when every holding here is one with no price, since "$0.00" directly under
// "This address holds a balance." is a contradiction.
function balanceWarningHtml(addr) { function balanceWarningHtml(addr) {
if (!addressHoldsFunds(addr)) return " "; if (!addressHoldsFunds(addr)) return " ";
const usd = getAddressValueUsd(addr); const line = formatAddressTotal(getAddressValue(addr));
const total = const total = line
usd === null ? `<div class="text-xs text-muted mt-1">${line}</div>`
? "" : "";
: `<div class="text-xs text-muted mt-1">Total: ${formatUsd(usd)}</div>`;
return ( return (
`<p class="mb-1">This address holds a balance. Removing it does not ` + `<p class="mb-1">This address holds a balance. Removing it does not ` +
`move or spend anything; the balance stays at the address.</p>` + `move or spend anything; the balance stays at the address.</p>` +

View File

@@ -1,11 +1,7 @@
// Shared DOM helpers used by all views. // Shared DOM helpers used by all views.
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
const { const { formatUsd, getPrice } = require("../../shared/prices");
formatUsd,
getPrice,
getAddressValueUsd,
} = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { markViewRendered } = require("../viewRouter"); const { markViewRendered } = require("../viewRouter");

View File

@@ -28,8 +28,9 @@ const {
} = require("../../shared/walletDefects"); } = require("../../shared/walletDefects");
const { const {
formatUsd, formatUsd,
formatAddressTotal,
getPrice, getPrice,
getAddressValueUsd, getAddressValue,
} = require("../../shared/prices"); } = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
@@ -71,9 +72,7 @@ function renderTotalValue() {
el.textContent = ethStr + ethUsd; el.textContent = ethStr + ethUsd;
if (subEl) { if (subEl) {
const totalUsd = getAddressValueUsd(addr); subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || "&nbsp;";
subEl.innerHTML =
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : "&nbsp;";
} }
} }
@@ -257,8 +256,8 @@ function walletListHtml() {
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`; html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`; html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`; html += `</div>`;
const addrUsd = formatUsd(getAddressValueUsd(addr)); const addrTotal = formatAddressTotal(getAddressValue(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || "&nbsp;"}</div>`; html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`;
html += balanceLinesForAddress( html += balanceLinesForAddress(
addr, addr,
state.trackedTokens, state.trackedTokens,

View File

@@ -602,6 +602,12 @@ const TX_STAGE_BROADCAST = "broadcast";
// may yet succeed, so the one thing the popup must not say is "start again // may yet succeed, so the one thing the popup must not say is "start again
// from the site". // from the site".
const TX_STAGE_INFLIGHT = "inflight"; const TX_STAGE_INFLIGHT = "inflight";
// A transaction refused for a nonce that is already spoken for, either by the
// node's own answer or by this wallet's record of what it has broadcast. It is
// the one broadcast-stage failure that is not ambiguous: the transaction was
// not taken, so the user is told it did not reach the network and to send it
// again, rather than being warned that it might already be out there.
const TX_STAGE_NONCE = "nonce";
function errorText(err) { function errorText(err) {
if (typeof err === "string" && err !== "") return err; if (typeof err === "string" && err !== "") return err;
@@ -611,6 +617,59 @@ function errorText(err) {
return "The transaction could not be sent."; return "The transaction could not be sent.";
} }
// Every string a failure might carry its reason in. ethers reports the node's
// own words in `shortMessage`, but a JSON-RPC error it could not classify is
// nested under `error` or `info.error` with the node's message intact, and the
// classification below has to see that too.
function failureTexts(err) {
if (typeof err === "string") return [err];
if (!err || typeof err !== "object") return [];
const texts = [];
for (const text of [err.shortMessage, err.message, err.reason]) {
if (text) texts.push(String(text));
}
const nested = err.error || (err.info && err.info.error);
if (nested && nested.message) texts.push(String(nested.message));
return texts;
}
// What the Ethereum clients say when a transaction's nonce is already spoken
// for: either it is below the account's next nonce, or another transaction is
// sitting in the pool at that nonce and this one did not outbid it. Either way
// the node answered, and its answer was that it did not take this transaction.
//
// "already known" is deliberately absent. A node that says it knows the
// transaction has it, so that transaction did reach the network and the
// ambiguous broadcast wording is the correct one for it.
const NONCE_COLLISION_PATTERNS = [
/nonce too low/i,
/nonce has already been used/i,
/invalid nonce/i,
/oldnonce/i,
/replacement transaction underpriced/i,
/replacement fee too low/i,
];
// ethers' own classification of the same two conditions.
const NONCE_COLLISION_CODES = ["NONCE_EXPIRED", "REPLACEMENT_UNDERPRICED"];
// Whether a failed send is a nonce collision.
function isNonceCollision(err) {
if (!err) return false;
if (err.code && NONCE_COLLISION_CODES.includes(err.code)) return true;
return failureTexts(err).some((text) =>
NONCE_COLLISION_PATTERNS.some((pattern) => pattern.test(text)),
);
}
// What both the requesting page and the popup are told about a nonce
// collision. The node's own words ("nonce too low") are a fragment and are
// replaced rather than passed through: they are not a sentence, and they say
// less than the wallet knows.
const NONCE_COLLISION_MESSAGE =
"The transaction was not sent, because its nonce had already been used" +
" by another transaction.";
// What the background does with a pending transaction approval after a failed // What the background does with a pending transaction approval after a failed
// attempt: what it tells the popup, and whether the approval is spent // 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 // (resolved to the requesting page as an error and deleted) or left standing
@@ -627,12 +686,31 @@ function errorText(err) {
// that never left from one that is already in the mempool. The approval is // that never left from one that is already in the mempool. The approval is
// spent and the requesting page has been given its outcome; a second // spent and the requesting page has been given its outcome; a second
// attempt against it would report a second outcome for one request. // attempt against it would report a second outcome for one request.
// - nonce: terminal too, and the one case where the wallet does know the
// transaction never left. The approval carries a nonce that is spent, so
// the artifact signed against it can never be accepted and the user is told
// to send it again from the site.
//
// The stage comes back out because a broadcast failure the node blamed on the
// nonce is reclassified here; the caller reports the stage this returns rather
// than the one it passed in.
function describeTxFailure(stage, err) { function describeTxFailure(stage, err) {
if (
stage === TX_STAGE_NONCE ||
(stage === TX_STAGE_BROADCAST && isNonceCollision(err))
) {
return {
error: NONCE_COLLISION_MESSAGE,
retryable: false,
spendApproval: true,
stage: TX_STAGE_NONCE,
};
}
const error = errorText(err); const error = errorText(err);
const retryable = const retryable =
stage === TX_STAGE_SIGN || stage === TX_STAGE_SIGN ||
(stage === TX_STAGE_VERIFY && failureIsRetryable(err)); (stage === TX_STAGE_VERIFY && failureIsRetryable(err));
return { error, retryable, spendApproval: !retryable }; return { error, retryable, spendApproval: !retryable, stage };
} }
// What the popup shows and does after the background reports a failed signing // What the popup shows and does after the background reports a failed signing
@@ -642,14 +720,20 @@ function describeTxFailure(stage, err) {
// //
// A failed broadcast gets its own wording: the transaction may already be on // 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 // the network, so telling the user to start again from the site is exactly the
// wrong instruction. // wrong instruction. A nonce collision is the exception to that exception —
// the transaction demonstrably did not go out, and saying it might have would
// send the user hunting for a transaction that does not exist.
function describeSigningFailure(response, fallbackMessage) { function describeSigningFailure(response, fallbackMessage) {
let message = (response && response.error) || fallbackMessage; let message = (response && response.error) || fallbackMessage;
if (!/[.!?]$/.test(message)) message += "."; if (!/[.!?]$/.test(message)) message += ".";
const retryable = !!(response && response.retryable); const retryable = !!(response && response.retryable);
const stage = response && response.stage; const stage = response && response.stage;
if (!retryable) { if (!retryable) {
if (stage === TX_STAGE_BROADCAST) { if (stage === TX_STAGE_NONCE) {
message +=
" The transaction did not reach the network." +
" Please send it again from the site.";
} else if (stage === TX_STAGE_BROADCAST) {
message += message +=
" The transaction may still have reached the network." + " The transaction may still have reached the network." +
" Check the account before sending it again."; " Check the account before sending it again.";
@@ -675,9 +759,11 @@ module.exports = {
assertWithinCeilings, assertWithinCeilings,
sameAddress, sameAddress,
failureIsRetryable, failureIsRetryable,
isNonceCollision,
describeTxFailure, describeTxFailure,
describeSigningFailure, describeSigningFailure,
ApprovalMismatchError, ApprovalMismatchError,
NONCE_COLLISION_MESSAGE,
ALLOWED_TX_TYPES, ALLOWED_TX_TYPES,
SERIALIZED_FIELDS, SERIALIZED_FIELDS,
FORBIDDEN_FIELDS, FORBIDDEN_FIELDS,
@@ -686,6 +772,7 @@ module.exports = {
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT, TX_STAGE_INFLIGHT,
TX_STAGE_NONCE,
MAX_GAS_LIMIT, MAX_GAS_LIMIT,
MAX_FEE_PER_GAS, MAX_FEE_PER_GAS,
}; };

View File

@@ -55,42 +55,77 @@ function formatUsd(amount) {
); );
} }
function getAddressValueUsd(addr) { // What an address is worth, as { usd, partial }.
//
// Prices are fetched for the top 25 tokens only, so an address can hold real
// assets this code has no price for. Adding up the priced ones and calling the
// result the total states a number the holdings do not support: an address
// holding nothing but unpriced tokens comes out at $0.00, which tells the user
// their address is worth nothing when it may hold a great deal. Worth zero and
// worth an unknown amount are separate facts and get separate fields, the same
// way an absent holders_count is not a count of zero.
//
// usd: the value of the holdings a price is known for, or null when
// nothing is knowable at all — testnet, or before the first fetch.
// partial: the address also holds a token with no price, so usd is a floor
// and not the total.
//
// Render it through formatAddressTotal() rather than reading usd alone.
function getAddressValue(addr) {
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null; if (currentNetwork().isTestnet) return { usd: null, partial: false };
if (!prices.ETH) return null; if (!prices.ETH) return { usd: null, partial: false };
let total = 0; let usd = parseFloat(addr.balance || "0") * prices.ETH;
const ethBal = parseFloat(addr.balance || "0"); let partial = false;
total += ethBal * prices.ETH;
for (const token of addr.tokenBalances || []) { for (const token of addr.tokenBalances || []) {
const tokenBal = parseFloat(token.balance || "0"); const tokenBal = parseFloat(token.balance || "0");
if (tokenBal > 0 && prices[token.symbol]) { // A balance of zero is not a holding: it can neither add to the total
total += tokenBal * prices[token.symbol]; // nor make it incomplete.
if (!(tokenBal > 0)) continue;
if (prices[token.symbol]) {
usd += tokenBal * prices[token.symbol];
} else {
partial = true;
} }
} }
return total; return { usd, partial };
} }
function getWalletValueUsd(wallet) { // The same pair for a whole wallet, and for every wallet at once. One
const { currentNetwork } = require("./state"); // unpriced holding anywhere makes the sum a floor, so partial carries up.
if (currentNetwork().isTestnet) return null; function getWalletValue(wallet) {
if (!prices.ETH) return null; return sumValues(wallet.addresses.map(getAddressValue));
let total = 0;
for (const addr of wallet.addresses) {
total += getAddressValueUsd(addr);
}
return total;
} }
function getTotalValueUsd(wallets) { function getTotalValue(wallets) {
const { currentNetwork } = require("./state"); return sumValues(wallets.map(getWalletValue));
if (currentNetwork().isTestnet) return null; }
if (!prices.ETH) return null;
let total = 0; function sumValues(values) {
for (const wallet of wallets) { let usd = null;
total += getWalletValueUsd(wallet); let partial = false;
for (const value of values) {
if (value.usd === null) continue;
usd = (usd === null ? 0 : usd) + value.usd;
partial = partial || value.partial;
} }
return total; return { usd, partial };
}
// The one rendering of an address total, so no screen says it differently.
//
// A partial total is shown and named as partial: the figure is the ETH and
// priced tokens the user does hold, which is worth having, and suppressing it
// would throw away a number that is correct as far as it goes. What is never
// shown is a figure covering no holdings at all — the $0.00 sum of an empty
// set beside a list of tokens is the bug this replaces.
function formatAddressTotal(value) {
if (!value || value.usd === null) return "";
if (!value.partial) return "Total: " + formatUsd(value.usd);
if (value.usd > 0) {
return "Total: " + formatUsd(value.usd) + " plus unpriced tokens";
}
return "Total: unpriced tokens only";
} }
module.exports = { module.exports = {
@@ -99,7 +134,8 @@ module.exports = {
clearPrices, clearPrices,
getPrice, getPrice,
formatUsd, formatUsd,
getAddressValueUsd, formatAddressTotal,
getWalletValueUsd, getAddressValue,
getTotalValueUsd, getWalletValue,
getTotalValue,
}; };

238
tests/addressValue.test.js Normal file
View File

@@ -0,0 +1,238 @@
// The USD total of an address that holds something this build cannot price
// (issue #261).
//
// Prices exist for the top 25 tokens only, so an address can hold real assets
// with no price attached. Summing what is priced and printing the result as
// the total says "$0.00" for an address holding nothing but unpriced tokens —
// worth-nothing and worth-an-unknown-amount collapsed into one number, in the
// direction that matters. The two are separate facts here, the same way an
// absent holders_count is not a count of zero.
//
// The value and its rendering are asserted directly, and then through the two
// call sites that return their markup as a string: the wallet list on Home and
// the balance warning on the address-removal confirmation. AddressDetail and
// the Home summary line render into the DOM and are covered by tests/e2e.
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const {
prices,
clearPrices,
getAddressValue,
getWalletValue,
getTotalValue,
formatAddressTotal,
} = require("../src/shared/prices");
const { state } = require("../src/shared/state");
const { walletListHtml } = require("../src/popup/views/home");
const { balanceWarningHtml } = require("../src/popup/views/deleteAddress");
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const NOVEL = "0x1111111111111111111111111111111111111111";
// No ETH, and a token no price is known for. The case the user is told is
// worth $0.00 today.
const UNPRICED_ONLY = {
address: "0x" + "a".repeat(40),
balance: "0",
tokenBalances: [{ address: NOVEL, symbol: "NOVEL", balance: "5000.0" }],
};
// Nothing at all: the address really is worth zero.
const EMPTY = {
address: "0x" + "b".repeat(40),
balance: "0",
tokenBalances: [],
};
// Every holding priced.
const FULLY_PRICED = {
address: "0x" + "c".repeat(40),
balance: "1.5",
tokenBalances: [{ address: USDC, symbol: "USDC", balance: "2500.0" }],
};
// Part priced, part not: 1.5 ETH plus a token with no price.
const PARTLY_PRICED = {
address: "0x" + "d".repeat(40),
balance: "1.5",
tokenBalances: [{ address: NOVEL, symbol: "NOVEL", balance: "5000.0" }],
};
beforeEach(() => {
clearPrices();
prices.ETH = 2000;
prices.USDC = 1;
state.wallets = [];
state.trackedTokens = [];
state.showZeroBalanceTokens = false;
state.activeAddress = null;
});
afterEach(() => {
clearPrices();
});
// The total line only, in each of the two markup-returning call sites. The
// ETH balance line above it legitimately reads $0.00 for an address with no
// ETH, so the assertions have to name the line under test.
function walletListTotal(addr) {
state.wallets = [{ name: "Wallet 1", type: "hd", addresses: [addr] }];
const match = walletListHtml().match(/min-h-\[1rem\]">([^<]*)</);
return match && match[1];
}
function removalWarningTotal(addr) {
const match = balanceWarningHtml(addr).match(/mt-1">([^<]*)</);
return match && match[1];
}
describe("the value of an address, and whether it is the whole value", () => {
test("an address holding only unpriced tokens has an incomplete value", () => {
expect(getAddressValue(UNPRICED_ONLY)).toEqual({
usd: 0,
partial: true,
});
});
test("an address holding nothing is complete, and zero", () => {
expect(getAddressValue(EMPTY)).toEqual({ usd: 0, partial: false });
});
test("a fully priced address is complete, and unchanged", () => {
expect(getAddressValue(FULLY_PRICED)).toEqual({
usd: 5500,
partial: false,
});
});
test("a partly priced address keeps the part it can price", () => {
expect(getAddressValue(PARTLY_PRICED)).toEqual({
usd: 3000,
partial: true,
});
});
// A token balance of zero is not a holding, so it cannot make the total
// incomplete: an address with a spent-out unpriced token is worth zero.
test("a zero balance in an unpriced token leaves the value complete", () => {
const addr = {
address: "0x1",
balance: "0",
tokenBalances: [{ address: NOVEL, symbol: "NOVEL", balance: "0" }],
};
expect(getAddressValue(addr)).toEqual({ usd: 0, partial: false });
});
// Before the first price fetch, and on testnet, nothing is knowable: that
// is a third state, and it stays distinct from both of the others.
test("no prices at all means no value, not an incomplete one", () => {
clearPrices();
expect(getAddressValue(FULLY_PRICED)).toEqual({
usd: null,
partial: false,
});
});
test("one unpriced holding makes a wallet and the grand total partial", () => {
const wallet = { addresses: [FULLY_PRICED, UNPRICED_ONLY] };
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: true });
expect(getTotalValue([wallet])).toEqual({ usd: 5500, partial: true });
});
test("a wallet of fully priced addresses stays complete", () => {
const wallet = { addresses: [FULLY_PRICED, EMPTY] };
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: false });
});
});
describe("how that value is written on screen", () => {
test("a complete total is the figure", () => {
expect(formatAddressTotal(getAddressValue(FULLY_PRICED))).toBe(
"Total: $5,500.00",
);
});
test("an address worth zero says so", () => {
expect(formatAddressTotal(getAddressValue(EMPTY))).toBe("Total: $0.00");
});
// The figure is still worth having — it is the ETH the user does hold —
// but on its own it understates the address, so it is named as partial.
test("a partly priced total is given, and marked as partial", () => {
expect(formatAddressTotal(getAddressValue(PARTLY_PRICED))).toBe(
"Total: $3,000.00 plus unpriced tokens",
);
});
// Nothing priced is held, so there is no figure to give: printing the
// $0.00 sum of an empty set is the bug.
test("a total with nothing priced in it gives no figure", () => {
const line = formatAddressTotal(getAddressValue(UNPRICED_ONLY));
expect(line).toBe("Total: unpriced tokens only");
expect(line).not.toContain("$");
});
test("an unknown value is written as nothing at all", () => {
clearPrices();
expect(formatAddressTotal(getAddressValue(FULLY_PRICED))).toBe("");
});
});
describe("the wallet list on Home", () => {
test("an address holding only unpriced tokens is not totalled at $0.00", () => {
expect(walletListTotal(UNPRICED_ONLY)).toBe(
"Total: unpriced tokens only",
);
});
test("an address holding nothing is still totalled at $0.00", () => {
expect(walletListTotal(EMPTY)).toBe("Total: $0.00");
});
test("a fully priced address shows its total", () => {
expect(walletListTotal(FULLY_PRICED)).toBe("Total: $5,500.00");
});
test("a partly priced address shows the priced part, marked partial", () => {
expect(walletListTotal(PARTLY_PRICED)).toBe(
"Total: $3,000.00 plus unpriced tokens",
);
});
test("an address whose value is unknown keeps its blank line", () => {
clearPrices();
expect(walletListTotal(FULLY_PRICED)).toBe("&nbsp;");
});
});
describe("the balance warning on the address-removal confirmation", () => {
// "This address holds a balance." followed by "Total: $0.00" is a flat
// contradiction, on the one screen whose job is to warn.
test("an address holding only unpriced tokens is not totalled at $0.00", () => {
expect(balanceWarningHtml(UNPRICED_ONLY)).toContain(
"This address holds a balance.",
);
expect(removalWarningTotal(UNPRICED_ONLY)).toBe(
"Total: unpriced tokens only",
);
});
test("a fully priced address still shows its total", () => {
expect(removalWarningTotal(FULLY_PRICED)).toBe("Total: $5,500.00");
});
test("a partly priced address shows the priced part, marked partial", () => {
expect(removalWarningTotal(PARTLY_PRICED)).toBe(
"Total: $3,000.00 plus unpriced tokens",
);
});
test("no total line is written when the value is unknown", () => {
clearPrices();
expect(removalWarningTotal(FULLY_PRICED)).toBe(null);
});
});

View File

@@ -14,8 +14,10 @@ const {
assertWithinCeilings, assertWithinCeilings,
sameAddress, sameAddress,
failureIsRetryable, failureIsRetryable,
isNonceCollision,
describeTxFailure, describeTxFailure,
describeSigningFailure, describeSigningFailure,
NONCE_COLLISION_MESSAGE,
ALLOWED_TX_TYPES, ALLOWED_TX_TYPES,
SERIALIZED_FIELDS, SERIALIZED_FIELDS,
FORBIDDEN_FIELDS, FORBIDDEN_FIELDS,
@@ -23,6 +25,7 @@ const {
TX_STAGE_SIGN, TX_STAGE_SIGN,
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
TX_STAGE_NONCE,
MAX_GAS_LIMIT, MAX_GAS_LIMIT,
MAX_FEE_PER_GAS, MAX_FEE_PER_GAS,
} = require("../src/shared/approvalVerify"); } = require("../src/shared/approvalVerify");
@@ -1191,7 +1194,6 @@ describe("signing failure and retry", () => {
"already known", "already known",
"timeout of 30000ms exceeded", "timeout of 30000ms exceeded",
"could not coalesce error", "could not coalesce error",
"replacement transaction underpriced",
]) { ]) {
const outcome = describeTxFailure( const outcome = describeTxFailure(
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
@@ -1199,10 +1201,76 @@ describe("signing failure and retry", () => {
); );
expect(outcome.retryable).toBe(false); expect(outcome.retryable).toBe(false);
expect(outcome.spendApproval).toBe(true); expect(outcome.spendApproval).toBe(true);
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
expect(outcome.error).toBe(message); expect(outcome.error).toBe(message);
} }
}); });
// The one broadcast failure that is not ambiguous. The node answered, and
// its answer was that the nonce was already spoken for, so this
// transaction is not in a mempool anywhere.
test("a nonce the node refused is classified however it was worded", () => {
for (const err of [
new Error("nonce too low"),
new Error("replacement transaction underpriced"),
Object.assign(new Error("could not coalesce error"), {
code: "NONCE_EXPIRED",
}),
Object.assign(new Error("could not coalesce error"), {
code: "REPLACEMENT_UNDERPRICED",
}),
// The shape ethers hands up when it could not classify the node's
// error itself: the node's own words are nested underneath.
Object.assign(new Error("could not coalesce error"), {
info: { error: { code: -32000, message: "OldNonce" } },
}),
]) {
const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
expect(
describeSigningFailure(
outcome,
"The transaction could not be sent.",
).message,
).toMatch(/did not reach the network/);
expect(outcome.retryable).toBe(false);
expect(outcome.spendApproval).toBe(true);
expect(outcome.error).toBe(NONCE_COLLISION_MESSAGE);
expect(outcome.stage).toBe(TX_STAGE_NONCE);
expect(isNonceCollision(err)).toBe(true);
}
});
// A node that says it knows the transaction has it, so it did reach the
// network and the ambiguous wording is the correct one.
test("already known is not a nonce collision", () => {
const err = new Error("already known");
const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
expect(
describeSigningFailure(
outcome,
"The transaction could not be sent.",
).message,
).toMatch(/may still have reached the network/);
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
expect(isNonceCollision(err)).toBe(false);
});
test("a nonce collision says the transaction did not reach the network", () => {
const outcome = describeTxFailure(
TX_STAGE_BROADCAST,
new Error("nonce too low"),
);
const copy = describeSigningFailure(
outcome,
"The transaction could not be sent.",
);
expect(copy.retryable).toBe(false);
expect(copy.message).toMatch(/did not reach the network/);
expect(copy.message).not.toMatch(/may still have reached the network/);
expect(copy.message).toMatch(/Please send it again from the site\.$/);
expect(copy.message).toMatch(/^[A-Z].*\.$/);
});
test("a failed broadcast does not tell the user to send it again", () => { test("a failed broadcast does not tell the user to send it again", () => {
const outcome = describeSigningFailure( const outcome = describeSigningFailure(
{ {

View File

@@ -20,6 +20,11 @@
const { Network, Wallet } = require("ethers"); const { Network, Wallet } = require("ethers");
// The real formatter the approval screen renders failures through. Bound here,
// before any jest.doMock() of the module, so the copy assertions below check
// what the user is actually shown.
const { describeSigningFailure } = require("../src/shared/approvalVerify");
const SIGNER_KEY = const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const OTHER_KEY = const OTHER_KEY =
@@ -30,6 +35,8 @@ const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ORIGIN = "https://dapp.example"; const ORIGIN = "https://dapp.example";
const HOSTNAME = "dapp.example"; const HOSTNAME = "dapp.example";
// A page the wallet has never been connected to, whose requests are refused.
const UNCONNECTED_ORIGIN = "https://stranger.example";
const EXT_URL = "chrome-extension://autistmask/"; const EXT_URL = "chrome-extension://autistmask/";
// What the dApp asks for: no nonce, no gas, no fees. This is the shape that // What the dApp asks for: no nonce, no gas, no fees. This is the shape that
@@ -51,10 +58,16 @@ const MESSAGE = "0x48656c6c6f204175746973744d61736b";
// The transaction the background populates and the approval screen displays. // The transaction the background populates and the approval screen displays.
// The nonce is a parameter because the duplicate case turns on two artifacts // The nonce is a parameter because the duplicate case turns on two artifacts
// differing in a field the dApp fixed nothing for. // differing in a field the dApp fixed nothing for.
function populated(nonce) { // The two chains the tests switch between, as both forms the code uses: the
// hex chain id the wallet's network record carries, and the number the node
// and the signed artifact carry.
const MAINNET = { hex: "0x1", num: 1 };
const SEPOLIA = { hex: "0xaa36a7", num: 11155111 };
function populated(nonce, chainId) {
return { return {
type: 2, type: 2,
chainId: 1, chainId: chainId || MAINNET.num,
nonce, nonce,
gasLimit: 100000n, gasLimit: 100000n,
maxFeePerGas: 2000000000n, maxFeePerGas: 2000000000n,
@@ -65,17 +78,17 @@ function populated(nonce) {
}; };
} }
function signedAtNonce(nonce, withWallet) { function signedAtNonce(nonce, withWallet, chainId) {
return (withWallet || signer).signTransaction(populated(nonce)); return (withWallet || signer).signTransaction(populated(nonce, chainId));
} }
// The node the background populates against. Its answers are the numbers the // The node the background populates against. Its answers are the numbers the
// approval screen shows, so they are also the numbers every artifact below is // approval screen shows, so they are also the numbers every artifact below is
// signed at. // signed at.
function fakeProvider(broadcastTransaction, overrides) { function fakeProvider(broadcastTransaction, overrides, chainId) {
return { return {
broadcastTransaction, broadcastTransaction,
getNetwork: async () => Network.from(1), getNetwork: async () => Network.from(chainId || MAINNET.num),
getTransactionCount: async () => NONCE, getTransactionCount: async () => NONCE,
estimateGas: async () => 100000n, estimateGas: async () => 100000n,
getFeeData: async () => ({ getFeeData: async () => ({
@@ -111,14 +124,20 @@ function loadBackground(options) {
const broadcastTransaction = jest.fn(); const broadcastTransaction = jest.fn();
const loadState = jest.fn(opts.loadState || (async () => {})); const loadState = jest.fn(opts.loadState || (async () => {}));
// The network the wallet is on, which the tests switch under a pending
// approval. The node the transaction is populated against is on the same
// one, as it would be: switching networks switches the RPC endpoint too.
let chain = MAINNET;
jest.doMock("../src/shared/state", () => ({ jest.doMock("../src/shared/state", () => ({
state: { rpcUrl: "https://rpc.invalid", wallets: [] }, state: { rpcUrl: "https://rpc.invalid", wallets: [] },
loadState, loadState,
saveState: jest.fn(async () => {}), saveState: jest.fn(async () => {}),
currentNetwork: () => ({ chainId: "0x1" }), currentNetwork: () => ({ chainId: chain.hex }),
})); }));
jest.doMock("../src/shared/balances", () => ({ jest.doMock("../src/shared/balances", () => ({
getProvider: () => fakeProvider(broadcastTransaction, opts.provider), getProvider: () =>
fakeProvider(broadcastTransaction, opts.provider, chain.num),
refreshBalances: jest.fn(async () => {}), refreshBalances: jest.fn(async () => {}),
})); }));
jest.doMock("../src/shared/phishingDomains", () => ({ jest.doMock("../src/shared/phishingDomains", () => ({
@@ -133,6 +152,14 @@ function loadBackground(options) {
ensureRecurringAlarms: jest.fn(async () => {}), ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(), registerAlarmHandlers: jest.fn(),
})); }));
// The real verification module, except where a test replaces one export
// with a throw to drive the handler's own error handling into failing.
if (opts.approvalVerify) {
jest.doMock("../src/shared/approvalVerify", () => ({
...jest.requireActual("../src/shared/approvalVerify"),
...opts.approvalVerify,
}));
}
const persisted = { const persisted = {
wallets: [ wallets: [
@@ -152,7 +179,10 @@ function loadBackground(options) {
global.chrome = { global.chrome = {
storage: { storage: {
local: { local: {
get: jest.fn(async () => ({ autistmask: persisted })), get: jest.fn(
opts.storageGet ||
(async () => ({ autistmask: persisted })),
),
set: jest.fn(async () => {}), set: jest.fn(async () => {}),
}, },
}, },
@@ -170,7 +200,9 @@ function loadBackground(options) {
getLastFocused: (cb) => cb(null), getLastFocused: (cb) => cb(null),
create: (options2, cb) => { create: (options2, cb) => {
created.push(options2); created.push(options2);
cb({ id: created.length }); // A browser that answers with no window at all. The approval
// then has no window it can ever be answered in.
cb(opts.noWindow ? undefined : { id: created.length });
}, },
remove: (id, cb) => { remove: (id, cb) => {
removed.push(id); removed.push(id);
@@ -204,8 +236,12 @@ function loadBackground(options) {
// Raise a pending transaction approval the way a dApp does, and dig the // Raise a pending transaction approval the way a dApp does, and dig the
// approval id back out of the popup URL the background opened. // approval id back out of the popup URL the background opened.
function requestTx(txParams) { function requestTx(txParams, origin) {
let rpcResult = null; let rpcResult = null;
// The window this request opens, if it opens one. A request refused
// before an approval is raised opens none, and the window belonging to
// some other request must not be handed back as this one's.
const windowIndex = created.length;
const sendResponse = jest.fn((r) => { const sendResponse = jest.fn((r) => {
rpcResult = r; rpcResult = r;
}); });
@@ -215,11 +251,16 @@ function loadBackground(options) {
method: "eth_sendTransaction", method: "eth_sendTransaction",
params: [txParams || TX_PARAMS], params: [txParams || TX_PARAMS],
}, },
{ origin: ORIGIN }, { origin: origin || ORIGIN },
sendResponse, sendResponse,
); );
return { return {
id: () => new URL(created[0].url).searchParams.get("approval"), id: () =>
created.length > windowIndex
? new URL(created[windowIndex].url).searchParams.get(
"approval",
)
: null,
result: () => rpcResult, result: () => rpcResult,
}; };
} }
@@ -269,6 +310,10 @@ function loadBackground(options) {
setActiveAddress: (address) => { setActiveAddress: (address) => {
persisted.activeAddress = address; persisted.activeAddress = address;
}, },
// The user switching network in the toolbar popup.
setNetwork: (network) => {
chain = network;
},
fromPopup: { url: EXT_URL + "src/popup/index.html" }, fromPopup: { url: EXT_URL + "src/popup/index.html" },
}; };
} }
@@ -280,6 +325,15 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve(); for (let i = 0; i < 50; i++) await Promise.resolve();
} }
// settle() only drains microtasks. A handler whose last-resort .catch() has to
// run after a macrotask boundary needs those turns too, so the assertion that
// the page WAS answered is what reports a regression rather than a timeout.
async function settleIncludingRejections() {
await settle();
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
}
afterEach(() => { afterEach(() => {
delete global.chrome; delete global.chrome;
jest.resetModules(); jest.resetModules();
@@ -444,6 +498,318 @@ describe("one approval, one broadcast", () => {
}); });
}); });
// Populating the transaction before the approval window opens is what makes
// the displayed object the verified object. It also fixes the nonce before the
// user has answered anything: two requests populated concurrently take the
// same nonce from a node that has seen neither of them broadcast, and the
// second can then never be sent, because the only way to give it a fresh nonce
// is to populate it again after the user has read the old one off the screen.
// So the second request is refused while the first is unanswered.
describe("one transaction approval at a time", () => {
test("a second eth_sendTransaction while one is pending is refused before it takes a nonce", async () => {
const getTransactionCount = jest.fn(async () => NONCE);
const bg = loadBackground({ provider: { getTransactionCount } });
const first = bg.requestTx();
await settle();
expect(first.id()).toBeTruthy();
expect(getTransactionCount).toHaveBeenCalledTimes(1);
const second = bg.requestTx();
await settle();
expect(second.result()).toEqual({
error: {
code: -32002,
message: expect.stringMatching(
/one transaction at a time.+already in progress/,
),
},
});
// Where the refusal happened matters as much as that it happened: no
// second window, and the node was never asked for a second nonce.
expect(bg.created).toHaveLength(1);
expect(getTransactionCount).toHaveBeenCalledTimes(1);
// The refusal leaves the pending approval untouched, and it still
// sends.
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: first.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(first.result()).toEqual({ result: "0xfeed" });
});
// The slot is only defensible if the wallet was going to raise an approval
// anyway. Taken any earlier, a request the wallet refuses outright still
// holds it, and any page at all — connected or not — can deny the user's
// own transactions for as long as it keeps asking.
test("a request the wallet refuses does not take the slot from the connected site", async () => {
const bg = loadBackground();
// Both delivered before either reaches its first suspension point,
// which is the interleaving the slot exists for.
const stranger = bg.requestTx(TX_PARAMS, UNCONNECTED_ORIGIN);
const connected = bg.requestTx();
await settle();
expect(stranger.result()).toEqual({
error: { code: 4100, message: "Unauthorized" },
});
// The connected site's transaction was raised, not refused as one the
// user already has in progress.
expect(connected.result()).toBeNull();
expect(connected.id()).toBeTruthy();
expect(bg.created).toHaveLength(1);
});
// The user closes an approval window that looks hung while the attempt
// behind it is still running, and that attempt then fails in a way that
// would normally leave the approval standing for a retry. There is no
// window left to retry in, so leaving it standing answers the requesting
// page never — and holds the slot for the life of the worker with it.
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
const stalled = deferred();
const bg = loadBackground({
loadState: async () => {
await stalled.promise;
throw new Error("The wallet data could not be read.");
},
});
const first = bg.requestTx();
await settle();
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: first.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
// The attempt owns the approval, so closing the window does not settle
// it: the attempt may yet broadcast, and it is the one that reports.
bg.closeWindow(1);
await settle();
expect(first.result()).toBeNull();
stalled.resolve();
await settle();
expect(first.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
const second = bg.requestTx();
await settle();
expect(second.result()).toBeNull();
expect(second.id()).toBeTruthy();
expect(bg.created).toHaveLength(2);
});
// An approval with no window is one nothing can ever answer.
test("a request whose approval window cannot be opened is answered rather than left waiting", async () => {
const bg = loadBackground({ noWindow: true });
const first = bg.requestTx();
await settle();
expect(first.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(
/could not open its approval window/,
),
},
});
// And it did not take the slot with it.
const second = bg.requestTx();
await settle();
expect(second.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(
/could not open its approval window/,
),
},
});
});
test("an answered approval frees the next request", async () => {
const bg = loadBackground();
const first = bg.requestTx();
await settle();
// The user closes the approval window, which rejects it.
bg.closeWindow(1);
await settle();
expect(first.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
const second = bg.requestTx();
await settle();
expect(second.id()).toBeTruthy();
expect(bg.created).toHaveLength(2);
});
test("a signature request is not held up by a pending transaction", async () => {
const bg = loadBackground();
bg.requestTx();
await settle();
// A signature consumes no nonce, so it has nothing to collide with.
const signing = bg.requestSign();
await settle();
expect(signing.id()).toBeTruthy();
expect(signing.result()).toBeNull();
expect(bg.created).toHaveLength(2);
});
});
// A nonce collision found before the transaction reaches the network is the
// one send failure the wallet can speak about with certainty. The user is told
// it did not go out and to send it again, rather than being warned it might
// already be on the chain — which would send them looking for a transaction
// that does not exist, and stop them retrying the one that never went.
describe("a nonce collision is reported as a transaction that did not go out", () => {
test("a broadcast the node refused for the nonce is not reported as possibly sent", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
bg.broadcastTransaction.mockRejectedValue(
Object.assign(new Error("nonce too low"), {
code: "NONCE_EXPIRED",
}),
);
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(answer.sendResponse).toHaveBeenCalledWith({
error: expect.stringMatching(/nonce had already been used/),
retryable: false,
stage: "nonce",
});
expect(pending.result()).toEqual({
error: {
message: expect.stringMatching(
/transaction was not sent, because its nonce/,
),
},
});
});
test("a nonce this wallet already broadcast is refused without asking the node again", async () => {
const bg = loadBackground();
const first = bg.requestTx();
await settle();
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: first.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(first.result()).toEqual({ result: "0xfeed" });
// The stubbed node still reports NONCE as the next nonce — a pending
// count that lags a broadcast the node has already taken — so this
// second approval is populated at a nonce this worker has spent.
const second = bg.requestTx();
await settle();
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: second.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
expect(answer.sendResponse).toHaveBeenCalledWith({
error: expect.stringMatching(/nonce had already been used/),
retryable: false,
stage: "nonce",
});
expect(second.result()).toEqual({
error: {
message: expect.stringMatching(/nonce had already been used/),
},
});
});
// Nonce spaces are per chain, and the wallet switches networks. A nonce
// this wallet spent on one chain says nothing about the same nonce on
// another — and low nonces overlap across chains as a matter of course, so
// a record that ignored the chain would refuse ordinary transactions,
// permanently and with a message that is not true of them.
test("a nonce spent on one chain is not refused on another", async () => {
const bg = loadBackground();
const first = bg.requestTx();
await settle();
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: first.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(first.result()).toEqual({ result: "0xfeed" });
// The user switches network. On this chain the address has sent
// nothing, so the node populates the next transaction at the same
// nonce — correctly.
bg.setNetwork(SEPOLIA);
const second = bg.requestTx();
await settle();
bg.broadcastTransaction.mockResolvedValue({ hash: "0xbeef" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: second.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE, undefined, SEPOLIA.num),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(2);
expect(second.result()).toEqual({ result: "0xbeef" });
});
});
// The approval carries the transaction the user was shown and the address it // The approval carries the transaction the user was shown and the address it
// was raised for, and the artifact is checked against both. Every case here is // was raised for, and the artifact is checked against both. Every case here is
// one the old comparison — against the dApp's request, for the address that is // one the old comparison — against the dApp's request, for the address that is
@@ -1034,6 +1400,200 @@ describe("a claimed approval outlives every other retirement path", () => {
}); });
}); });
// A handler that throws must still answer. `sendResponse` is the only thing
// that settles the page's window.ethereum.request() promise, so a throw that
// escapes a handler leaves that promise pending forever — no error, no
// timeout, indistinguishable from a slow wallet. Each case below drives a real
// throw out of a handler rather than asserting the catch block exists.
describe("a handler that throws still settles the page", () => {
const INTERNAL_ERROR = {
code: -32603,
message:
"AutistMask could not complete this request because of an internal error.",
};
let errorLog;
beforeEach(() => {
errorLog = jest.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
errorLog.mockRestore();
});
// getState() awaits extension storage unguarded, and every read path in
// handleRpc goes through it. A storage read that rejects is the whole
// failure — no hook in the handler itself.
test("a rejected handleRpc rejects the page instead of hanging it", async () => {
const bg = loadBackground({
storageGet: async () => {
throw new Error("storage unavailable");
},
});
const answer = bg.send(
{ type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
{ origin: ORIGIN },
);
await settleIncludingRejections();
// The channel is held open for the async answer, and the answer
// arrives.
expect(answer.kept).toBe(true);
expect(answer.sendResponse).toHaveBeenCalledWith({
error: INTERNAL_ERROR,
});
// Not swallowed: the throw is on the background console, which is how
// this class gets caught in future.
expect(errorLog).toHaveBeenCalledWith(
"[AutistMask]",
"RPC request failed:",
"eth_accounts",
expect.objectContaining({ message: "storage unavailable" }),
);
});
// The transaction response handler wraps every statement in a try, so what
// escapes it is a throw from inside one of its catch blocks. Here the
// failure classifier itself throws while classifying a real verification
// failure — the approval is left claimed, so nothing else can settle it.
// The escape happens before broadcastTransaction() is reached, so the
// reported stage must be the one that says the transaction is gone.
test("a throw while verifying a transaction settles both the page and the popup", async () => {
const bg = loadBackground({
approvalVerify: {
describeTxFailure: () => {
throw new Error("classifier broke");
},
},
});
const pending = bg.requestTx();
await settle();
const id = pending.id();
// A real verification failure: the artifact is signed at a nonce the
// approval never displayed.
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(NONCE + 1),
},
{ url: bg.fromPopup.url },
);
await settleIncludingRejections();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
expect(answer.sendResponse).toHaveBeenCalledWith({
error: INTERNAL_ERROR.message,
retryable: false,
// Nothing was broadcast, so the popup must say the request is gone
// rather than that it may still have reached the network.
stage: "verify",
});
expect(errorLog).toHaveBeenCalledWith(
"[AutistMask]",
"transaction approval response failed:",
expect.objectContaining({ message: "classifier broke" }),
);
// The copy the user actually reads, from the popup's own formatter.
expect(
describeSigningFailure(answer.sendResponse.mock.calls[0][0], "")
.message,
).toBe(
INTERNAL_ERROR.message +
" This request can no longer be signed." +
" Please start it again from the site.",
);
});
// The other side of the same local: once broadcastTransaction() has been
// entered the wallet genuinely cannot tell whether the node took the
// transaction, and the copy that warns about a second send is correct.
test("a throw while handling a failed broadcast reports the broadcast stage", async () => {
const bg = loadBackground({
approvalVerify: {
describeTxFailure: () => {
throw new Error("classifier broke");
},
},
});
bg.broadcastTransaction.mockRejectedValue(new Error("node refused"));
const pending = bg.requestTx();
await settle();
const id = pending.id();
// The approved artifact, so verification passes and the failure
// happens at the broadcast.
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settleIncludingRejections();
expect(bg.broadcastTransaction).toHaveBeenCalled();
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
expect(answer.sendResponse).toHaveBeenCalledWith({
error: INTERNAL_ERROR.message,
retryable: false,
stage: "broadcast",
});
expect(
describeSigningFailure(answer.sendResponse.mock.calls[0][0], "")
.message,
).toBe(
INTERNAL_ERROR.message +
" The transaction may still have reached the network." +
" Check the account before sending it again.",
);
});
test("a throw while handling a failed signature settles both the page and the popup", async () => {
const bg = loadBackground({
approvalVerify: {
failureIsRetryable: () => {
throw new Error("classifier broke");
},
},
});
const pending = bg.requestSign();
await settle();
// A real verification failure: the active address moved after the
// approval was raised.
bg.setActiveAddress(other.address);
const answer = bg.send(
{
type: "AUTISTMASK_SIGN_RESPONSE",
id: pending.id(),
approved: true,
signature: await signer.signMessage(
Buffer.from(MESSAGE.slice(2), "hex"),
),
},
{ url: bg.fromPopup.url },
);
await settleIncludingRejections();
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
expect(answer.sendResponse).toHaveBeenCalledWith({
error: INTERNAL_ERROR.message,
retryable: false,
});
expect(errorLog).toHaveBeenCalledWith(
"[AutistMask]",
"sign approval response failed:",
expect.objectContaining({ message: "classifier broke" }),
);
});
});
describe("popup-only messages", () => { describe("popup-only messages", () => {
test("a page sender cannot answer an approval", async () => { test("a page sender cannot answer an approval", async () => {
const bg = loadBackground(); const bg = loadBackground();

View File

@@ -150,7 +150,7 @@ describe("the balance warning on the removal confirmation", () => {
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00"); expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
}); });
// getAddressValueUsd() returns null on testnet and before the first // getAddressValue() reports no value on testnet and before the first
// price fetch. A "Total: $0.00" there would be a lie about the holdings. // price fetch. A "Total: $0.00" there would be a lie about the holdings.
test("no USD total is shown when prices are not known", () => { test("no USD total is shown when prices are not known", () => {
expect(balanceWarningHtml(TOKEN_ONLY)).not.toContain("Total:"); expect(balanceWarningHtml(TOKEN_ONLY)).not.toContain("Total:");

View File

@@ -46,9 +46,24 @@ const STUB_TX_HASH =
const STUB_BLOCK_NUMBER = 21000000; const STUB_BLOCK_NUMBER = 21000000;
// The native ETH transfer, seeded by opts.seedNativeTransfer. Its own hash
// and an older block, so it is a second row rather than a leg of the token
// transfer: mergeTransactions() consolidates a native entry and a token
// transfer that share a hash into one row, which would leave nothing native
// to open. 0.25 ETH clears the 100000 gwei dust threshold the default
// filters apply, so the row is not silently dropped.
const STUB_NATIVE_TX_HASH =
"0xe7e0000000000000000000000000000000000000000000000000000000000e7e";
const STUB_NATIVE_BLOCK_NUMBER = STUB_BLOCK_NUMBER - 1;
const STUB_NATIVE_VALUE_WEI = "250000000000000000";
// Fixed instant so timeAgo() output is stable across runs. // Fixed instant so timeAgo() output is stable across runs.
const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z"; const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z";
const STUB_NATIVE_TX_TIMESTAMP = "2026-01-02T02:03:04.000000Z";
// A 32-byte zero word. Returned for every eth_call, which is what makes // A 32-byte zero word. Returned for every eth_call, which is what makes
// ethers' ENS reverse lookup resolve to "no resolver set" and return null // ethers' ENS reverse lookup resolve to "no resolver set" and return null
// instead of throwing. A throw would be logged by src/shared/ens.js via // instead of throwing. A throw would be logged by src/shared/ens.js via
@@ -258,6 +273,25 @@ function tokenTransferItems(address) {
]; ];
} }
// One received native ETH transfer, in the shape src/shared/transactions.js
// parses. to.is_contract is false and there is no method, so parseTx() keeps
// it a plain transfer rather than a contract call — which is what makes the
// detail screen classify it "Native ETH Transfer" and leave the token
// contract row hidden.
function nativeTransactionItems(address) {
return [
{
hash: STUB_NATIVE_TX_HASH,
block_number: STUB_NATIVE_BLOCK_NUMBER,
timestamp: STUB_NATIVE_TX_TIMESTAMP,
from: { hash: STUB_COUNTERPARTY },
to: { hash: address, is_contract: false },
value: STUB_NATIVE_VALUE_WEI,
status: "ok",
},
];
}
// A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving // A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving
// this is what puts an ERC-20 in the send screen's token dropdown, which is // this is what puts an ERC-20 in the send screen's token dropdown, which is
// the only way the confirmation screen's ERC-20 path can be reached. // the only way the confirmation screen's ERC-20 path can be reached.
@@ -270,12 +304,17 @@ function tokenBalanceItems() {
]; ];
} }
// Full details for STUB_TX_HASH. raw_input is "0x" so the calldata // Full details for either seeded transaction — the detail screen fetches
// decoder short-circuits; the on-chain detail fields still populate. // them for whichever row was opened, and an unstubbed hash would be
function transactionDetails() { // reported as escaping traffic. raw_input is "0x" so the calldata decoder
// short-circuits; the on-chain detail fields still populate.
function transactionDetails(hash) {
return { return {
hash: STUB_TX_HASH, hash: hash,
block_number: STUB_BLOCK_NUMBER, block_number:
hash === STUB_NATIVE_TX_HASH
? STUB_NATIVE_BLOCK_NUMBER
: STUB_BLOCK_NUMBER,
nonce: 7, nonce: 7,
gas_used: "51000", gas_used: "51000",
gas_price: "1000000000", gas_price: "1000000000",
@@ -479,6 +518,10 @@ function traceEnabled(raw) {
* @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20 * @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20
* transfer. Read at request time, so a test can flip it on the same * transfer. Read at request time, so a test can flip it on the same
* options object without re-registering the route. * options object without re-registering the route.
* @param {boolean} [opts.seedNativeTransfer] serve the stubbed native ETH
* transfer, read at request time like seedTokenTransfer. Without it the
* normal-transactions endpoint answers with an empty list, so there is no
* non-ERC-20 row to open.
* @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20 * @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20
* holding, which is what makes the token reachable from the send screen. * holding, which is what makes the token reachable from the send screen.
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance; * @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
@@ -550,7 +593,13 @@ async function installNetworkStubs(ctx, opts) {
// Blockscout v2 // Blockscout v2
if (p.includes("/api/v2/")) { if (p.includes("/api/v2/")) {
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
return jsonResponse(route, { items: [] }); const addr = blockscoutAddress(p);
return jsonResponse(route, {
items:
opts.seedNativeTransfer && addr
? nativeTransactionItems(addr)
: [],
});
} }
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) {
const addr = blockscoutAddress(p); const addr = blockscoutAddress(p);
@@ -567,8 +616,10 @@ async function installNetworkStubs(ctx, opts) {
opts.seedTokenBalance ? tokenBalanceItems() : [], opts.seedTokenBalance ? tokenBalanceItems() : [],
); );
} }
if (p.endsWith("/transactions/" + STUB_TX_HASH)) { for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) {
return jsonResponse(route, transactionDetails()); if (p.endsWith("/transactions/" + hash)) {
return jsonResponse(route, transactionDetails(hash));
}
} }
} }
@@ -640,6 +691,8 @@ module.exports = {
FEE_ESTIMATE_WEI, FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI, FEE_RESERVE_WEI,
STUB_COUNTERPARTY, STUB_COUNTERPARTY,
STUB_NATIVE_TX_HASH,
STUB_NATIVE_VALUE_WEI,
STUB_TOKEN, STUB_TOKEN,
STUB_TX_HASH, STUB_TX_HASH,
}; };

View File

@@ -36,6 +36,8 @@ const {
FEE_ESTIMATE_WEI, FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI, FEE_RESERVE_WEI,
STUB_COUNTERPARTY, STUB_COUNTERPARTY,
STUB_NATIVE_TX_HASH,
STUB_NATIVE_VALUE_WEI,
STUB_TOKEN, STUB_TOKEN,
STUB_TX_HASH, STUB_TX_HASH,
} = require("./network"); } = require("./network");
@@ -169,6 +171,264 @@ test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
assert(dots > 0, "token contract row rendered without its colour dot"); assert(dots > 0, "token contract row rendered without its colour dot");
}); });
// --------------------- the rest of the #150 and #151 definition of done
//
// The two tests above assert that the screens #150 and #151 broke now open
// without throwing, which is narrower than what those issues asked for.
// The four items below are the remainder (#188): the navigation stack out
// of Add Token, the quick-pick actually populating the field, the native
// ETH detail path the ERC-20 fix could have regressed, and tap-to-copy.
// Leave the transaction detail screen for the address screen it was opened
// from. The two tests above finish on it, and so does the last test here.
async function leaveTransactionDetail(page) {
if (await page.isVisible("#view-transaction")) {
await page.click("#btn-tx-back");
}
await openAddressDetail(page);
}
// Back out to Home from wherever the previous test finished.
async function goHome(page) {
await leaveTransactionDetail(page);
await page.click("#btn-address-back");
await visible(page, "#view-main");
}
// The navigation stack as it was actually persisted, read out of extension
// storage rather than inferred from which screen is showing. A stale entry
// left behind by a forward navigation that threw is invisible on screen
// until the user presses Back one time too many — which is exactly the
// second-order damage #150 did — so the stack itself is what gets asserted.
function persistedViewStack(page) {
return page.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
resolve((r.autistmask && r.autistmask.viewStack) || []);
});
}),
);
}
// saveState() is fired from showView() without being awaited, so the write
// lands shortly after the screen does. Polling for the expected stack keeps
// that race out of the assertion; a stack that never becomes the expected
// one fails with what it actually was.
const VIEW_STACK_SETTLE_MS = 5000;
async function waitForViewStack(page, expected, where) {
const want = JSON.stringify(expected);
const deadline = Date.now() + VIEW_STACK_SETTLE_MS;
let seen;
for (;;) {
seen = await persistedViewStack(page);
if (JSON.stringify(seen) === want) return;
if (Date.now() >= deadline) break;
await sleep(50);
}
throw new Error(
"navigation stack " +
where +
" is " +
JSON.stringify(seen) +
", expected " +
want,
);
}
// The invariant is stated as a delta against whatever the earlier tests
// left on the stack, not as an absolute: a round trip into Add Token and
// back out must leave the stack exactly as it found it. That is what "no
// duplicated or orphaned stack entry" means, and it holds whatever the
// starting depth is.
test("Back from Add Token unwinds the stack exactly once (#150)", async (env) => {
await goHome(env.page);
const base = await persistedViewStack(env.page);
await env.page.locator("#wallet-list .btn-addr-info").first().click();
await visible(env.page, "#view-address");
await waitForViewStack(env.page, base.concat("main"), "on address detail");
await env.page.click("#btn-add-token");
await visible(env.page, "#view-add-token");
await waitForViewStack(
env.page,
base.concat("main", "address"),
"on the add token screen",
);
await env.page.click("#btn-add-token-back");
await visible(env.page, "#view-address");
assert(
!(await env.page.isVisible("#view-add-token")),
"the add token screen is still showing after Back",
);
await waitForViewStack(
env.page,
base.concat("main"),
"after Back from add token",
);
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await waitForViewStack(env.page, base, "after a second Back");
});
test("a common-token quick-pick fills in the contract address (#150)", async (env) => {
await openAddressDetail(env.page);
await env.page.click("#btn-add-token");
await visible(env.page, "#view-add-token");
const before = await env.page.inputValue("#add-token-address");
assert(
before === "",
"the add token screen opened with the address field already filled: " +
JSON.stringify(before),
);
const pick = env.page.locator("#common-token-list .common-token").first();
const wanted = await pick.getAttribute("data-address");
assert(
/^0x[0-9a-fA-F]{40}$/.test(wanted || ""),
"the first quick-pick button carries no contract address: " +
JSON.stringify(wanted),
);
await pick.click();
const after = await env.page.inputValue("#add-token-address");
assert(
after === wanted,
"clicking the " +
(await pick.innerText()).trim() +
" quick-pick left the address field as " +
JSON.stringify(after) +
", expected " +
JSON.stringify(wanted),
);
await env.page.click("#btn-add-token-back");
await visible(env.page, "#view-address");
});
// The native amount as the transaction list writes it (four decimals) and
// as the detail screen writes it (full precision). Both are rendered here
// from the fixture rather than read off the screen, so the assertions
// compare against the wei the stub served.
const NATIVE_ROW_TEXT =
parseFloat(formatEther(STUB_NATIVE_VALUE_WEI)).toFixed(4) + " ETH";
const NATIVE_DETAIL_TEXT = formatEther(STUB_NATIVE_VALUE_WEI) + " ETH";
test("the native ETH transaction detail still renders (#151)", async (env) => {
// The ERC-20 fix could only have regressed this path by making the
// token-contract branch run for a transfer that has no contract, so
// the assertions below are as much about that row staying hidden as
// about the screen coming up.
env.routeOpts.seedNativeTransfer = true;
await env.page.reload();
await openAddressDetail(env.page);
const row = env.page
.locator("#tx-list .tx-row")
.filter({ hasText: NATIVE_ROW_TEXT });
await row.waitFor({ state: "visible", timeout: 30000 });
await row.click();
await visible(env.page, "#view-transaction");
const hash = await env.page.locator("#tx-detail-hash").innerText();
assert(
hash.includes(STUB_NATIVE_TX_HASH),
"the native transaction detail shows the wrong hash: " + hash,
);
const type = (await env.page.locator("#tx-detail-type").innerText()).trim();
assert(
type === "Native ETH Transfer",
"the native transaction was classified " + JSON.stringify(type),
);
const value = await env.page.locator("#tx-detail-value").innerText();
assert(
value.includes(NATIVE_DETAIL_TEXT),
"the native transaction detail shows " +
JSON.stringify(value) +
", expected it to contain " +
NATIVE_DETAIL_TEXT,
);
const native = await env.page.locator("#tx-detail-native").innerText();
assert(
native.includes(STUB_NATIVE_VALUE_WEI + " wei"),
"the raw quantity row shows " +
JSON.stringify(native) +
", expected the value in wei",
);
assert(
!(await env.page.isVisible("#tx-detail-token-contract-section")),
"the token contract row is showing on a transfer that has no token " +
"contract",
);
// Back to one seeded transaction for everything after this: the tests
// below were written against a list holding the token transfer alone.
env.routeOpts.seedNativeTransfer = false;
});
test("tap-to-copy on the transaction detail screen copies the address (#151)", async (env) => {
// Read the clipboard back rather than watching the handler run: what
// #151 asks for is the address reaching the clipboard, and a spy on
// navigator.clipboard would assert the call and not the effect.
//
// Granted context-wide rather than for the popup's origin: an
// origin-scoped grant is refused for chrome-extension: URLs, which
// both Playwright and Chrome treat as opaque here.
await env.ctx.grantPermissions(["clipboard-read", "clipboard-write"]);
await leaveTransactionDetail(env.page);
const row = env.page
.locator("#tx-list .tx-row")
.filter({ hasText: STUB_TOKEN.symbol });
await row.waitFor({ state: "visible", timeout: 30000 });
await row.click();
await visible(env.page, "#view-transaction");
await visible(env.page, "#tx-detail-token-contract-section");
// Seed a sentinel first, so a clipboard that nothing writes to cannot
// pass on whatever was left in it.
const SENTINEL = "e2e-clipboard-untouched";
await env.page.evaluate((s) => navigator.clipboard.writeText(s), SENTINEL);
const seeded = await env.page.evaluate(() =>
navigator.clipboard.readText(),
);
assert(
seeded === SENTINEL,
"the harness could not seed the clipboard, so the assertion below " +
"would prove nothing; it read back " +
JSON.stringify(seeded),
);
await env.page.locator("#tx-detail-token-contract [data-copy]").click();
const copied = await env.page.evaluate(() =>
navigator.clipboard.readText(),
);
assert(
copied.toLowerCase() === STUB_TOKEN.address,
"tapping the token contract address put " +
JSON.stringify(copied) +
" on the clipboard, expected " +
STUB_TOKEN.address,
);
const flash = await env.page.locator("#flash-msg").innerText();
assert(
flash.trim() === "Copied!",
"the copy gave no confirmation, flash line reads " +
JSON.stringify(flash),
);
});
// -------------------------------------------- recovery phrase (#161) // -------------------------------------------- recovery phrase (#161)
// The gear toggles, so pressing it while Settings is already up leaves it. // The gear toggles, so pressing it while Settings is already up leaves it.
@@ -2371,6 +2631,7 @@ async function main() {
// starting state of a run is readable without hunting through tests. // starting state of a run is readable without hunting through tests.
const routeOpts = { const routeOpts = {
seedTokenTransfer: false, seedTokenTransfer: false,
seedNativeTransfer: false,
seedTokenBalance: false, seedTokenBalance: false,
ethBalanceWei: null, ethBalanceWei: null,
failGasEstimate: false, failGasEstimate: false,