Compare commits

...

3 Commits

Author SHA1 Message Date
clawbot
43d533d1b9 fix: settle a site approval on the port that carries its teardown (closes #275)
All checks were successful
check / check (push) Successful in 27s
Approve and window.close() left the popup on the next line, and the decision
and the disconnect the close caused travelled independent channels with nothing
ordering them. The disconnect handler settled a pending site approval as a
rejection, so whichever landed first decided the outcome. Driven in a tab the
teardown won every time: the user allowed the connection and the dApp was told
they had refused.

The decision now goes out on the approval port the popup already opens, which
is the same port the close disconnects. One channel is ordered -- a message
posted on a port is delivered before that port's own disconnect -- so the
approval is settled before the teardown is even seen, and the disconnect then
finds nothing pending to reject. Nothing waits, nothing is timed, and the popup
closes exactly as immediately as before.

windows.onRemoved no longer decides a site approval whose port is connected
either. In the fallback-window shape that event races the decision on a channel
of its own, which is the same defect one level over; the port disconnect says
the same thing in a defined order, so it is left to say it. A window that
closes before its popup ever connected has nothing else to speak for it and is
still rejected there, so no dApp is left waiting on a window that is gone.

Rejecting reports a rejection, and so does closing without deciding, in both
shapes. AUTISTMASK_APPROVAL_RESPONSE is gone; the port name carries the
approval id, so the popup no longer names one, and the sender check the message
carried moved to the port.

tests/backgroundApproval.test.js drives decide-then-disconnect with nothing
awaited in between, in the toolbar-popup shape that production uses and in the
fallback-window shape, and asserts every close-without-deciding path still
rejects. tests/e2e/run.js drops the deferred-window.close() accommodation it
carried for this bug, so the two site-prompt tests now drive the shipped
decide-then-close in a real Chromium.
2026-08-17 06:48:34 +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
16 changed files with 1712 additions and 213 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
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
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.
- **Elements**:
- 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)
- Send / Receive quick-action buttons, both acting on the active address
- ETH/USD price display
@@ -735,7 +758,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- Title: "Wallet Name — Address N"
- ENS name (if resolved, bold above the address)
- 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
places, USD inline). Each balance row is clickable → **AddressToken**
- 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.
- A warning when the address holds anything, ETH or any tracked ERC-20,
followed by the holdings themselves via `balanceLinesForAddress()` and the
USD total via `getAddressValueUsd()`. The sentence names no figure of its
own: the lines round to four decimals, so a sentence built from a rounded
number would report `0.0000 ETH` for an address holding real money. The
predicate is `addressHoldsFunds()` in `src/popup/views/helpers.js`,
unrounded and token-aware. A balance is a warning, never a refusal.
USD total via `formatAddressTotal()` (see
[Partial USD totals](#partial-usd-totals)). The sentence names no figure
of its own: the lines round to four decimals, so a sentence built from a
rounded number would report `0.0000 ETH` for an address holding real
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
removing the last one means deleting the wallet from Settings
- 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
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
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**:
- "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing

53
TODO.md
View File

@@ -45,6 +45,22 @@ undefined identifiers, which is how
# 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
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
@@ -54,6 +70,31 @@ undefined identifiers, which is how
stubbed RPC and the unobservable toolbar popup — were checked against the
current `src/content/inpage.js` and `tests/e2e/` and left as they are
([#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
@@ -70,6 +111,18 @@ undefined identifiers, which is how
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: Approving a site connection is no longer a race against the popup
closing. The decision now rides the approval port the popup already holds,
which is the same channel the close disconnects, so it is delivered ahead of
that disconnect however fast the teardown is; `windows.onRemoved` no longer
decides a site approval whose port is connected, since that event is ordered
against nothing either. Rejecting and closing without deciding both still
report a rejection, and the popup delays its own close by nothing. The e2e
harness's deferred-`window.close()` accommodation is gone with it, so the two
site-prompt tests now drive the shipped decide-then-close in a real Chromium;
against the unfixed code the approval came back to the page as
`{"settled":"rejected","code":4001}`
([#275](https://git.eeqj.de/sneak/AutistMask/issues/275)).
- 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
background produced and the content script relayed intact was dropped in the

View File

@@ -24,6 +24,7 @@ const {
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT,
TX_STAGE_NONCE,
} = require("../shared/approvalVerify");
const { prepareApprovalTx } = require("../shared/approvalTx");
const {
@@ -57,6 +58,114 @@ const connectedSites = {};
// Pending approval requests: { id: { origin, hostname, resolve } }
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;
}
}
async function getState() {
const result = await storageApi.get("autistmask");
return (
@@ -148,11 +257,41 @@ function settleApproval(id, result, options) {
const holdsClaim = !!(options && options.holdsClaim);
if (approval.attemptInFlight && !holdsClaim) return false;
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);
resetPopupUrl();
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.
//
// An approval that failed retryably has to stay in pendingApprovals, so its
@@ -172,8 +311,26 @@ function claimApproval(approval) {
// Release an approval whose attempt failed in a way the user can retry.
// 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) {
approval.attemptInFlight = false;
if (approval.windowClosed) {
settleApproval(
approval.id,
abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
),
);
}
}
// Open approval in a separate popup window.
@@ -200,9 +357,35 @@ function openApprovalWindow(id) {
);
}
windowsApi.create(opts, (win) => {
if (win) {
pendingApprovals[id].windowId = win.id;
const approval = pendingApprovals[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 +395,7 @@ function openApprovalWindow(id) {
function requestApproval(origin, hostname) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = { origin, hostname, resolve };
pendingApprovals[id] = { id, origin, hostname, resolve };
if (actionApi && typeof actionApi.openPopup === "function") {
actionApi.setPopup({
@@ -243,10 +426,13 @@ function requestApproval(origin, hostname) {
// 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
// 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) => {
const id = crypto.randomUUID();
pendingApprovals[id] = {
id,
origin,
hostname,
approvedTx,
@@ -254,6 +440,7 @@ function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
resolve,
type: "tx",
};
if (slot) slot.approvalId = id;
openApprovalWindow(id);
});
@@ -267,6 +454,7 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = {
id,
origin,
hostname,
signParams,
@@ -279,13 +467,53 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
});
}
// Detect when an approval popup (browser-action) closes without a response.
// TX and sign approvals now use windows.create() and are handled by the
// windowsApi.onRemoved listener below, but we still handle site-connection
// approval disconnects here.
// Anything only the extension's own pages may say. A content script speaks
// with the page's URL, so this is what separates the popup from the site the
// popup is being asked about.
function isExtensionSender(sender) {
const extUrl = runtime.getURL("");
return !!(sender && sender.url && sender.url.startsWith(extUrl));
}
// The approval popup's port: it carries the user's decision on a
// site-connection approval, and its disconnect is how that approval learns the
// popup closed without one.
//
// The decision travels this port rather than a one-off runtime.sendMessage()
// for exactly one reason: the port is also what the popup's window.close()
// disconnects. A message posted on a port is delivered before that port's
// disconnect, so approve-then-close settles as an approval no matter how fast
// the teardown is. Sent as a one-off message the two crossed on independent
// channels with nothing ordering them, and the teardown won every time when
// the prompt was driven in a tab: the user approved and the dApp was told they
// had refused.
//
// TX and sign approvals do not decide here. They stay pending across a
// disconnect — the user can reopen the toolbar popup — and are rejected by the
// windowsApi.onRemoved listener below.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1];
if (pendingApprovals[id] && isExtensionSender(port.sender)) {
// The extension's own popup is on the other end, so its disconnect
// is a trustworthy "closed" and onRemoved below stands down. The
// sender check is what keeps that from being an off switch: a
// content script that guessed the id and held its port open would
// otherwise disable the only settlement path a prompt whose popup
// never connected has left, and the dApp would wait forever.
pendingApprovals[id].portConnected = true;
}
port.onMessage.addListener((msg) => {
if (!msg || msg.type !== "AUTISTMASK_APPROVAL_DECISION") return;
if (!isExtensionSender(port.sender)) return;
const approval = pendingApprovals[id];
if (!approval || approval.type === "tx" || approval.type === "sign")
return;
settleApproval(id, {
approved: !!msg.approved,
remember: !!msg.remember,
});
});
port.onDisconnect.addListener(() => {
const approval = pendingApprovals[id];
if (approval) {
@@ -295,7 +523,6 @@ runtime.onConnect.addListener((port) => {
}
settleApproval(id, { approved: false, remember: false });
}
resetPopupUrl();
});
}
});
@@ -585,31 +812,68 @@ async function handleRpc(method, params, origin) {
}
if (method === "eth_sendTransaction") {
const s = await getState();
const activeAddress = await getActiveAddress();
if (!activeAddress)
return { error: { message: "No accounts available" } };
return await handleSendTransaction(params, origin);
}
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
// 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 } };
}
}
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.",
},
};
}
return { error: { message: "Unsupported method: " + method } };
}
// 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
// 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
@@ -644,22 +908,17 @@ async function handleRpc(method, params, origin) {
hostname,
approvedTx,
activeAddress,
slot,
);
if (decision.error) return { error: decision.error };
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.
@@ -694,15 +953,11 @@ async function broadcastAccountsChanged() {
// being signed and broadcast right now, and neither rejecting it to the
// page nor closing the window it is reporting into is survivable.
for (const [id, approval] of Object.entries(pendingApprovals)) {
const rejection =
approval.type === "tx" || approval.type === "sign"
? {
error: {
code: 4001,
message: "User rejected the request.",
},
}
: { approved: false, remember: false };
const rejection = abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
);
if (!settleApproval(id, rejection)) continue;
if (approval.windowId) {
windowsApi.remove(approval.windowId, () => {
@@ -831,21 +1086,30 @@ startBackgroundJobs();
// verify and broadcast it is waiting on, so a user closing an apparently-hung
// window is an ordinary event with an attempt already in flight behind it.
// settleApproval() refuses those, which leaves the attempt to report its real
// outcome to the page.
// 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.
//
// A site-connection approval whose popup connected its port is not decided
// here. That popup approves and closes in the same breath, and this event
// races the decision on a channel of its own — the same race the port exists
// to end. Its port disconnect says the same thing this event does, in an order
// that is defined, so the disconnect is left to say it. The window closing
// before any port connected is the one case with nothing else to speak for it,
// and is rejected here so the dApp is not left waiting on a window that is
// gone.
if (windowsApi && windowsApi.onRemoved) {
windowsApi.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue;
const rejection =
approval.type === "tx" || approval.type === "sign"
? {
error: {
code: 4001,
message: "User rejected the request.",
},
}
: { approved: false, remember: false };
settleApproval(id, rejection);
const isSite = approval.type !== "tx" && approval.type !== "sign";
if (isSite && approval.portConnected) continue;
const rejection = abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
);
if (!settleApproval(id, rejection)) approval.windowClosed = true;
}
});
}
@@ -872,18 +1136,16 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
// Validate that popup-only messages originate from the extension itself.
// The site-connection decision is not here: it is a port message, and it
// is checked the same way where the port is served.
const POPUP_ONLY_TYPES = [
"AUTISTMASK_GET_APPROVAL",
"AUTISTMASK_APPROVAL_RESPONSE",
"AUTISTMASK_TX_RESPONSE",
"AUTISTMASK_SIGN_RESPONSE",
];
if (POPUP_ONLY_TYPES.includes(msg.type)) {
const extUrl = runtime.getURL("");
if (!sender.url || !sender.url.startsWith(extUrl)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
@@ -915,15 +1177,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return false;
}
if (msg.type === "AUTISTMASK_APPROVAL_RESPONSE") {
settleApproval(msg.id, {
approved: msg.approved,
remember: msg.remember,
});
resetPopupUrl();
return false;
}
if (msg.type === "AUTISTMASK_TX_RESPONSE") {
const approval = pendingApprovals[msg.id];
if (!approval) return false;
@@ -959,7 +1212,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: TX_STAGE_SIGN,
stage: outcome.stage,
});
return false;
}
@@ -975,8 +1228,15 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
(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 {
await loadState();
chainId = currentNetwork().chainId;
const activeAddress = await getActiveAddress();
// An address switch between approval and signing refuses. The
// approval named one account; signing from whichever account
@@ -999,7 +1259,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
msg.rawSignedTx,
approval.approvedTx,
approval.approvedFrom,
currentNetwork().chainId,
chainId,
);
} catch (e) {
// A signed transaction that is not the approved one is not
@@ -1019,7 +1279,31 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({
error: outcome.error,
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;
}
@@ -1027,6 +1311,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
try {
const provider = getProvider(state.rpcUrl);
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
if (nonce !== null) spent.add(nonce);
settleApproval(
msg.id,
{ txHash: tx.hash },
@@ -1039,6 +1324,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// tell a transaction that never left from one already in the
// mempool. The page has been given its outcome for this
// 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);
settleApproval(
msg.id,
@@ -1048,7 +1338,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: TX_STAGE_BROADCAST,
stage: outcome.stage,
});
}
})();

View File

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

View File

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

View File

@@ -441,7 +441,7 @@ function showSignApproval(details) {
function show(id) {
approvalId = id;
runtime.connect({ name: "approval:" + id });
approvalPort = runtime.connect({ name: "approval:" + id });
runtime.sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id }, (details) => {
if (!details) {
window.close();
@@ -470,6 +470,14 @@ function show(id) {
}
let approvalId = null;
// The port this approval was opened on. Closing this window disconnects it,
// and the background treats that disconnect as "closed without deciding" for a
// site connection — so the decision goes out on this same port and not as a
// one-off message. One channel is ordered: a message posted on it is delivered
// before its own disconnect, however immediately the close follows. Two
// channels were not, and the close won, reporting a user who approved as
// having refused.
let approvalPort = null;
let pendingTxDetails = null;
// The exact objects shown to the user, kept so the popup signs what it
// displayed rather than re-fetching or re-populating anything at approval
@@ -537,6 +545,28 @@ function clearSignPassword() {
hideError("approve-sign-error");
}
// Answer a site-connection approval and close. The decision goes out on the
// approval port — see approvalPort above for why — and carries no approval id,
// because the port name already names the approval the background will settle.
// The post is guarded because a throw must not cost the close: posting on a
// port whose background worker has been torn down throws, and the approval it
// would have settled died with that worker, so the only thing left to do is
// what the user asked for — go away.
function decideSite(approved) {
if (approvalPort) {
try {
approvalPort.postMessage({
type: "AUTISTMASK_APPROVAL_DECISION",
approved,
remember: $("approve-remember").checked,
});
} catch {
// Nothing to report it to; the window closes either way.
}
}
window.close();
}
function init(ctx) {
onViewLeave("approve-tx", clearTxPassword);
onViewLeave("approve-sign", clearSignPassword);
@@ -547,25 +577,11 @@ function init(ctx) {
});
$("btn-approve").addEventListener("click", () => {
const remember = $("approve-remember").checked;
runtime.sendMessage({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: true,
remember,
});
window.close();
decideSite(true);
});
$("btn-reject").addEventListener("click", () => {
const remember = $("approve-remember").checked;
runtime.sendMessage({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: false,
remember,
});
window.close();
decideSite(false);
});
$("btn-approve-tx").addEventListener("click", async () => {

View File

@@ -17,7 +17,7 @@ const {
addressHoldsFunds,
balanceLinesForAddress,
} = require("./helpers");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { state, saveState } = require("../../shared/state");
const {
@@ -84,16 +84,16 @@ function recoveryPathText(wallet) {
// 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.
// 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
// testnet and before the first price fetch, where the line is left off rather
// than printed as $0.00).
// AddressDetail, followed by the USD total when there is one to give — no
// total line at all on testnet or before the first price fetch, and no figure
// 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) {
if (!addressHoldsFunds(addr)) return " ";
const usd = getAddressValueUsd(addr);
const total =
usd === null
? ""
: `<div class="text-xs text-muted mt-1">Total: ${formatUsd(usd)}</div>`;
const line = formatAddressTotal(getAddressValue(addr));
const total = line
? `<div class="text-xs text-muted mt-1">${line}</div>`
: "";
return (
`<p class="mb-1">This address holds a balance. Removing it does not ` +
`move or spend anything; the balance stays at the address.</p>` +

View File

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

View File

@@ -28,8 +28,9 @@ const {
} = require("../../shared/walletDefects");
const {
formatUsd,
formatAddressTotal,
getPrice,
getAddressValueUsd,
getAddressValue,
} = require("../../shared/prices");
const {
fetchRecentTransactions,
@@ -71,9 +72,7 @@ function renderTotalValue() {
el.textContent = ethStr + ethUsd;
if (subEl) {
const totalUsd = getAddressValueUsd(addr);
subEl.innerHTML =
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : "&nbsp;";
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || "&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-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`;
const addrUsd = formatUsd(getAddressValueUsd(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || "&nbsp;"}</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`;
html += balanceLinesForAddress(
addr,
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
// from the site".
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) {
if (typeof err === "string" && err !== "") return err;
@@ -611,6 +617,59 @@ function errorText(err) {
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
// 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
@@ -627,12 +686,31 @@ function errorText(err) {
// 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
// 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) {
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 retryable =
stage === TX_STAGE_SIGN ||
(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
@@ -642,14 +720,20 @@ function describeTxFailure(stage, err) {
//
// A failed broadcast gets its own wording: the transaction may already be on
// the network, so telling the user to start again from the site is exactly the
// wrong instruction.
// 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) {
let message = (response && response.error) || fallbackMessage;
if (!/[.!?]$/.test(message)) message += ".";
const retryable = !!(response && response.retryable);
const stage = response && response.stage;
if (!retryable) {
if (stage === TX_STAGE_BROADCAST) {
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 +=
" The transaction may still have reached the network." +
" Check the account before sending it again.";
@@ -675,9 +759,11 @@ module.exports = {
assertWithinCeilings,
sameAddress,
failureIsRetryable,
isNonceCollision,
describeTxFailure,
describeSigningFailure,
ApprovalMismatchError,
NONCE_COLLISION_MESSAGE,
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
FORBIDDEN_FIELDS,
@@ -686,6 +772,7 @@ module.exports = {
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT,
TX_STAGE_NONCE,
MAX_GAS_LIMIT,
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");
if (currentNetwork().isTestnet) return null;
if (!prices.ETH) return null;
let total = 0;
const ethBal = parseFloat(addr.balance || "0");
total += ethBal * prices.ETH;
if (currentNetwork().isTestnet) return { usd: null, partial: false };
if (!prices.ETH) return { usd: null, partial: false };
let usd = parseFloat(addr.balance || "0") * prices.ETH;
let partial = false;
for (const token of addr.tokenBalances || []) {
const tokenBal = parseFloat(token.balance || "0");
if (tokenBal > 0 && prices[token.symbol]) {
total += tokenBal * prices[token.symbol];
// A balance of zero is not a holding: it can neither add to the total
// 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) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
if (!prices.ETH) return null;
let total = 0;
for (const addr of wallet.addresses) {
total += getAddressValueUsd(addr);
}
return total;
// The same pair for a whole wallet, and for every wallet at once. One
// unpriced holding anywhere makes the sum a floor, so partial carries up.
function getWalletValue(wallet) {
return sumValues(wallet.addresses.map(getAddressValue));
}
function getTotalValueUsd(wallets) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
if (!prices.ETH) return null;
let total = 0;
for (const wallet of wallets) {
total += getWalletValueUsd(wallet);
function getTotalValue(wallets) {
return sumValues(wallets.map(getWalletValue));
}
function sumValues(values) {
let usd = null;
let partial = false;
for (const value of values) {
if (value.usd === null) continue;
usd = (usd === null ? 0 : usd) + value.usd;
partial = partial || value.partial;
}
return 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 = {
@@ -99,7 +134,8 @@ module.exports = {
clearPrices,
getPrice,
formatUsd,
getAddressValueUsd,
getWalletValueUsd,
getTotalValueUsd,
formatAddressTotal,
getAddressValue,
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,
sameAddress,
failureIsRetryable,
isNonceCollision,
describeTxFailure,
describeSigningFailure,
NONCE_COLLISION_MESSAGE,
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
FORBIDDEN_FIELDS,
@@ -23,6 +25,7 @@ const {
TX_STAGE_SIGN,
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
TX_STAGE_NONCE,
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
} = require("../src/shared/approvalVerify");
@@ -1191,7 +1194,6 @@ describe("signing failure and retry", () => {
"already known",
"timeout of 30000ms exceeded",
"could not coalesce error",
"replacement transaction underpriced",
]) {
const outcome = describeTxFailure(
TX_STAGE_BROADCAST,
@@ -1199,10 +1201,76 @@ describe("signing failure and retry", () => {
);
expect(outcome.retryable).toBe(false);
expect(outcome.spendApproval).toBe(true);
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
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", () => {
const outcome = describeSigningFailure(
{

View File

@@ -30,8 +30,25 @@ const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ORIGIN = "https://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/";
// An origin the persisted state has never allowed, so asking to connect from
// it raises a prompt rather than being answered from allowedSites.
const FRESH_ORIGIN = "https://fresh.example";
// The approval id in the most recent popup URL of a list, or null when none
// of them carries one. Takes both shapes: the absolute URL windows.create()
// is given and the extension-relative one action.setPopup() is given.
function approvalIdIn(urls) {
for (let i = urls.length - 1; i >= 0; i--) {
if (!urls[i] || !urls[i].includes("?approval=")) continue;
return new URL(urls[i], EXT_URL).searchParams.get("approval");
}
return null;
}
// What the dApp asks for: no nonce, no gas, no fees. This is the shape that
// makes a duplicate broadcast possible at all.
const TX_PARAMS = {
@@ -51,10 +68,16 @@ const MESSAGE = "0x48656c6c6f204175746973744d61736b";
// The transaction the background populates and the approval screen displays.
// The nonce is a parameter because the duplicate case turns on two artifacts
// 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 {
type: 2,
chainId: 1,
chainId: chainId || MAINNET.num,
nonce,
gasLimit: 100000n,
maxFeePerGas: 2000000000n,
@@ -65,17 +88,17 @@ function populated(nonce) {
};
}
function signedAtNonce(nonce, withWallet) {
return (withWallet || signer).signTransaction(populated(nonce));
function signedAtNonce(nonce, withWallet, chainId) {
return (withWallet || signer).signTransaction(populated(nonce, chainId));
}
// 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
// signed at.
function fakeProvider(broadcastTransaction, overrides) {
function fakeProvider(broadcastTransaction, overrides, chainId) {
return {
broadcastTransaction,
getNetwork: async () => Network.from(1),
getNetwork: async () => Network.from(chainId || MAINNET.num),
getTransactionCount: async () => NONCE,
estimateGas: async () => 100000n,
getFeeData: async () => ({
@@ -111,14 +134,20 @@ function loadBackground(options) {
const broadcastTransaction = jest.fn();
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", () => ({
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
loadState,
saveState: jest.fn(async () => {}),
currentNetwork: () => ({ chainId: "0x1" }),
currentNetwork: () => ({ chainId: chain.hex }),
}));
jest.doMock("../src/shared/balances", () => ({
getProvider: () => fakeProvider(broadcastTransaction, opts.provider),
getProvider: () =>
fakeProvider(broadcastTransaction, opts.provider, chain.num),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
@@ -146,8 +175,13 @@ function loadBackground(options) {
let messageListener = null;
let windowRemovedListener = null;
let connectListener = null;
const created = [];
const removed = [];
// Every URL the background put on the browser action. A site approval
// raised through action.openPopup() opens no window at all, so this is
// the only place its id appears.
const actionPopups = [];
global.chrome = {
storage: {
@@ -163,14 +197,23 @@ function loadBackground(options) {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
// Captured, not swallowed: the approval port is what carries a
// site connection's decision and the popup teardown that races
// it, so a no-op stub here hides the whole subject of #275.
onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options2, cb) => {
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) => {
removed.push(id);
@@ -189,7 +232,17 @@ function loadBackground(options) {
query: (q, cb) => cb([]),
sendMessage: () => {},
},
action: { setPopup: () => {} },
action: {
setPopup: (o) => {
actionPopups.push(o.popup);
},
// The production route for a site connection. Present only when
// a test asks for it, because with it the prompt is the toolbar
// popup: no window is created, so windows.onRemoved can never
// fire for it and the port disconnect is the only close signal
// that exists.
...(opts.actionPopup ? { openPopup: () => Promise.resolve() } : {}),
},
};
require("../src/background/index");
@@ -204,8 +257,12 @@ function loadBackground(options) {
// Raise a pending transaction approval the way a dApp does, and dig the
// approval id back out of the popup URL the background opened.
function requestTx(txParams) {
function requestTx(txParams, origin) {
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) => {
rpcResult = r;
});
@@ -215,11 +272,16 @@ function loadBackground(options) {
method: "eth_sendTransaction",
params: [txParams || TX_PARAMS],
},
{ origin: ORIGIN },
{ origin: origin || ORIGIN },
sendResponse,
);
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,
};
}
@@ -248,6 +310,70 @@ function loadBackground(options) {
};
}
// A dApp asking to connect. The origin defaults to one the persisted
// state has never allowed, so the request really does raise a prompt
// instead of being answered from allowedSites.
function requestSite(origin) {
let rpcResult = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "eth_requestAccounts",
params: [],
},
{ origin: origin || FRESH_ORIGIN },
(r) => {
rpcResult = r;
},
);
return {
// Wherever the prompt went: the toolbar popup URL when
// action.openPopup() carried it, the created window otherwise.
id: () =>
approvalIdIn(actionPopups) ||
approvalIdIn(created.map((c) => c.url)),
result: () => rpcResult,
};
}
// The popup's approval port, as the browser delivers it. Messages posted
// on a port and that port's disconnect travel one channel in FIFO order,
// which is exactly the property the fix rests on, so this stub delivers
// them in the order the caller emits them and never reorders them.
function connectApproval(id, senderUrl) {
const onMessage = [];
const onDisconnect = [];
const port = {
name: "approval:" + id,
sender: {
url:
senderUrl === undefined
? EXT_URL + "src/popup/index.html?approval=" + id
: senderUrl,
},
onMessage: { addListener: (fn) => onMessage.push(fn) },
onDisconnect: { addListener: (fn) => onDisconnect.push(fn) },
};
connectListener(port);
return {
decide: (approved, remember) => {
for (const fn of onMessage) {
fn(
{
type: "AUTISTMASK_APPROVAL_DECISION",
approved,
remember: !!remember,
},
port,
);
}
},
disconnect: () => {
for (const fn of onDisconnect) fn(port);
},
};
}
// The user closes the approval popup. `created` is index-aligned with the
// ids the window stub hands back, so window 1 is the first popup opened.
function closeWindow(windowId) {
@@ -258,6 +384,8 @@ function loadBackground(options) {
send,
requestTx,
requestSign,
requestSite,
connectApproval,
closeWindow,
broadcastTransaction,
loadState,
@@ -269,6 +397,10 @@ function loadBackground(options) {
setActiveAddress: (address) => {
persisted.activeAddress = address;
},
// The user switching network in the toolbar popup.
setNetwork: (network) => {
chain = network;
},
fromPopup: { url: EXT_URL + "src/popup/index.html" },
};
}
@@ -444,6 +576,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
// 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
@@ -1058,3 +1502,176 @@ describe("popup-only messages", () => {
});
});
});
// A site connection decided in a popup that closes on the next line.
//
// The decision and the teardown are two events the popup emits back to back,
// and the background must not be able to reach different outcomes depending on
// which of them it processes first. It cannot, because they are now one
// channel: the decision is posted on the approval port that the close then
// disconnects, so it is delivered first. Every test here therefore emits the
// close IMMEDIATELY after the decision, with nothing awaited in between —
// which is what the popup does, and what used to report a user who approved as
// having refused (#275).
describe("a site connection decided as the popup closes", () => {
// The production route: chrome.action.openPopup() put the prompt in the
// toolbar popup, which is not a window, so nothing but the port
// disconnect can tell the background this prompt is gone.
test("approving in the toolbar popup connects the site", async () => {
const bg = loadBackground({ actionPopup: true });
const pending = bg.requestSite();
await settle();
const id = pending.id();
expect(id).toBeTruthy();
expect(bg.created).toHaveLength(0);
const port = bg.connectApproval(id);
port.decide(true, false);
port.disconnect();
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
});
test("closing the toolbar popup without deciding is a rejection", async () => {
const bg = loadBackground({ actionPopup: true });
const pending = bg.requestSite();
await settle();
const port = bg.connectApproval(pending.id());
port.disconnect();
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
test("rejecting is a rejection, and the close that follows adds nothing", async () => {
const bg = loadBackground({ actionPopup: true });
const pending = bg.requestSite();
await settle();
const port = bg.connectApproval(pending.id());
port.decide(false, false);
port.disconnect();
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
// The port carries a decision now, so it carries the sender check the
// one-off message used to carry. A content script that guessed an
// approval id must not be able to connect the site it is running on.
test("a decision from a page sender is ignored, and the close rejects", async () => {
const bg = loadBackground({ actionPopup: true });
const pending = bg.requestSite();
await settle();
const port = bg.connectApproval(pending.id(), FRESH_ORIGIN + "/x.html");
port.decide(true, true);
await settle();
expect(pending.result()).toBeNull();
port.disconnect();
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
// The fallback shape, where openPopup() is unavailable and the prompt is
// a window the extension opened. Closing it fires windows.onRemoved as
// well, on a channel of its own that is ordered against nothing — so the
// window event must not be allowed to decide a site approval either.
//
// The event goes FIRST here, which is the interleaving the guard in the
// onRemoved listener exists for: the approval is still pending when the
// event arrives, so the listener really reaches it and really has to
// decline it. With the decision first there is nothing left in
// pendingApprovals and the listener finds no approval to spare.
test("approving in the fallback window survives a window event that lands first", async () => {
const bg = loadBackground();
const pending = bg.requestSite();
await settle();
expect(bg.created).toHaveLength(1);
const port = bg.connectApproval(pending.id());
bg.closeWindow(1);
port.decide(true, false);
port.disconnect();
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
});
test("approving in the fallback window survives a window event that follows", async () => {
const bg = loadBackground();
const pending = bg.requestSite();
await settle();
const port = bg.connectApproval(pending.id());
port.decide(true, false);
bg.closeWindow(1);
port.disconnect();
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
});
// The connected port is what silences the window event, so connecting one
// must take the same sender check the decision takes. Otherwise a content
// script that guessed the id switches off the only settlement path a
// prompt whose real popup never connected has, and the dApp hangs.
test("a port from a page sender does not silence the window event", async () => {
const bg = loadBackground();
const pending = bg.requestSite();
await settle();
// Connected and held open — no disconnect, so nothing but the window
// event can settle this approval.
bg.connectApproval(pending.id(), FRESH_ORIGIN + "/x.html");
bg.closeWindow(1);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
// Same shape, and the same window event arriving before the popup has
// said anything at all — which is a user closing the window rather than
// deciding, and still has to reach the dApp as a rejection.
test("closing the fallback window without deciding is a rejection", async () => {
const bg = loadBackground();
const pending = bg.requestSite();
await settle();
const port = bg.connectApproval(pending.id());
bg.closeWindow(1);
port.disconnect();
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
// The net under the paragraph above: a prompt whose page never got as far
// as connecting the port has no disconnect to reject it, so the window
// event has to. Otherwise the dApp waits forever on a window that is gone.
test("a window that closes before its popup ever connected still rejects", async () => {
const bg = loadBackground();
const pending = bg.requestSite();
await settle();
bg.closeWindow(1);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
});

View File

@@ -150,7 +150,7 @@ describe("the balance warning on the removal confirmation", () => {
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.
test("no USD total is shown when prices are not known", () => {
expect(balanceWarningHtml(TOKEN_ONLY)).not.toContain("Total:");

View File

@@ -1856,32 +1856,13 @@ async function reserveApprovalTab(env) {
// one down with it.
env.approvalTab = await env.ctx.newPage();
// The one accommodation this section makes to the shipped code, and the
// reason for it.
//
// Both approval buttons call runtime.sendMessage() and then window.close()
// on the next line. Closing this page disconnects the approval port, and
// the disconnect handler in src/background/index.js settles a pending
// site approval as a rejection. In a tab those two race and the teardown
// wins: the approve message is never acted on, and the page is told the
// user rejected. Measured — with the close left in place the approval
// resolves as a rejection every time; with it deferred it resolves as an
// approval every time.
//
// It is deferred, not removed: the harness closes the page itself once
// the outcome has been observed, which is what window.close() would have
// done, only after the message it was racing has been processed.
//
// This affects the site-connection prompt only. The sign and transaction
// prompts run in windows the extension opens itself, with window.close()
// untouched, and their disconnect handler deliberately keeps a tx or sign
// approval pending rather than rejecting it — so there is no race there
// to accommodate. Whether the same ordering holds in a real toolbar popup
// is not observable from a headless harness and is reported rather than
// assumed either way.
await env.approvalTab.addInitScript(() => {
window.close = function () {};
});
// This tab runs the shipped popup with nothing patched. The site
// approval buttons decide and then close on the next line, and the two
// site-approval tests below are therefore the real-browser
// approve-then-immediate-close and reject-then-immediate-close cases: the
// decision rides the approval port, which also carries the disconnect the
// close causes, so it is delivered ahead of it and the outcome does not
// depend on the teardown timing (#275).
await env.approvalTab.goto("about:blank");
await sleep(APPROVAL_TAB_SETTLE_MS);
return env.approvalTab;
@@ -1930,6 +1911,95 @@ async function closeApprovalPages(ctx) {
}
}
// Click a button whose own handler closes the window it lives in — every
// Reject, and Allow on the site prompt.
//
// page.click() dispatches the click and then waits for the renderer to
// acknowledge it, and a page torn down by the handler never gets to. The
// dispatch is what the test needs and the log shows it happening ("performing
// click action") immediately before the failure; the page going away is the
// button working, not the click failing. Observed on #btn-reject-sign and
// #btn-reject-tx, whose windows have always closed themselves.
//
// What the swallow costs is not the same for every button, so neither is what
// proves the click landed:
//
// #btn-reject-sign, #btn-reject-tx — their disconnect leaves the approval
// pending, so a click that never landed leaves the dApp promise unsettled
// and the assertion after the call fails on its own.
// #btn-approve — only a decision resolves the promise, and a swallowed click
// cannot produce settled === "resolved".
// #btn-reject on the site prompt — NOT self-proving. A page that went away
// without the click landing disconnects the approval port, the background
// settles that as 4001, and 4001 is exactly what assertUserRejection
// accepts. That call site arms the click trace below and asserts it.
//
// A button that is missing or unclickable raises a different error, which is
// rethrown.
async function clickAndClose(page, selector) {
try {
await page.click(selector);
} catch (e) {
if (!String((e && e.message) || e).includes("has been closed")) throw e;
}
}
// Evidence that a click reached the button, for the button whose outcome
// cannot tell.
//
// A capture-phase listener on the document runs ahead of the button's own
// handler and writes one key with localStorage.setItem(), which is synchronous
// and therefore already in the browser process when the handler tears the page
// down a line later. Any other page of the extension origin can read it back,
// and env.page is one. The listener only observes: nothing about the shipped
// decide-then-close is deferred, patched or reordered.
const CLICK_TRACE_KEY = "autistmask-e2e-click-landed";
async function armClickTrace(env, page, selector) {
await env.page.evaluate(
(key) => localStorage.removeItem(key),
CLICK_TRACE_KEY,
);
await page.evaluate(
({ key, sel }) => {
document.addEventListener(
"click",
(e) => {
const target = e.target;
if (target && target.closest && target.closest(sel)) {
localStorage.setItem(key, sel);
}
},
true,
);
},
{ key: CLICK_TRACE_KEY, sel: selector },
);
}
// The write crosses processes to reach env.page's renderer, so it is waited
// for rather than read once. Nothing else in the test is timed on this.
async function assertClickLanded(env, selector, timeout = 5000) {
const deadline = Date.now() + timeout;
let seen = null;
for (;;) {
seen = await env.page.evaluate(
(key) => localStorage.getItem(key),
CLICK_TRACE_KEY,
);
if (seen === selector || Date.now() > deadline) break;
await sleep(25);
}
assert(
seen === selector,
"the click on " +
selector +
" never reached the button, so the outcome below proves nothing " +
"about it: trace was " +
JSON.stringify(seen),
);
}
// Record every message the approval window sends to the background worker.
//
// This is the direct observation the password check needs. It is installed
@@ -2150,7 +2220,11 @@ test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", as
// origin in deniedSites and every later test in this section is
// auto-rejected with no prompt at all, which would look like a pass.
await popup.uncheck("#approve-remember");
await popup.click("#btn-reject");
// The rejection this asserts is also what an unclicked prompt that
// simply went away produces, so the click itself is witnessed.
await armClickTrace(env, popup, "#btn-reject");
await clickAndClose(popup, "#btn-reject");
await assertClickLanded(env, "#btn-reject");
await assertUserRejection(
env.dapp,
@@ -2181,7 +2255,7 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
// does not, and the sign and transaction tests below all require the
// origin to still be authorized.
await popup.check("#approve-remember");
await popup.click("#btn-approve");
await clickAndClose(popup, "#btn-approve");
outcome = await settleRequest(env.dapp, "accounts");
} finally {
@@ -2289,7 +2363,7 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign");
await popup.click("#btn-reject-sign");
await clickAndClose(popup, "#btn-reject-sign");
await assertUserRejection(
env.dapp,
@@ -2392,7 +2466,7 @@ test("eth_signTypedData_v4 rejected returns a rejection to the page (#183)", asy
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign");
await popup.click("#btn-reject-sign");
await clickAndClose(popup, "#btn-reject-sign");
await assertUserRejection(
env.dapp,
@@ -2550,7 +2624,7 @@ test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-tx");
await popup.click("#btn-reject-tx");
await clickAndClose(popup, "#btn-reject-tx");
await assertUserRejection(
env.dapp,