Compare commits

...

7 Commits

Author SHA1 Message Date
a94110ed6c harden: verify all approval fields and make failed signing retryable (closes #174)
All checks were successful
check / check (push) Successful in 23s
verifySignedTx compared only from, to, value and data, so a signed
transaction could differ from the approval in chain id, nonce, gas limit
or any fee field and still be broadcast. Worse, it named the fields it
checked and so admitted every field it did not name: a type 4 artifact
carrying an EIP-7702 authorization passed verification, paying the
approved amount to the approved recipient and, in the same transaction,
permanently installing another contract's code at the signer's own
account.

The check is now an allowlist in both directions. The transaction type
must be 0, 1 or 2 — the only types this wallet signs — so no later
EIP-2718 type can bring a field along; authorizationList, blobs, blob
commitments and blob gas fees are refused by name; and the access list
is compared with the approval. Every consequential field is compared and
any mismatch refuses outright: the chain id against the selected network
(and against the approval when the page fixed one), plus nonce, gas
limit, gasPrice, maxFeePerGas and maxPriorityFeePerGas wherever the
approval carries a value, together with the fee mechanism the approval
implies. Fields the approval does not carry are populated locally by the
popup and have no approved value to compare against, so they are held to
absolute ceilings instead. Verification then closes by rebuilding the
transaction from exactly those checked fields and comparing the unsigned
bytes, so an artifact carrying anything this module does not account for
is refused without having to be named first.

An approved value that is not a number now refuses like every other
quantity rather than escaping as a raw BigInt conversion error, which
was reported as retryable and left a live button that could never
succeed.

A failed signing attempt also left a button that could not succeed: the
background deleted the approval before it broadcast, so a retry found
nothing to sign. The approval is now retired once the request has an
outcome, and the background tells the popup which stage failed. A popup
that could not sign is retryable; a mismatch spends the approval; a
failed broadcast is terminal, because the node may have accepted the
transaction and still failed to answer and the popup's retry re-signs at
a freshly fetched nonce rather than re-broadcasting the same bytes,
which would send the approved transfer twice.
2026-08-11 12:59:12 +00:00
86cdea5e4e chore: repo policy compliance sweep — test rerun, frozen lockfile, documented targets (closes #166)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:57:51 +02:00
f271bcd7b4 fix: one transaction history row per value movement (closes #177)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:56:30 +02:00
93e3f6e4e2 fix: correct verify-build diagnostics and close two robustness gaps (closes #180)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:55:06 +02:00
9b957ffd69 fix: derive hasWallet from the wallet list on load (closes #195)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:51:22 +02:00
cf5f582be9 docs: correct three README claims contradicted by the code (closes #213)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:41:16 +02:00
b9bc226ae1 docs: rebuild the README Screen Map from the code (closes #164)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:31:45 +02:00
16 changed files with 2302 additions and 291 deletions

View File

@@ -1,3 +1,6 @@
# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for
# build-info stamping and the Dockerfile runs `make build`, so excluding it
# would make every built extension report commitHash "unknown".
node_modules node_modules
.DS_Store .DS_Store
dist dist

View File

@@ -11,7 +11,7 @@ setup:
@script/setup @script/setup
install: install:
@yarn install @yarn install --frozen-lockfile
test: test:
@script/test @script/test

473
README.md
View File

@@ -31,10 +31,13 @@ list exists to detect symbol spoofing attacks and improve UX.
```bash ```bash
git clone https://git.eeqj.de/sneak/autistmask.git git clone https://git.eeqj.de/sneak/autistmask.git
cd autistmask cd autistmask
make install make setup
make build make build
``` ```
`make setup` is the entrypoint for a fresh clone: it installs dependencies from
the lockfile and installs the git pre-commit hook.
Load the extension: Load the extension:
- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click - **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click
@@ -97,6 +100,19 @@ provide:
- `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/precommit` — run by the git pre-commit hook; runs `script/check`
- `script/install-precommit` — install the git pre-commit hook - `script/install-precommit` — install the git pre-commit hook
The Makefile shims to those. It also carries a few targets that have no
`script/` counterpart and are Makefile-only conveniences:
- `make install``yarn install --frozen-lockfile` on its own, without the rest
of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being
silently rewritten. Use `make setup` for a fresh clone.
- `make hooks` — shims to `script/install-precommit`
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see
[Debug Builds](#debug-builds))
- `make clean` — remove `dist/`
- `make dev` — build in watch mode
## End-to-End Tests ## End-to-End Tests
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real `make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
@@ -271,10 +287,10 @@ on a different table knows exactly tf I am talking about.
Every interactive element must visually indicate that it is clickable. Buttons Every interactive element must visually indicate that it is clickable. Buttons
use a visible border, padding, and a hover state (invert to white-on-black). use a visible border, padding, and a hover state (invert to white-on-black).
Text that triggers an action (e.g. "Import private key") uses an underline. No Text that triggers an action (e.g. "Add additional wallet...") uses an
invisible hit targets, no bare text that happens to have a click handler. If it underline. No invisible hit targets, no bare text that happens to have a click
does something when you click it, it must look like it does something when you handler. If it does something when you click it, it must look like it does
click it. something when you click it.
#### Display Consistency #### Display Consistency
@@ -334,115 +350,181 @@ attack.
The core hierarchy is **Wallets → Addresses**: The core hierarchy is **Wallets → Addresses**:
- A **wallet** is either: - A **wallet** is one of three types:
- An **HD wallet** (recovery phrase): generates multiple addresses from a - An **HD wallet** (`type: "hd"`, recovery phrase): generates multiple
single 12/24 word recovery phrase using BIP-39/BIP-44 derivation. The user addresses from a single 12/24 word recovery phrase using BIP-39/BIP-44
can add more addresses with a "+" button. derivation. The user can add more addresses with a "+" button.
- A **key wallet** (private key): a single address imported directly from a - A **key wallet** (`type: "key"`, private key): a single address imported
private key. No "+" button since there is only one address. directly from a private key. No "+" button since there is only one
- An **address** holds ETH and any user-added ERC-20 tokens. address.
- An **xprv wallet** (`type: "xprv"`, extended private key): the same
multi-address behavior as an HD wallet, including the "+" button and the
address scan on import, but imported from an extended private key rather
than a recovery phrase. It therefore has no recovery phrase to display or
back up.
- An **address** holds ETH and ERC-20 tokens.
- The user can have multiple wallets, each with multiple addresses (HD) or a - The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key). single address (key).
Which tokens an address shows is decided by `fetchTokenBalances()` in
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
tokens do appear without the user adding them. An ERC-20 is shown when its
balance is nonzero and it is in the bundled top-250 token list, is tracked by
the user, or has 1,000 or more holders; a token claiming a symbol from the
bundled list from any other contract address is always dropped. That filter is
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
the transaction history and the send-screen token selector, not this list.
Tracked tokens with a zero balance are listed as well while "Show tracked tokens
with zero balance" is on.
#### Navigation #### Navigation
The main view shows all addresses grouped by wallet, with ETH balances inline. The main view shows all addresses grouped by wallet, with ETH balances inline.
The user taps an address to see its detail view (full address, balance, tokens, The user taps an address to see its detail view (full address, balance, tokens,
send/receive). Navigation is flat — every view has a "Back" or "Cancel" button send/receive). Navigation is a stack: each forward action pushes the current
that returns to the previous context. No deep nesting, no tabs, no hamburger screen, and every view has a "Back" or "Cancel" button that pops back to it (see
menus. the Screen Map below). There is no hamburger menu and no persistent tab bar; the
Settings gear in the title bar is the only global control. Two screens carry an
in-screen control beyond that: AddWallet uses three tabs to select the import
mode, and AddressDetail keeps its one rarely-used action ("Export Private Key")
behind a "···" menu.
### Screen Map ### Screen Map
Navigation uses a stack model (like iOS): each action pushes a screen onto the Navigation uses a stack model (like iOS): each forward action pushes the current
stack, and "Back" pops it. The root screen is either Welcome (no wallets) or screen onto `state.viewStack`, and "Back" pops it (`pushCurrentView()` and
Home (has wallets). Screens are listed below with their elements and `goBack()` in `src/popup/views/helpers.js`). The root screen is either Welcome
transitions. (no wallets) or Home (has wallets). Each screen below gives its view id in
parentheses; the registry of view ids is the `VIEWS` array in
`src/popup/views/helpers.js`, and the markup for a screen is the element with id
`view-` plus that view id in `src/popup/index.html`.
#### Welcome Three elements sit outside the screens and are present on all of them: the title
bar ("AutistMask by @sneak" plus the Settings gear), the flash message line
under it, and the red banner at the very top that appears on a debug build, when
runtime debug mode is on, or when the active network is a testnet. They are not
repeated in the element lists below.
- **When**: No wallets exist yet. Closing and reopening the popup returns to the screen the user was last on only
- **Elements**: "AutistMask" heading, brief intro text, "Add wallet" button. for the views listed in `RESTORABLE_VIEWS` (`src/popup/index.js`). Every other
screen, including ExportPrivKey, falls back to Home.
#### Welcome (`welcome`)
- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
screen in that case.
- **Elements**:
- "Welcome! To get started, add a wallet." text
- "Add wallet" button
- **Transitions**: - **Transitions**:
- "Add wallet" → **AddWallet** - "Add wallet" → **AddWallet**
#### Home #### Home (`main`)
- **When**: At least one wallet exists. This is the root screen. - **When**: At least one wallet exists. This is the root screen.
- **Elements**: - **Elements**:
- Header: "AutistMask", Settings gear button - Active address ETH balance (large) + USD value in parentheses
- Active address ETH balance (large) + USD value (inline parentheses) - "Total:" USD value across ETH and every token shown for the active address
- Total USD value across all tokens (small text)
- Active address (color dot, full address, etherscan link, tap to copy) - Active address (color dot, full address, etherscan link, tap to copy)
- Send / Receive quick-action buttons - Send / Receive quick-action buttons, both acting on the active address
- ETH/USD price display - ETH/USD price display
- Wallet list: each wallet shows name (tap to rename), "+" button (HD only), - Wallet list: each wallet shows its name (tap to rename inline) and a "+"
and its addresses with color dots, balances, and `[info]` buttons button for HD and xprv wallets, then one block per address with "Address
- Recent transactions across all addresses (merged, deduplicated, filtered) N" (bold when active), the ENS name if resolved, the full address, an
`[info]` button, the address USD total, and a balance line for ETH and for
each token shown for that address
- "Recent Transactions": up to 25 transactions merged across every address
of every wallet, deduplicated by hash and filtered
- "Add additional wallet..." link at bottom - "Add additional wallet..." link at bottom
- **Transitions**: - **Transitions**:
- Tap address row → sets active address (no screen change) - Tap address row → sets the active address and broadcasts
`AUTISTMASK_ACTIVE_CHANGED` (no screen change)
- Tap wallet name → inline rename field (no screen change)
- "+" on wallet → derives the next address inline (no screen change)
- `[info]` on address → **AddressDetail** - `[info]` on address → **AddressDetail**
- "Send" → **Send** (selects active address) - "Send" → **Send** (refuses with a flash message on a zero balance)
- "Receive" → **Receive** (shows active address QR) - "Receive" → **Receive** (shows active address QR)
- "+" on wallet → derives next address inline - Tap home tx row → **TransactionDetail**
- "Add additional wallet..." → **AddWallet** - "Add additional wallet..." → **AddWallet**
- Settings gear → **Settings** (toggles; tap again to return) - Settings gear → **Settings** (toggles; tap again to return)
- Tap home tx row → **AddressDetail** (for the address involved)
#### AddWallet #### AddWallet (`add-wallet`)
- **When**: User wants to add a new wallet (from Home, Welcome, or Settings). - **When**: User wants to add a new wallet (from Welcome, Home, or Settings).
This one screen covers all three import modes; there is no separate import
screen.
- **Elements**: - **Elements**:
- "Add Wallet" heading, "Back" button - "Back" button, "Add Wallet" heading
- Instruction text - Three tabs — "From Phrase" (`tab-mnemonic`), "From Key" (`tab-privkey`),
- Die button `[die]` (generates random recovery phrase) "From xprv" (`tab-xprv`) — each showing its own form section:
- Recovery phrase textarea - **From Phrase**: instruction text, a die button that generates a
- Backup warning box (shown after die is clicked) random recovery phrase, a recovery phrase textarea, and a backup
- Password + confirm password inputs warning box that becomes visible once the die button has been used
- "Add" button - **From Key**: instruction text and a masked private key input
- "Have a private key instead?" link - **From xprv**: instruction text and a masked extended private key
- **Transitions**: input
- "Add" (valid phrase + password) → **Home** - Password + confirm password inputs, with a hint line whose wording depends
- "Back" → previous screen (Home or Welcome) on the selected tab
- "Have a private key instead?" → **ImportKey**
#### ImportKey
- **When**: User wants to import a single private key.
- **Elements**:
- "Import Private Key" heading, "Back" button
- Instruction text
- Private key input (password-masked)
- Password + confirm password inputs
- "Import" button - "Import" button
- **Transitions**: - **Transitions**:
- "Import" (valid key + password) → **Home** - "Import" with a valid entry and a matching password of at least 12
- "Back" → **AddWallet** characters → creates the wallet, clears the navigation stack, and →
**Home**. The phrase and xprv modes then scan for further used addresses
and report the count as a flash message.
- "Import" with an invalid entry, a duplicate wallet or address, or a short
or mismatched password → flash message, no screen change
- "Back" → previous screen (Welcome, Home, or Settings)
#### AddressDetail #### AddressDetail (`address`)
- **When**: User tapped `[info]` on an address from Home. - **When**: User tapped `[info]` on an address from Home.
- **Elements**: - **Elements**:
- "Back" button - "Back" button
- Blockie identicon (48px, centered) - Blockie identicon (48px, centered)
- Title: "Wallet Name — Address N" - Title: "Wallet Name — Address N"
- ENS name (if resolved, bold with color dot) - ENS name (if resolved, bold above the address)
- Full address (color dot, etherscan link, tap to copy) - Full address (color dot, etherscan link, tap to copy)
- USD total for address - USD total for address
- Balance list: ETH + tracked ERC-20 tokens (4 decimal places, USD inline). - Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
Each balance row is clickable → **AddressToken** places, USD inline). Each balance row is clickable → **AddressToken**
- Send / Receive / + Token buttons - Send / Receive / + Token buttons and a "···" menu button
- "···" dropdown containing a single "Export Private Key" entry
- Transaction list (with ENS resolution for counterparties) - Transaction list (with ENS resolution for counterparties)
- **Transitions**: - **Transitions**:
- Tap balance row → **AddressToken** (for that token) - Tap balance row → **AddressToken** (for that token)
- "Send" → **Send** - "Send" → **Send** (refuses with a flash message on a zero balance)
- "Receive" → **Receive** - "Receive" → **Receive**
- "+ Token" → **AddToken** - "+ Token" → **AddToken**
- "···" → "Export Private Key" → **ExportPrivKey**
- Tap transaction row → **TransactionDetail** - Tap transaction row → **TransactionDetail**
- "Back" → **Home** - "Back" → previous screen (Home)
#### AddressToken #### ExportPrivKey (`export-privkey`)
- **When**: User chose "Export Private Key" from the "···" menu on
AddressDetail. This screen discloses secret material.
- **Elements**:
- "Back" button
- Blockie identicon (48px, centered)
- "Export Private Key" heading
- "Wallet Name — Address N" and the full address (etherscan link, tap to
copy)
- Warning that anyone holding the private key can transfer all funds from
the address
- Error line
- Password input and "Reveal" button, shown until the key is revealed
- The private key on a highlighted background, tap to copy, shown only after
the password has been accepted
- **Transitions**:
- "Reveal" (correct password) → decrypts the wallet secret, derives this
address's key, hides the password input and shows the key (no screen
change)
- "Reveal" (wrong password) → "Wrong password." on the error line, nothing
revealed
- "Back" → clears the key and password from the DOM, then → previous screen
(AddressDetail)
#### AddressToken (`address-token`)
- **When**: User clicked a specific token balance on AddressDetail. - **When**: User clicked a specific token balance on AddressDetail.
- **Elements**: - **Elements**:
@@ -453,49 +535,64 @@ transitions.
- USD total for this token - USD total for this token
- Single token balance line (4 decimal places) - Single token balance line (4 decimal places)
- Send / Receive buttons - Send / Receive buttons
- Token contract well (ERC-20 only): full contract address (tap to copy,
etherscan link) plus name, symbol, decimals, holder count and project
website where known
- Token-filtered transaction list (only this token's transfers) - Token-filtered transaction list (only this token's transfers)
- **Transitions**: - **Transitions**:
- "Send" → **Send** (token pre-selected and locked in dropdown) - "Send" → **Send** (token locked: the dropdown is replaced by a static
symbol and contract address)
- "Receive" → **Receive** (ERC-20 warning shown for non-ETH tokens) - "Receive" → **Receive** (ERC-20 warning shown for non-ETH tokens)
- Tap transaction row → **TransactionDetail** - Tap transaction row → **TransactionDetail**
- "Back" → **AddressDetail** - "Back" → previous screen (AddressDetail)
#### Send #### Send (`send`)
- **When**: User wants to send ETH or a token from this address. - **When**: User wants to send ETH or a token, from Home, AddressDetail, or
AddressToken.
- **Elements**: - **Elements**:
- "Send" heading, "Back" button - "Back" button, "Send" heading
- From: address with color dot + etherscan link - From: address with color dot + etherscan link
- What to send: token dropdown (or static display with contract address when - What to send: token dropdown (or static display with contract address when
locked from AddressToken) locked from AddressToken)
- To: address or ENS name input - To: address or ENS name input, with an inline validation message
- Amount input with current balance display - Amount input with current balance display
- "Review" button - "Review" button, disabled until the recipient validates
- **Transitions**: - **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx** - "Review" (valid inputs, ENS resolved) → **ConfirmTx**
- "Back" → **AddressToken** (if came from token view) or **AddressDetail** - "Review" with an unresolvable ENS name or an invalid amount → flash
message, no screen change
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
#### ConfirmTx #### ConfirmTx (`confirm-tx`)
- **When**: User reviewed send details and is ready to authorize. - **When**: User reviewed send details and is ready to authorize.
- **Elements**: - **Elements**:
- "Confirm Transaction" heading, "Back" button - "Back" button, "Confirm Transaction" heading
- Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)" - Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)"
- Token contract: full address + etherscan link (ERC-20 only) - Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title - From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name - To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses) - Amount: value + symbol (USD in parentheses)
- Your balance: value + symbol (USD in parentheses) - Your balance: value + symbol (USD in parentheses)
- Estimated network fee: ETH amount (USD in parentheses), fetched async - Estimated network fee: "Estimating..." then the ETH amount (USD in
- Warnings (scam address, self-send) parentheses) or "Unable to estimate", fetched async
- Warnings: inline warnings from the local checks (scam address, self-send)
plus four reserved warning boxes made visible by the async checks —
recipient with no transaction history, recipient is a contract, burn
address, and an Etherscan phishing/scam label
- Errors (insufficient balance) - Errors (insufficient balance)
- "Send" button (disabled if errors) - Password: an inline field on this screen, not a modal, with its own error
line
- "Sign & Send" button (disabled if errors)
- **Transitions**: - **Transitions**:
- "Send" → password modal → broadcast tx → **WaitTx** - "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Send" → password modal → broadcast fails → **ErrorTx** - "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Back" → **Send** - "Back" → **Send**
#### WaitTx #### WaitTx (`wait-tx`)
- **When**: Transaction has been broadcast, waiting for on-chain confirmation. - **When**: Transaction has been broadcast, waiting for on-chain confirmation.
- **Elements**: - **Elements**:
@@ -509,20 +606,24 @@ transitions.
- Receipt found → **SuccessTx** - Receipt found → **SuccessTx**
- 60 seconds without confirmation → **ErrorTx** (timeout message) - 60 seconds without confirmation → **ErrorTx** (timeout message)
#### SuccessTx #### SuccessTx (`success-tx`)
- **When**: Transaction confirmed on-chain. - **When**: Transaction confirmed on-chain.
- **Elements**: - **Elements**:
- "Transaction Confirmed" heading - "Transaction Confirmed" heading
- Decoded action well (shown when the transaction carried recognized
calldata; the top-level Amount and To are hidden in that case)
- Amount + symbol - Amount + symbol
- To: color dot + full address + etherscan link - To: color dot + full address + etherscan link
- Block number - Block number
- Transaction hash: full hash (tap to copy) + etherscan link - Transaction hash: full hash (tap to copy) + etherscan link
- "Done" button - "Done" button
- **Transitions**: - **Transitions**:
- "Done" **AddressToken** (if `selectedToken` set) or **AddressDetail** - "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
(if `selectedToken` set) or **AddressDetail**
#### ErrorTx #### ErrorTx (`error-tx`)
- **When**: Transaction broadcast failed, or timed out waiting for confirmation. - **When**: Transaction broadcast failed, or timed out waiting for confirmation.
- **Elements**: - **Elements**:
@@ -534,24 +635,28 @@ transitions.
full hash (tap to copy) + etherscan link full hash (tap to copy) + etherscan link
- "Done" button - "Done" button
- **Transitions**: - **Transitions**:
- "Done" **AddressToken** (if `selectedToken` set) or **AddressDetail** - "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
(if `selectedToken` set) or **AddressDetail**
#### Receive #### Receive (`receive`)
- **When**: User wants to receive funds at this address. - **When**: User wants to receive funds at this address, from Home,
AddressDetail, or AddressToken.
- **Elements**: - **Elements**:
- "Receive" heading, "Back" button - "Back" button, "Receive" heading
- Instruction text - Instruction text
- QR code encoding the address - QR code encoding the address
- Full address (color dot, selectable, etherscan link) - Full address (color dot, selectable, etherscan link)
- "Copy address" button - "Copy address" button
- ERC-20 warning (shown when navigating from AddressToken for non-ETH token) - ERC-20 warning (shown when navigating from AddressToken for non-ETH token)
- **Transitions**: - **Transitions**:
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail** - "Back" → previous screen (Home, AddressDetail, or AddressToken)
#### TransactionDetail #### TransactionDetail (`transaction`)
- **When**: User tapped a transaction row from AddressDetail or AddressToken. - **When**: User tapped a transaction row on Home, AddressDetail, or
AddressToken.
- **Elements** (grouped into logical blocks using light well containers; field - **Elements** (grouped into logical blocks using light well containers; field
labels are self-explanatory so groups have no headings): labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button - "Transaction" heading, "Back" button
@@ -576,91 +681,182 @@ transitions.
- Raw data (shown when calldata is present): full calldata in monospace - Raw data (shown when calldata is present): full calldata in monospace
dashed border dashed border
- **Transitions**: - **Transitions**:
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail** - "Back" → previous screen (Home, AddressDetail, or AddressToken)
#### AddToken #### AddToken (`add-token`)
- **When**: User wants to track an ERC-20 token on this address. - **When**: User wants to track an ERC-20 token, reached from "+ Token" on
AddressDetail.
- **Elements**: - **Elements**:
- "Add Token" heading, "Back" button - "Back" button, "Add Token" heading
- Instruction text (find contract address on Etherscan) - Instruction text (find contract address on Etherscan)
- Contract address input - Contract address input
- Token info preview (name, symbol — fetched from contract) - Status line ("Looking up token...", cleared or replaced on failure)
- Common token quick-pick buttons - Common token quick-pick buttons (top 25 by market cap), which fill the
contract address input
- "Add" button - "Add" button
- **Transitions**: - **Transitions**:
- "Add" (valid contract) → **AddressDetail** - "Add" (valid contract) → tracks the token, pops the stack, and re-renders
- "Back" → **AddressDetail** **AddressDetail**
- "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change
- "Back" → previous screen (AddressDetail)
#### Settings #### Settings (`settings`)
- **When**: User tapped Settings gear from Home. - **When**: User tapped the Settings gear.
- **Elements**: - **Elements**:
- "Settings" heading, "Back" button - "Back" button, "Settings" heading
- Wallets: "+ Add wallet" button - Wallets: one row per wallet with its name (tap to rename inline) and an
- Display: "Show tracked tokens with zero balance" checkbox `[x]` delete button, plus a "+ Add wallet" button
- Ethereum RPC: endpoint URL input + "Save" button - Tracked Tokens: one row per tracked token with an `[x]` remove button,
- Blockscout API: endpoint URL input + "Save" button plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox and a Theme
selector (System / Light / Dark)
- Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
resets the RPC and Blockscout endpoints to that network's defaults
- Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against
`/stats` before being saved)
- Token Spam Protection: - Token Spam Protection:
- "Hide tokens with fewer than 1,000 holders" checkbox - "Hide tokens with fewer than 1,000 holders" checkbox
- "Hide transactions from detected fraud contracts" checkbox - "Hide transactions from detected fraud contracts" checkbox
- "Hide dust transactions below N gwei" checkbox + threshold input - "Hide dust transactions below N gwei" checkbox + threshold input
- "UTC Timestamps" checkbox
- Allowed Sites: list with remove buttons - Allowed Sites: list with remove buttons
- Denied Sites: list with remove buttons - Denied Sites: list with remove buttons
- About: project link, license, author, version, release date, and the
commit, which links to the commit in the repository
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
turns on the red banner and verbose logging
- **Transitions**: - **Transitions**:
- "+ Add wallet" → **AddWallet** - "+ Add wallet" → **AddWallet**
- "Back" (or Settings gear again) → **Home** - "+ Add token" → **SettingsAddToken**
- `[x]` on a wallet → **DeleteWallet**
- Tap wallet name → inline rename field (no screen change)
- `[x]` on a tracked token or a site → removes it in place (no screen
change)
- Ten clicks on the version → reveals the Debug well (no screen change)
- "Back" (or Settings gear again) → previous screen (Home)
#### SiteApproval #### DeleteWallet (`delete-wallet-confirm`)
- **When**: A website requests wallet access via `eth_requestAccounts`. Opened - **When**: User tapped the `[x]` next to a wallet in Settings.
in a separate popup by the background script. - **Elements**:
- "Back" button, "Delete Wallet" heading
- Warning naming the wallet and stating that deletion is permanent and any
funds are unrecoverable without the recovery phrase
- Error line
- Password input
- "Confirm Delete" button
- **Transitions**:
- "Confirm Delete" (correct password, other wallets remain) → deletes the
wallet and its site permissions, then → **Settings** with a "Wallet
deleted." flash message
- "Confirm Delete" (correct password, last wallet) → deletes the wallet,
clears the selection and the navigation stack, then → **Welcome**
- Either way, the active address moves only if it belonged to the deleted
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
(`src/shared/walletDelete.js`)
- "Confirm Delete" (wrong password) → "Wrong password." on the error line,
nothing deleted
- "Back" → previous screen (Settings)
#### SettingsAddToken (`settings-addtoken`)
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
across every address, unlike AddToken which is reached from one address.
- **Elements**:
- "Back" button, "Add Token" heading
- Instruction text
- "Top tokens:" quick-pick buttons (top 10 by market cap; already-tracked
tokens are disabled)
- "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
"Add selected" button
- "Or enter contract address:" input, a status line, and an "Add" button
- **Transitions**:
- Any of the three add paths, on success → adds the token and shows an
"Added SYMBOL" flash message (no screen change)
- A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change
- "Back" → previous screen (Settings)
#### SiteApproval (`approve-site`)
- **When**: A website requests wallet access via `eth_requestAccounts` or
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
The background script prefers the toolbar popup (`action.openPopup()`) and
falls back to a separate popup window (`src/background/index.js`,
`requestApproval()`).
- **Elements**: - **Elements**:
- "Connection Request" heading - "Connection Request" heading
- Site hostname (bold) - Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site hostname (bold) + "wants to connect to your wallet"
- Address that will be shared (color dot + full address + etherscan link) - Address that will be shared (color dot + full address + etherscan link)
- "Remember my choice for this site" checkbox - "Remember my choice for this site" checkbox
- "Allow" / "Deny" buttons - "Allow" / "Deny" buttons
- **Transitions**: - **Transitions**:
- "Allow" / "Deny" → closes popup (returns result to background script) - "Allow" / "Deny" → closes popup (returns result to background script; the
choice is persisted to the allowed or denied list when "Remember" is
checked)
- Popup closed without answering → treated as a denial
#### TxApproval #### TxApproval (`approve-tx`)
- **When**: A connected website requests a transaction via - **When**: A connected website requests a transaction via
`eth_sendTransaction`. Opened via the toolbar popup by the background script. `eth_sendTransaction`. Always opened in a separate popup window by the
background script (`windows.create()`), because the request is triggered
programmatically rather than by a user gesture.
- **Elements**: - **Elements**:
- "Transaction Request" heading - "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site hostname (bold) + "wants to send a transaction" - Site hostname (bold) + "wants to send a transaction"
- Decoded action (if calldata is recognized): action name, token details, - Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline (see Transaction Decoding) amounts, steps, deadline (see Transaction Decoding)
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- To/Contract: color dot + full address + etherscan link (or "contract - Contract: color dot + full address + etherscan link (or "contract
creation"), token symbol label if known creation"), token symbol label if known
- Value: amount in ETH (4 decimal places) - Value: amount in ETH (4 decimal places, USD in parentheses)
- Raw data: full calldata displayed inline (shown if present) - Raw data: full calldata displayed inline (shown if present)
- Password input - Password input and an error line
- "Confirm" / "Reject" buttons - "Confirm" / "Reject" buttons
- **Transitions**: - **Transitions**:
- "Confirm" (with password) → closes popup (returns result to background) - "Confirm" (correct password) → decrypts and signs in the popup, hands the
signed transaction to the background to broadcast, then → **WaitTx** in
the same popup window
- "Confirm" (wrong password) → error line, no screen change
- "Reject" → closes popup (returns rejection to background) - "Reject" → closes popup (returns rejection to background)
- Popup window closed without answering → the request is rejected with
EIP-1193 code 4001
#### SignApproval #### SignApproval (`approve-sign`)
- **When**: A connected website requests a message signature via - **When**: A connected website requests a message signature via
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened via the toolbar `personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
popup by the background script. TxApproval, in a separate popup window.
- **Elements**: - **Elements**:
- "Signature Request" heading - "Signature Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site hostname (bold) + "wants you to sign a message" - Site hostname (bold) + "wants you to sign a message"
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
- Type: "Personal message" or "Typed data (EIP-712)" - Type: "Personal message" or "Typed data (EIP-712)"
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/ - Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
message fields (EIP-712 typed data) message fields (EIP-712 typed data)
- Password input - Password input and an error line
- "Sign" / "Reject" buttons - "Sign" / "Reject" buttons
- **Transitions**: - **Transitions**:
- "Sign" (with password) → signs locally → closes popup (returns signature) - "Sign" (correct password) → signs locally → closes popup (returns
signature)
- "Sign" (wrong password, or a signing failure) → error line, no screen
change
- "Reject" → closes popup (returns rejection to background) - "Reject" → closes popup (returns rejection to background)
- Popup window closed without answering → the request is rejected with
EIP-1193 code 4001
### External Services ### External Services
@@ -696,7 +892,7 @@ communicates with three external services to function as a wallet:
What the extension does NOT do: What the extension does NOT do:
- No analytics or telemetry services - No analytics or telemetry services
- No token list APIs (user adds tokens manually by contract address) - No token list APIs (the top-250 token list is bundled at build time)
- No Infura/Alchemy dependency (any JSON-RPC endpoint works) - No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer - No backend servers operated by the developer
@@ -815,10 +1011,12 @@ hardcoded test phrase.
- Create new HD wallet (generates 12-word recovery phrase) - Create new HD wallet (generates 12-word recovery phrase)
- Import HD wallet from existing 12 or 24 word recovery phrase - Import HD wallet from existing 12 or 24 word recovery phrase
- Import single-address wallet from private key - Import single-address wallet from private key
- Import multi-address wallet from an extended private key (`xprv`)
- Add multiple addresses within an HD wallet - Add multiple addresses within an HD wallet
- Manage multiple wallets simultaneously - Manage multiple wallets simultaneously
- View ETH balance per address - View ETH balance per address
- View ERC-20 token balances (user adds token by contract address) - View ERC-20 token balances (bundled top-250 tokens, tokens with 1,000 or more
holders, and tokens the user adds by contract address)
- Send ETH to an address - Send ETH to an address
- Send ERC-20 tokens to an address - Send ERC-20 tokens to an address
- Receive ETH/tokens (display address, copy to clipboard, QR code) - Receive ETH/tokens (display address, copy to clipboard, QR code)
@@ -964,7 +1162,8 @@ Currently supported:
- Built in token swaps (use a DEX in the browser) - Built in token swaps (use a DEX in the browser)
- Analytics, telemetry, or tracking of any kind - Analytics, telemetry, or tracking of any kind
- Advertisements or promotions - Advertisements or promotions
- Obscure token list auto-discovery (user adds tokens manually) - Obscure token list auto-discovery — nothing outside the bundled list, the
1,000-holder floor, and the tokens the user added by contract address
- We detect common/popular ERC20s in the basic case - We detect common/popular ERC20s in the basic case
- Fiat on/off ramps - Fiat on/off ramps
- Extensive transaction decoding/parsing - Extensive transaction decoding/parsing
@@ -986,7 +1185,7 @@ Currently supported:
### Transactions ### Transactions
- [ ] Gas estimation and fee display before confirming - [x] Gas estimation and fee display before confirming
### Testing ### Testing
@@ -1021,14 +1220,18 @@ This repository includes data files from third-party projects that are not
covered by the GPL-3.0 license above. These files, their copyright holders, and covered by the GPL-3.0 license above. These files, their copyright holders, and
their licenses are: their licenses are:
| File | Source | Copyright | License | | File | Source | Copyright | License |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- | | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
| `src/shared/phishingBlocklist.json` | [eth-phishing-detect](https://github.com/AugurProject/eth-phishing-detect) community-maintained phishing domain blocklist | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) | | `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, vendored from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT | | `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT | | `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
The full license texts for these third-party files are included in the The full license texts for these third-party files are included in the
[LICENSE](LICENSE) file. [LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
link because the upstream is hosted under a competitor's organization name,
which project policy keeps out of code and documentation; the vendored copy and
the runtime refresh both come from that upstream, whose URL is the
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`.
## Author ## Author

29
TODO.md
View File

@@ -44,13 +44,42 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-11: Approval verification became an allowlist — transaction type
restricted to 0/1/2 so an EIP-7702 delegation can no longer ride along on an
approved transfer, every consequential field compared, and the artifact
re-serialized from the checked fields alone; broadcast failure is now terminal
([#174](https://git.eeqj.de/sneak/AutistMask/issues/174)).
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
the Makefile-only targets documented in the README
([#166](https://git.eeqj.de/sneak/AutistMask/issues/166)).
- 2026-08-11: `script/verify-build` diagnostics corrected: the both-markers
message now states what is and is not proven, an unreadable bundle is
diagnosed as an I/O fault rather than as changed output, the `*.js` assumption
lives only in `build.js`, and the unlisted-bundle scan hard-fails when it
cannot enumerate `dist/`
([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)).
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
attribution, token-display rule, navigation model
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
- 2026-08-11: README Screen Map rebuilt from the code — every screen, element
and transition re-verified against `src/popup/`
([#164](https://git.eeqj.de/sneak/AutistMask/issues/164)).
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names, - 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
all five network destinations documented, password/Settings/Add Wallet all five network destinations documented, password/Settings/Add Wallet
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)). sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
- 2026-08-11: `loadState()` now derives `hasWallet` from the wallet list instead
of trusting the persisted flag, so a profile already saved inconsistent no
longer stays broken on every load
([#195](https://git.eeqj.de/sneak/AutistMask/issues/195)).
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the - 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
remaining wallets, the selection only moves when it was deleted, and the remaining wallets, the selection only moves when it was deleted, and the
active-address change is broadcast to connected sites active-address change is broadcast to connected sites
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)). ([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
- 2026-08-11: One row per on-chain value movement in transaction history: the
merge moved into the pure `mergeTransactions` and the zero-ETH native side of
a plain ERC-20 transfer absorbed into its token row
([#177](https://git.eeqj.de/sneak/AutistMask/issues/177)).
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model - 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
on `next`, with Status and Next Step refreshed on `next`, with Status and Next Step refreshed
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)). ([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).

View File

@@ -29,6 +29,12 @@ function repoRelative(p) {
// reports every input that contributed to an output in the metafile, which is // reports every input that contributed to an output in the metafile, which is
// the authoritative answer to "is constants.js in this bundle" — unlike // the authoritative answer to "is constants.js in this bundle" — unlike
// searching the minified text, it does not depend on what survived minification. // searching the minified text, it does not depend on what survived minification.
//
// The ".js" filter below is the only place that assumption lives:
// script/verify-build searches every file and symlink under dist/ for a
// marker, without filtering by extension, and hard-fails if it cannot walk the
// whole tree, so a bundle emitted under some other extension fails there as
// unlisted rather than escaping both checks at once.
function outputsContainingAuditedModule(metafile) { function outputsContainingAuditedModule(metafile) {
return Object.entries(metafile.outputs) return Object.entries(metafile.outputs)
.filter(([outFile, info]) => { .filter(([outFile, info]) => {
@@ -115,8 +121,17 @@ async function build() {
// build that never gets around to writing one cannot be verified against // build that never gets around to writing one cannot be verified against
// a stale list. // a stale list.
fs.rmSync(BUNDLE_MANIFEST, { force: true }); fs.rmSync(BUNDLE_MANIFEST, { force: true });
// The locally installed binary, not `npx` — npx silently fetches from the
// registry when the binary is absent, which is an unpinned network fetch
// in the middle of a build.
const tailwindBin = path.join(
__dirname,
"node_modules",
".bin",
"tailwindcss",
);
execSync( execSync(
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`, `"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
{ stdio: "inherit" }, { stdio: "inherit" },
); );

View File

@@ -7,6 +7,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"test": "jest --forceExit", "test": "jest --forceExit",
"test:verbose": "jest --forceExit --verbose",
"build": "node build.js", "build": "node build.js",
"lint": "prettier --check .", "lint": "prettier --check .",
"fmt": "prettier --write .", "fmt": "prettier --write .",

View File

@@ -7,7 +7,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Running tests..." echo "Running tests..."
timeout 30 yarn run test 2>&1 timeout 30 yarn run test 2>&1 || {
echo "--- Rerunning with --verbose for details ---"
timeout 30 yarn run test:verbose 2>&1 || true
# Always fail: the first run already proved the tests are broken, so a
# flaky pass on the rerun must not turn the build green.
exit 1
}
} }
main "$@" main "$@"

View File

@@ -34,16 +34,51 @@ fail() {
exit 1 exit 1
} }
# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match
# (exit 1) are answers about the emitted output. Anything else (exit 2: the
# file could not be read) is not an answer at all, and must not be reported as
# "no marker" — that would blame the bundle for a permissions or I/O fault.
has_marker() { has_marker() {
grep -q -F "$1" "$2" 2>/dev/null _hm_status=0
grep -q -F -e "$1" -- "$2" || _hm_status=$?
case "$_hm_status" in
0) return 0 ;;
1) return 1 ;;
*)
fail "grep exited $_hm_status reading $2, so the file could not be
searched and its DEBUG state was not checked at all. That is a permissions
or I/O fault on the artifact, not a change in the emitted output. Refusing
to report success."
;;
esac
}
# Does the manifest list the path $1, as a whole line? Same discipline as
# has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the
# manifest could not be read and is not an answer at all. Without this, an
# unreadable manifest reads as "this file is not listed" and every emitted
# bundle gets reported as an unlisted one.
is_listed() {
_il_status=0
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
case "$_il_status" in
0) return 0 ;;
1) return 1 ;;
*)
fail "grep exited $_il_status reading $MANIFEST, so it could not be
searched and nothing was established about which bundles it lists. That is
a permissions or I/O fault on the manifest, not a stale manifest. Refusing
to report success."
;;
esac
} }
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be # Read one bundle's DEBUG state into MARKER. Exactly one marker must be
# present. Both means the ternary in constants.js was never folded, which is # present. Both means the ternary in constants.js was never folded, which is
# what happens when the __BUILD_DEBUG__ define goes missing from build.js: # what happens when the __BUILD_DEBUG__ define goes missing from build.js:
# DEBUG stops being known at build time and the debug branch is live again. # DEBUG stops being known at build time. Neither means we are reading output
# Neither means we are reading output we do not understand. Both are hard # we do not understand. Both are hard failures; neither is ever treated as
# failures; neither is ever treated as absence of a problem. # absence of a problem.
read_marker() { read_marker() {
_file="$1" _file="$1"
_on=no _on=no
@@ -52,9 +87,14 @@ read_marker() {
if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi
if [ "$_on" = yes ] && [ "$_off" = yes ]; then if [ "$_on" = yes ] && [ "$_off" = yes ]; then
fail "$_file carries both debug markers, so the build-time DEBUG value fail "$_file carries both debug markers, so DEBUG was not resolved at
was never resolved and the debug branch is still live. Check that build.js build time: the ternary in src/shared/constants.js survived into the
still defines __BUILD_DEBUG__." emitted output. This does not mean the debug branch is live in this
artifact: an unresolved __BUILD_DEBUG__ is undeclared in extension
context, so DEBUG evaluates to false at runtime. It does mean the
release/debug distinction is no longer enforced at build time, and which
way that fallback happens to evaluate is then an accident a refactor can
flip. Check that build.js still defines __BUILD_DEBUG__."
fi fi
if [ "$_on" = no ] && [ "$_off" = no ]; then if [ "$_on" = no ] && [ "$_off" = no ]; then
fail "$_file carries no debug marker, so its DEBUG state cannot be fail "$_file carries no debug marker, so its DEBUG state cannot be
@@ -70,13 +110,43 @@ read_marker() {
} }
# The manifest says which bundles must carry a marker. This says no other # The manifest says which bundles must carry a marker. This says no other
# emitted bundle may carry one, which catches a manifest that has gone stale # emitted file may carry one, which catches a manifest that has gone stale
# or short rather than trusting whatever it happens to list. # or short rather than trusting whatever it happens to list.
#
# Deliberately unfiltered by extension. build.js selects manifest entries with
# an endsWith(".js") test; repeating that literal here would mean a bundle
# emitted under some other extension escaped the manifest AND this check at
# once, which is the correlated blind spot the two-source design exists to
# avoid. Every file under dist/ is searched, so build.js's filter is the only
# place the assumption lives and this check is what catches it being wrong.
#
# That claim only holds if the walk is exhaustive, so two things are enforced
# here rather than assumed:
#
# - find's exit status is checked. A subtree it cannot descend is reported on
# stderr and then simply missing from the listing, so an unchecked status
# turns "could not look" into "nothing was there" — the same conflation
# has_marker exists to prevent. The status cannot be read off a pipeline
# ending in sort, so the sort is a separate step.
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
# reachable under an unlisted path in dist/ is a stale manifest whether the
# path is a link or a file, and grep reads through the link. A link that
# cannot be read through — dangling, or pointing at a directory — fails
# hard via has_marker's exit-2 path, which is the fail-closed answer: the
# build emits neither, so their DEBUG state is unproven, not fine.
check_unlisted_bundles() { check_unlisted_bundles() {
_listing="$(find dist -type f -name '*.js' | sort)" _find_status=0
_listing="$(find dist \( -type f -o -type l \) -print)" || _find_status=$?
[ "$_find_status" -eq 0 ] ||
fail "find exited $_find_status enumerating dist/, so part of the tree
was never walked and nothing was established about the files in it. Any
unlisted bundle there went unchecked. That is a permissions or I/O fault on
the artifact, not a stale manifest. Refusing to report success."
_listing="$(printf '%s\n' "$_listing" | sort)"
while read -r _file; do while read -r _file; do
[ -n "$_file" ] || continue [ -n "$_file" ] || continue
if grep -q -x -F "$_file" "$MANIFEST"; then if is_listed "$_file"; then
continue continue
fi fi
if has_marker "$MARKER_ON" "$_file" || if has_marker "$MARKER_ON" "$_file" ||
@@ -113,12 +183,18 @@ main() {
fail "$MANIFEST is empty, so no emitted bundle was found to contain fail "$MANIFEST is empty, so no emitted bundle was found to contain
src/shared/constants.js. That is never correct, so it is a failure and not src/shared/constants.js. That is never correct, so it is a failure and not
a pass." a pass."
[ -r "$MANIFEST" ] ||
fail "$MANIFEST is not readable, so nothing was inspected. That is a
permissions or I/O fault, not a pass."
count=0 count=0
while read -r file; do while read -r file; do
[ -n "$file" ] || continue [ -n "$file" ] || continue
[ -f "$file" ] || [ -f "$file" ] ||
fail "$MANIFEST lists $file, which does not exist." fail "$MANIFEST lists $file, which does not exist."
[ -s "$file" ] ||
fail "$MANIFEST lists $file, which is empty. An empty bundle
carries no marker and proves nothing, so this is a failure and not a pass."
read_marker "$file" read_marker "$file"
[ "$MARKER" = "$expected" ] || [ "$MARKER" = "$expected" ] ||
fail "$file is $MARKER but this build expects $expected." fail "$file is $MARKER but this build expects $expected."

View File

@@ -13,7 +13,15 @@ const {
} = require("../shared/state"); } = require("../shared/state");
const { refreshBalances, getProvider } = require("../shared/balances"); const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch } = require("../shared/log"); const { debugFetch } = require("../shared/log");
const { verifySignedTx, verifySignature } = require("../shared/approvalVerify"); const {
verifySignedTx,
verifySignature,
failureIsRetryable,
describeTxFailure,
TX_STAGE_SIGN,
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
} = require("../shared/approvalVerify");
const { const {
isPhishingDomain, isPhishingDomain,
updatePhishingList, updatePhishingList,
@@ -100,6 +108,15 @@ function resetPopupUrl() {
} }
} }
// Retire a pending approval. Only called once the request it belongs to has
// an outcome: an approval that failed in a way the user can retry stays in
// pendingApprovals, so a second attempt signs the same approved payload
// instead of finding nothing to sign.
function finishApproval(id) {
delete pendingApprovals[id];
resetPopupUrl();
}
// Open approval in a separate popup window. // Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically, // This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals. // not from a user gesture) and the fallback for site-connection approvals.
@@ -713,21 +730,25 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_TX_RESPONSE") { if (msg.type === "AUTISTMASK_TX_RESPONSE") {
const approval = pendingApprovals[msg.id]; const approval = pendingApprovals[msg.id];
if (!approval) return false; if (!approval) return false;
delete pendingApprovals[msg.id];
resetPopupUrl();
if (!msg.approved) { if (!msg.approved) {
finishApproval(msg.id);
approval.resolve({ approval.resolve({
error: { code: 4001, message: "User rejected the request." }, error: { code: 4001, message: "User rejected the request." },
}); });
return true; return true;
} }
// The popup signs; it reports back here when it could not. Fail the // The popup signs; it reports back here when it could not. Keep the
// request the same way this handler used to when it did the signing. // approval so the user can correct the problem and try again with the
// transaction they already saw.
if (msg.error) { if (msg.error) {
approval.resolve({ error: { message: msg.error } }); const outcome = describeTxFailure(TX_STAGE_SIGN, msg.error);
sendResponse({ error: msg.error }); sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: TX_STAGE_SIGN,
});
return false; return false;
} }
@@ -737,22 +758,52 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
// The popup holds the secret, but the background stays the // The popup holds the secret, but the background stays the
// authority on what is broadcast: the raw transaction must be // authority on what is broadcast: the raw transaction must be
// the approved one, signed by the approved address. // the approved one, signed by the approved address, on the
// network that is selected.
verifySignedTx( verifySignedTx(
msg.rawSignedTx, msg.rawSignedTx,
approval.txParams, approval.txParams,
activeAddress, activeAddress,
currentNetwork().chainId,
); );
} catch (e) {
// A signed transaction that is not the approved one is not
// retried against that approval; it is refused outright.
// Anything else that failed before the check ran is the
// user's to retry.
const outcome = describeTxFailure(TX_STAGE_VERIFY, e);
if (outcome.spendApproval) {
finishApproval(msg.id);
approval.resolve({ error: { message: outcome.error } });
}
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: TX_STAGE_VERIFY,
});
return;
}
try {
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
const tx = await provider.broadcastTransaction(msg.rawSignedTx); const tx = await provider.broadcastTransaction(msg.rawSignedTx);
finishApproval(msg.id);
approval.resolve({ txHash: tx.hash }); approval.resolve({ txHash: tx.hash });
sendResponse({ txHash: tx.hash }); sendResponse({ txHash: tx.hash });
} catch (e) { } catch (e) {
const errMsg = e.shortMessage || e.message; // Terminal, never retried: the node may have accepted the
approval.resolve({ // transaction and still failed to answer, and the popup's
error: { message: errMsg }, // retry re-signs at a freshly fetched nonce rather than
// re-broadcasting these bytes. Retrying would send the
// approved transfer a second time.
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
finishApproval(msg.id);
approval.resolve({ error: { message: outcome.error } });
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: TX_STAGE_BROADCAST,
}); });
sendResponse({ error: errMsg });
} }
})(); })();
return true; return true;
@@ -761,21 +812,20 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_SIGN_RESPONSE") { if (msg.type === "AUTISTMASK_SIGN_RESPONSE") {
const approval = pendingApprovals[msg.id]; const approval = pendingApprovals[msg.id];
if (!approval) return false; if (!approval) return false;
delete pendingApprovals[msg.id];
resetPopupUrl();
if (!msg.approved) { if (!msg.approved) {
finishApproval(msg.id);
approval.resolve({ approval.resolve({
error: { code: 4001, message: "User rejected the request." }, error: { code: 4001, message: "User rejected the request." },
}); });
return true; return true;
} }
// The popup signs; it reports back here when it could not. Fail the // The popup signs; it reports back here when it could not. Keep the
// request the same way this handler used to when it did the signing. // approval so the user can correct the problem and try again with the
// message they already saw.
if (msg.error) { if (msg.error) {
approval.resolve({ error: { message: msg.error } }); sendResponse({ error: msg.error, retryable: true });
sendResponse({ error: msg.error });
return false; return false;
} }
@@ -788,14 +838,17 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// address. // address.
const signature = msg.signature; const signature = msg.signature;
verifySignature(approval.signParams, signature, activeAddress); verifySignature(approval.signParams, signature, activeAddress);
finishApproval(msg.id);
approval.resolve({ signature }); approval.resolve({ signature });
sendResponse({ signature }); sendResponse({ signature });
} catch (e) { } catch (e) {
const errMsg = e.shortMessage || e.message; const errMsg = e.shortMessage || e.message;
approval.resolve({ const retryable = failureIsRetryable(e);
error: { message: errMsg }, if (!retryable) {
}); finishApproval(msg.id);
sendResponse({ error: errMsg }); approval.resolve({ error: { message: errMsg } });
}
sendResponse({ error: errMsg, retryable });
} }
})(); })();
return true; return true;

View File

@@ -22,6 +22,7 @@ const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { describeSigningFailure } = require("../../shared/approvalVerify");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap"); const uniswap = require("../../shared/uniswap");
const runtime = const runtime =
@@ -546,10 +547,20 @@ function init(ctx) {
runtime.sendMessage(payload, (response) => { runtime.sendMessage(payload, (response) => {
if (response && response.txHash) { if (response && response.txHash) {
txStatus.showWait(pendingTxDetails, response.txHash); txStatus.showWait(pendingTxDetails, response.txHash);
return;
}
// A retryable failure leaves the approval pending in the
// background, so stay on this screen with a live button rather
// than sending the user to a dead end.
const outcome = describeSigningFailure(
response,
"The transaction could not be sent.",
);
if (outcome.retryable) {
showError("approve-tx-error", outcome.message);
setTxButtonBusy(false);
} else { } else {
const msg = txStatus.showError(pendingTxDetails, null, outcome.message);
(response && response.error) || "Transaction failed.";
txStatus.showError(pendingTxDetails, null, msg);
} }
}); });
}); });
@@ -644,11 +655,18 @@ function init(ctx) {
runtime.sendMessage(payload, (response) => { runtime.sendMessage(payload, (response) => {
if (response && response.signature) { if (response && response.signature) {
window.close(); window.close();
} else { return;
const msg = (response && response.error) || "Signing failed.";
showError("approve-sign-error", msg);
setSignButtonBusy(false);
} }
// The button comes back only when the approval is still pending in
// the background; otherwise it stays disabled and the message says
// why, because a control that cannot succeed must not look like it
// can.
const outcome = describeSigningFailure(
response,
"The message could not be signed.",
);
showError("approve-sign-error", outcome.message);
if (outcome.retryable) setSignButtonBusy(false);
}); });
}); });

View File

@@ -7,17 +7,139 @@
// the signer from the artifact and checks it against the approval it is // the signer from the artifact and checks it against the approval it is
// holding before acting on it. All recovery is delegated to ethers. // holding before acting on it. All recovery is delegated to ethers.
// //
// The check is an allowlist, in both directions, because a denylist cannot be
// correct against a transaction format that keeps gaining fields:
//
// - only transaction types 0, 1 and 2 are accepted. Every later EIP-2718 type
// adds a field with consequences of its own — EIP-7702's authorizationList
// rewrites the code at the signer's own account, EIP-4844's blob
// commitments carry a separate fee — and a check that enumerates the fields
// it refuses admits every one of them by default.
// - after the per-field comparisons, the artifact is rebuilt from those
// checked fields and nothing else, and the two are compared byte for byte.
// Anything the artifact carries that this module does not name is absent
// from the rebuild and changes the bytes, so the final assertion is that
// the artifact *is* the approved transaction, not merely that it is not one
// of the tampered shapes that were thought of.
//
// Every consequential field is compared, and a mismatch is a refusal to act,
// never a warning: what the user approved is what gets broadcast, or nothing
// does.
//
// Fields the approval does not carry are not treated as zero. The popup
// populates nonce, gas limit, fee and chain id through populateTransaction()
// when the requesting page did not fix them, so there is no approved value to
// compare against; treating absent as zero would refuse every legitimate
// transaction. Those fields are instead held to the absolute ceilings below,
// and the chain id is always checked against the selected network rather than
// against the approval alone, which is what makes a cross-chain replay
// impossible.
//
// Every failure message is a full sentence, because these strings are shown to // Every failure message is a full sentence, because these strings are shown to
// the user and returned to the dApp. // the user and returned to the dApp.
const { const {
Transaction, Transaction,
accessListify,
getAddress, getAddress,
getBytes, getBytes,
verifyMessage, verifyMessage,
verifyTypedData, verifyTypedData,
} = require("ethers"); } = require("ethers");
// The only transaction types this wallet signs: legacy, EIP-2930 and
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
// be an artifact of an approval this wallet raised.
const ALLOWED_TX_TYPES = [0, 1, 2];
// The serialized fields of each allowed type, which is also the complete set
// of fields the checks below compare or bound. The artifact is rebuilt from
// exactly these at the end of verification and compared byte for byte, so a
// field outside this table cannot ride along unexamined.
const SERIALIZED_FIELDS = {
0: ["chainId", "nonce", "gasPrice", "gasLimit", "to", "value", "data"],
1: [
"chainId",
"nonce",
"gasPrice",
"gasLimit",
"to",
"value",
"data",
"accessList",
],
2: [
"chainId",
"nonce",
"maxPriorityFeePerGas",
"maxFeePerGas",
"gasLimit",
"to",
"value",
"data",
"accessList",
],
};
// Fields no allowed type may carry. The type allowlist already excludes every
// type that defines them, and the structural check at the end of verification
// would catch them anyway; they are named here so that an artifact carrying
// one is refused with a message that says what it was.
const FORBIDDEN_FIELDS = [
{
key: "authorizationList",
message:
"The signed transaction would hand the signing account over to another contract, which was not approved.",
},
{
key: "blobVersionedHashes",
message:
"The signed transaction carries blob commitments, which were not approved.",
},
{
key: "blobs",
message:
"The signed transaction carries blobs, which were not approved.",
},
{
key: "maxFeePerBlobGas",
message:
"The signed transaction carries a blob gas fee, which was not approved.",
},
];
// Above the block gas limit of every supported network (see networks.js), so
// no transaction that could ever be included is refused by it.
const MAX_GAS_LIMIT = 100000000n;
// 100,000 gwei per gas: orders of magnitude above the highest fee either
// supported network has produced, and low enough to catch a fee that would
// hand the validator the balance.
const MAX_FEE_PER_GAS = 100000000000000n;
// A refusal to act on an artifact: it is not the thing that was approved, so
// the approval it was offered against is spent and must not be retried. Every
// throw in this module is one of these; the background distinguishes them from
// transient failures (a busy node, a failed broadcast), which leave the
// approval standing so the user can try again.
class ApprovalMismatchError extends Error {
constructor(message) {
super(message);
this.name = "ApprovalMismatchError";
this.approvalMismatch = true;
}
}
function refuse(message) {
return new ApprovalMismatchError(message);
}
// Whether a signing failure leaves the approval usable. Anything that is not a
// mismatch is the user's to correct and retry.
function failureIsRetryable(err) {
return !(err && err.approvalMismatch === true);
}
// Case-insensitive address comparison that tolerates absent values on either // Case-insensitive address comparison that tolerates absent values on either
// side. Two absent addresses compare equal (contract creation has no `to`). // side. Two absent addresses compare equal (contract creation has no `to`).
function sameAddress(a, b) { function sameAddress(a, b) {
@@ -31,11 +153,64 @@ function sameAddress(a, b) {
} }
} }
// Whether the approval fixed a value for a field at all.
function present(v) {
return v !== null && v !== undefined && v !== "";
}
// Whether a field carries anything at all. An empty array is nothing: ethers
// reports an absent access list on a type 2 transaction as `[]`.
function carriesValue(v) {
if (!present(v)) return false;
if (Array.isArray(v)) return v.length > 0;
return true;
}
// Normalize a quantity that must be present, refusing anything that is not a
// number: an approval carrying junk in a fee field cannot be compared, and an
// uncomparable field is a refusal rather than a pass.
function normalizeQuantity(v, label) {
try {
return BigInt(v);
} catch {
throw refuse(
"The approved " +
label +
" is not a number, so it cannot be" +
" compared with the signed transaction.",
);
}
}
// Normalize a transaction value (hex string, decimal string, number or // Normalize a transaction value (hex string, decimal string, number or
// bigint) to a bigint. An absent value is zero, matching ethers. // bigint) to a bigint. An absent value is zero, matching ethers. The value is
// page-controlled, so it goes through the same refusal as every other
// quantity rather than throwing a raw BigInt conversion error.
function normalizeValue(v) { function normalizeValue(v) {
if (v === null || v === undefined || v === "") return 0n; if (!present(v)) return 0n;
return BigInt(v); return normalizeQuantity(v, "value");
}
// Normalize an access list to a comparable string. An absent or empty list is
// the empty string, so absent and `[]` are the same thing.
function normalizeAccessList(v) {
if (!carriesValue(v)) return "";
let list;
try {
list = accessListify(v);
} catch {
throw refuse(
"The approved access list is not a valid access list, so it cannot be compared with the signed transaction.",
);
}
return list
.map(
(entry) =>
String(entry.address).toLowerCase() +
":" +
entry.storageKeys.map((k) => String(k).toLowerCase()).join(","),
)
.join(";");
} }
// Normalize call data to a lowercase hex string. Absent data is "0x". // Normalize call data to a lowercase hex string. Absent data is "0x".
@@ -44,44 +219,198 @@ function normalizeData(v) {
return String(v).toLowerCase(); return String(v).toLowerCase();
} }
// Quantity fields the requesting page may fix in the approval. Each is
// compared exactly when the approval carries it, and left to the ceilings
// above when it does not.
const APPROVED_QUANTITIES = [
{
key: "nonce",
label: "nonce",
message: "The signed transaction does not carry the approved nonce.",
},
{
key: "gasLimit",
label: "gas limit",
message:
"The signed transaction does not carry the approved gas limit.",
},
{
key: "gasPrice",
label: "gas price",
message:
"The signed transaction does not carry the approved gas price.",
},
{
key: "maxFeePerGas",
label: "maximum fee per gas",
message:
"The signed transaction does not carry the approved maximum fee per gas.",
},
{
key: "maxPriorityFeePerGas",
label: "maximum priority fee per gas",
message:
"The signed transaction does not carry the approved maximum priority fee per gas.",
},
];
// Refuse a field only a transaction type this wallet does not sign can carry.
// The type allowlist keeps these unreachable in production, which is exactly
// what they are for; it also means nothing else exercises them, so this is
// exported and tested on its own rather than left to be believed.
function assertNoForbiddenFields(parsed) {
for (const field of FORBIDDEN_FIELDS) {
if (carriesValue(parsed[field.key])) throw refuse(field.message);
}
}
// Closing structural check. Rebuild the transaction from the fields the
// comparisons cover, and nothing else, then compare the unsigned bytes. Every
// field carried by the artifact but absent from the rebuild changes the
// serialization, so this refuses anything this module does not account for —
// including a field a future ethers learns to parse onto an allowed type —
// instead of waving it through by not naming it. Also exported for its own
// test: nothing reachable today can make the bytes differ.
function assertNothingUnchecked(parsed) {
let rebuilt;
try {
const fields = { type: parsed.type };
for (const key of SERIALIZED_FIELDS[parsed.type]) {
fields[key] = parsed[key];
}
rebuilt = Transaction.from(fields);
} catch {
throw refuse(
"The signed transaction could not be rebuilt from the fields that were checked, so it cannot be shown to be the approved transaction.",
);
}
if (rebuilt.unsignedSerialized !== parsed.unsignedSerialized) {
throw refuse(
"The signed transaction carries data beyond the fields that were checked against the approval.",
);
}
}
// Assert that a raw signed transaction is the transaction the user approved, // Assert that a raw signed transaction is the transaction the user approved,
// signed by the address the approval was raised for. Returns the parsed // signed by the address the approval was raised for, on the network that is
// ethers Transaction on success, throws otherwise. // selected. Returns the parsed ethers Transaction on success, throws
function verifySignedTx(rawSignedTx, txParams, expectedFrom) { // otherwise.
function verifySignedTx(rawSignedTx, txParams, expectedFrom, selectedChainId) {
if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) { if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) {
throw new Error("The signed transaction is missing or malformed."); throw refuse("The signed transaction is missing or malformed.");
} }
let parsed; let parsed;
try { try {
parsed = Transaction.from(rawSignedTx); parsed = Transaction.from(rawSignedTx);
} catch { } catch {
throw new Error("The signed transaction could not be decoded."); throw refuse("The signed transaction could not be decoded.");
} }
if (!parsed.from) { if (!parsed.from) {
throw new Error("The signed transaction carries no valid signature."); throw refuse("The signed transaction carries no valid signature.");
} }
if (!sameAddress(parsed.from, expectedFrom)) { if (!sameAddress(parsed.from, expectedFrom)) {
throw new Error( throw refuse(
"The signed transaction was signed by a different address than the one that was approved.", "The signed transaction was signed by a different address than the one that was approved.",
); );
} }
// Before any field is looked at: the type decides which fields exist at
// all, so an unrecognised type is refused outright rather than compared
// field by field against an approval that cannot describe it.
if (!ALLOWED_TX_TYPES.includes(parsed.type)) {
throw refuse(
"The signed transaction is of a type this wallet does not sign, so what it would do beyond the approved transfer cannot be checked.",
);
}
assertNoForbiddenFields(parsed);
// The selected network, not the artifact, is the authority on which chain
// this may be broadcast to; without it nothing can be verified.
if (!present(selectedChainId)) {
throw refuse(
"The selected network is unknown, so the signed transaction cannot be checked against it.",
);
}
if (parsed.chainId !== normalizeQuantity(selectedChainId, "network")) {
throw refuse(
"The signed transaction is for a different network than the one that is selected.",
);
}
if (
present(txParams.chainId) &&
parsed.chainId !== normalizeQuantity(txParams.chainId, "network")
) {
throw refuse(
"The signed transaction is for a different network than the one that was approved.",
);
}
if (!sameAddress(parsed.to, txParams.to)) { if (!sameAddress(parsed.to, txParams.to)) {
throw new Error( throw refuse(
"The signed transaction does not go to the approved recipient.", "The signed transaction does not go to the approved recipient.",
); );
} }
if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) { if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) {
throw new Error( throw refuse(
"The signed transaction does not carry the approved value.", "The signed transaction does not carry the approved value.",
); );
} }
if (normalizeData(parsed.data) !== normalizeData(txParams.data)) { if (normalizeData(parsed.data) !== normalizeData(txParams.data)) {
throw new Error( throw refuse(
"The signed transaction does not carry the approved call data.", "The signed transaction does not carry the approved call data.",
); );
} }
if (
normalizeAccessList(parsed.accessList) !==
normalizeAccessList(txParams.accessList)
) {
throw refuse(
"The signed transaction does not carry the approved access list.",
);
}
// An approval that fixed EIP-1559 fees must not be signed as a legacy
// transaction, and vice versa: the fee the user agreed to is only
// meaningful under the mechanism it was quoted in.
const approvedEip1559 =
present(txParams.maxFeePerGas) ||
present(txParams.maxPriorityFeePerGas);
const approvedLegacy = present(txParams.gasPrice);
const signedEip1559 = parsed.type === 2;
if (
(approvedEip1559 && !signedEip1559) ||
(approvedLegacy && signedEip1559)
) {
throw refuse(
"The signed transaction does not use the approved fee mechanism.",
);
}
for (const field of APPROVED_QUANTITIES) {
if (!present(txParams[field.key])) continue;
const approved = normalizeQuantity(txParams[field.key], field.label);
if (normalizeQuantity(parsed[field.key], field.label) !== approved) {
throw refuse(field.message);
}
}
if (parsed.gasLimit > MAX_GAS_LIMIT) {
throw refuse(
"The signed transaction sets a gas limit no network this wallet supports can accept.",
);
}
for (const key of ["gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) {
const fee = parsed[key];
if (fee !== null && fee !== undefined && fee > MAX_FEE_PER_GAS) {
throw refuse(
"The signed transaction sets a fee per gas far above any plausible value.",
);
}
}
assertNothingUnchecked(parsed);
return parsed; return parsed;
} }
@@ -91,7 +420,7 @@ function verifySignedTx(rawSignedTx, txParams, expectedFrom) {
// address on success, throws otherwise. // address on success, throws otherwise.
function verifySignature(signParams, signature, expectedFrom) { function verifySignature(signParams, signature, expectedFrom) {
if (typeof signature !== "string" || !signature.startsWith("0x")) { if (typeof signature !== "string" || !signature.startsWith("0x")) {
throw new Error("The signature is missing or malformed."); throw refuse("The signature is missing or malformed.");
} }
let recovered; let recovered;
@@ -109,11 +438,11 @@ function verifySignature(signParams, signature, expectedFrom) {
recovered = verifyTypedData(domain, types, message, signature); recovered = verifyTypedData(domain, types, message, signature);
} }
} catch { } catch {
throw new Error("The signature could not be verified."); throw refuse("The signature could not be verified.");
} }
if (!sameAddress(recovered, expectedFrom)) { if (!sameAddress(recovered, expectedFrom)) {
throw new Error( throw refuse(
"The signature was produced by a different address than the one that was approved.", "The signature was produced by a different address than the one that was approved.",
); );
} }
@@ -121,4 +450,83 @@ function verifySignature(signParams, signature, expectedFrom) {
return recovered; return recovered;
} }
module.exports = { verifySignedTx, verifySignature, sameAddress }; // The stage a transaction approval failed at. Which stage it is decides
// whether the approval survives the failure.
const TX_STAGE_SIGN = "sign";
const TX_STAGE_VERIFY = "verify";
const TX_STAGE_BROADCAST = "broadcast";
function errorText(err) {
if (typeof err === "string" && err !== "") return err;
if (err && (err.shortMessage || err.message)) {
return err.shortMessage || err.message;
}
return "The transaction could not be sent.";
}
// What the background does with a pending transaction approval after a failed
// attempt: what it tells the popup, and whether the approval is spent
// (resolved to the requesting page as an error and deleted) or left standing
// so the user can try the transaction they already saw again.
//
// - sign: the popup could not produce an artifact, almost always a wrong
// password. Nothing left the extension, so the approval stands.
// - verify: a mismatch is a refusal and spends the approval — an artifact
// that is not the approved transaction must never be retried against that
// approval. Anything else failed before the check ran and is retryable.
// - broadcast: always terminal. A broadcast that throws after the node
// accepted the transaction is routine (a timeout, a dropped response, a
// node answering "already known"), and the popup's retry does not
// re-broadcast these bytes — it re-runs populateTransaction() and signs
// again at a freshly fetched pending-tag nonce. Retrying would therefore
// put a second transaction on the chain for one approval.
function describeTxFailure(stage, err) {
const error = errorText(err);
const retryable =
stage === TX_STAGE_SIGN ||
(stage === TX_STAGE_VERIFY && failureIsRetryable(err));
return { error, retryable, spendApproval: !retryable };
}
// What the popup shows and does after the background reports a failed signing
// attempt. A retryable failure leaves the approval pending in the background,
// so the button goes back to being usable; a refusal spent the approval, and
// the popup says so rather than offering a button that cannot succeed.
//
// A failed broadcast gets its own wording: the transaction may already be on
// the network, so telling the user to start again from the site is exactly the
// wrong instruction.
function describeSigningFailure(response, fallbackMessage) {
let message = (response && response.error) || fallbackMessage;
if (!/[.!?]$/.test(message)) message += ".";
const retryable = !!(response && response.retryable);
if (!retryable) {
message +=
response && response.stage === TX_STAGE_BROADCAST
? " The transaction may still have reached the network." +
" Check the account before sending it again."
: " This request can no longer be signed. Please start it" +
" again from the site.";
}
return { message, retryable };
}
module.exports = {
verifySignedTx,
verifySignature,
assertNoForbiddenFields,
assertNothingUnchecked,
sameAddress,
failureIsRetryable,
describeTxFailure,
describeSigningFailure,
ApprovalMismatchError,
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
FORBIDDEN_FIELDS,
TX_STAGE_SIGN,
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
};

View File

@@ -84,8 +84,11 @@ async function loadState() {
const result = await storageApi.get("autistmask"); const result = await storageApi.get("autistmask");
if (result.autistmask) { if (result.autistmask) {
const saved = result.autistmask; const saved = result.autistmask;
state.hasWallet = saved.hasWallet;
state.wallets = saved.wallets || []; state.wallets = saved.wallets || [];
// Derived, never read from storage: a profile persisted with the flag
// out of step with the wallet list would otherwise stay broken on
// every load. Nothing depends on the two disagreeing.
state.hasWallet = state.wallets.length > 0;
state.trackedTokens = saved.trackedTokens || []; state.trackedTokens = saved.trackedTokens || [];
state.networkId = saved.networkId || DEFAULT_STATE.networkId; state.networkId = saved.networkId || DEFAULT_STATE.networkId;
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;

View File

@@ -113,6 +113,85 @@ function parseTokenTransfer(tt, addrLower) {
}; };
} }
// True when a parsed native entry moved no ETH. Contract-call entries have
// their amount fields blanked by parseTx, so they are never judged here.
function movedNoEther(tx) {
if (tx.direction === "contract") return false;
return BigInt(tx.rawAmount || "0") === BigInt(0);
}
// Merge parsed normal transactions with parsed ERC-20 token transfers into
// one row per distinct value movement. Pure: it reads only its arguments
// and returns a new list sorted newest block first.
//
// The merge key is the transaction hash for the native entry and
// hash + token contract for each token transfer, so:
//
// - A display-level contract call (a swap and friends, direction
// "contract") absorbs every token leg of its hash into the single
// native entry, because the legs are hops of one operation rather
// than separate movements the user made.
// - Otherwise each distinct token contract in the transaction keeps its
// own row, so a hash carrying several genuine transfers stays several
// rows.
// - The native entry of such a transaction is dropped when it moved no
// ETH and at least one token transfer shares its hash: that entry is
// the ERC-20 call itself, already represented by the token row. A
// native entry that moved ETH survives alongside the token rows, since
// the ETH and the tokens are two real movements, and a zero-value
// native transaction with no token transfer on its hash survives too.
function mergeTransactions(txs, tokenTransfers) {
const byKey = new Map();
// Entries are copied so consolidation never writes through to the
// caller's objects.
for (const tx of txs) {
byKey.set(tx.hash, { ...tx });
}
const absorbedHashes = new Set();
for (const parsed of tokenTransfers) {
const existing = byKey.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
if (existing && movedNoEther(existing)) {
absorbedHashes.add(parsed.hash);
}
// Every other token transfer gets its own entry.
byKey.set(parsed.hash + ":" + (parsed.contractAddress || ""), {
...parsed,
});
}
for (const hash of absorbedHashes) {
byKey.delete(hash);
}
const merged = [...byKey.values()];
merged.sort((a, b) => b.blockNumber - a.blockNumber);
return merged;
}
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
log.debugf("fetchRecentTransactions", address); log.debugf("fetchRecentTransactions", address);
const addrLower = address.toLowerCase(); const addrLower = address.toLowerCase();
@@ -145,53 +224,11 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
const txJson = txResp.ok ? await txResp.json() : {}; const txJson = txResp.ok ? await txResp.json() : {};
const ttJson = ttResp.ok ? await ttResp.json() : {}; const ttJson = ttResp.ok ? await ttResp.json() : {};
const txsByHash = new Map(); const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
);
for (const tx of txJson.items || []) {
txsByHash.set(tx.hash, parseTx(tx, addrLower));
}
// When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. For contract calls (swaps), a single transaction
// can produce multiple token transfers (input, intermediates, output).
// We consolidate these into the original tx entry using the token
// transfer where the user *receives* tokens (the swap output), so
// the transaction list shows the final result rather than confusing
// intermediate hops. We preserve the original tx's from/to so the
// user sees their own address, not a router or Permit2 contract.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
// Non-contract token transfers get their own entries.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
}
const txs = [...txsByHash.values()];
txs.sort((a, b) => b.blockNumber - a.blockNumber);
const result = txs.slice(0, count); const result = txs.slice(0, count);
log.debugf("fetchRecentTransactions done, count:", result.length); log.debugf("fetchRecentTransactions done, count:", result.length);
return result; return result;
@@ -265,4 +302,8 @@ function filterTransactions(txs, filters = {}) {
return { transactions: filtered, newFraudContracts: newFraud }; return { transactions: filtered, newFraudContracts: newFraud };
} }
module.exports = { fetchRecentTransactions, filterTransactions }; module.exports = {
fetchRecentTransactions,
filterTransactions,
mergeTransactions,
};

View File

@@ -2,7 +2,20 @@ const { Network, Transaction, Wallet } = require("ethers");
const { const {
verifySignedTx, verifySignedTx,
verifySignature, verifySignature,
assertNoForbiddenFields,
assertNothingUnchecked,
sameAddress, sameAddress,
failureIsRetryable,
describeTxFailure,
describeSigningFailure,
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
FORBIDDEN_FIELDS,
TX_STAGE_SIGN,
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
} = require("../src/shared/approvalVerify"); } = require("../src/shared/approvalVerify");
const { getSignerForAddress } = require("../src/shared/wallet"); const { getSignerForAddress } = require("../src/shared/wallet");
@@ -18,6 +31,10 @@ const other = new Wallet(OTHER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
// The chain id of the selected network, as networks.js carries it.
const SELECTED = "0x1";
const SEPOLIA = "0xaa36a7";
// Approved parameters as a dApp would supply them over eth_sendTransaction. // Approved parameters as a dApp would supply them over eth_sendTransaction.
const TX_PARAMS = { const TX_PARAMS = {
from: signer.address, from: signer.address,
@@ -27,25 +44,38 @@ const TX_PARAMS = {
gas: "0x5208", gas: "0x5208",
}; };
// The values populateTransaction() fills in when the dApp fixed none of them.
const POPULATED = {
chainId: 1,
nonce: 7,
gasLimit: 100000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
type: 2,
};
// Build a signable transaction from approved params. The popup does the same // Build a signable transaction from approved params. The popup does the same
// thing through populateTransaction(); here the fields are fixed so the test // thing through populateTransaction(); here the fields are fixed so the test
// needs no provider. // needs no provider. `overrides` stands in for what a tampered or misbuilt
function txFor(params) { // popup would put on the wire.
function txFor(params, overrides) {
return { return {
chainId: 1, ...POPULATED,
nonce: 7,
gasLimit: 100000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
type: 2,
to: params.to, to: params.to,
value: params.value === undefined ? 0n : BigInt(params.value), value: params.value === undefined ? 0n : BigInt(params.value),
data: params.data || "0x", data: params.data || "0x",
...(overrides || {}),
}; };
} }
async function signedFor(params, withWallet) { async function signedFor(params, withWallet, overrides) {
return (withWallet || signer).signTransaction(txFor(params)); return (withWallet || signer).signTransaction(txFor(params, overrides));
}
// Sign the approved transaction with one field changed from what was
// populated, which is the shape of every tamper case below.
async function signedWith(overrides) {
return signedFor(TX_PARAMS, signer, overrides);
} }
describe("sameAddress", () => { describe("sameAddress", () => {
@@ -71,7 +101,7 @@ describe("sameAddress", () => {
describe("verifySignedTx", () => { describe("verifySignedTx", () => {
test("accepts the approved transaction signed by the approved address", async () => { test("accepts the approved transaction signed by the approved address", async () => {
const raw = await signedFor(TX_PARAMS); const raw = await signedFor(TX_PARAMS);
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address); const parsed = verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
expect(parsed.from).toBe(signer.address); expect(parsed.from).toBe(signer.address);
expect(parsed.hash).toBe(Transaction.from(raw).hash); expect(parsed.hash).toBe(Transaction.from(raw).hash);
}); });
@@ -79,14 +109,16 @@ describe("verifySignedTx", () => {
test("accepts a contract creation with no recipient", async () => { test("accepts a contract creation with no recipient", async () => {
const params = { to: undefined, value: "0x0", data: "0x600160005500" }; const params = { to: undefined, value: "0x0", data: "0x600160005500" };
const raw = await signedFor(params); const raw = await signedFor(params);
expect(() => verifySignedTx(raw, params, signer.address)).not.toThrow(); expect(() =>
verifySignedTx(raw, params, signer.address, SELECTED),
).not.toThrow();
}); });
test("accepts an absent value as zero", async () => { test("accepts an absent value as zero", async () => {
const approved = { to: RECIPIENT, data: "0x" }; const approved = { to: RECIPIENT, data: "0x" };
const raw = await signedFor(approved); const raw = await signedFor(approved);
expect(() => expect(() =>
verifySignedTx(raw, approved, signer.address), verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow(); ).not.toThrow();
}); });
@@ -94,7 +126,7 @@ describe("verifySignedTx", () => {
const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" }; const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
const raw = await signedFor(approved); const raw = await signedFor(approved);
expect(() => expect(() =>
verifySignedTx(raw, approved, signer.address), verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow(); ).not.toThrow();
}); });
@@ -103,9 +135,9 @@ describe("verifySignedTx", () => {
...TX_PARAMS, ...TX_PARAMS,
to: OTHER_RECIPIENT, to: OTHER_RECIPIENT,
}); });
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow( expect(() =>
/approved recipient/, verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
); ).toThrow(/approved recipient/);
}); });
test("rejects an inflated value", async () => { test("rejects an inflated value", async () => {
@@ -113,48 +145,48 @@ describe("verifySignedTx", () => {
...TX_PARAMS, ...TX_PARAMS,
value: "0x4563918244f40000", value: "0x4563918244f40000",
}); });
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow( expect(() =>
/approved value/, verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
); ).toThrow(/approved value/);
}); });
test("rejects substituted call data", async () => { test("rejects substituted call data", async () => {
const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" }); const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow( expect(() =>
/approved call data/, verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
); ).toThrow(/approved call data/);
}); });
test("rejects a transaction signed by a different address", async () => { test("rejects a transaction signed by a different address", async () => {
const raw = await signedFor(TX_PARAMS, other); const raw = await signedFor(TX_PARAMS, other);
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow( expect(() =>
/different address/, verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
); ).toThrow(/different address/);
}); });
test("rejects an unsigned transaction", () => { test("rejects an unsigned transaction", () => {
const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized; const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
expect(() => expect(() =>
verifySignedTx(unsigned, TX_PARAMS, signer.address), verifySignedTx(unsigned, TX_PARAMS, signer.address, SELECTED),
).toThrow(/no valid signature/); ).toThrow(/no valid signature/);
}); });
test("rejects a missing or malformed payload", () => { test("rejects a missing or malformed payload", () => {
expect(() => expect(() =>
verifySignedTx(undefined, TX_PARAMS, signer.address), verifySignedTx(undefined, TX_PARAMS, signer.address, SELECTED),
).toThrow(/missing or malformed/); ).toThrow(/missing or malformed/);
expect(() => verifySignedTx("nope", TX_PARAMS, signer.address)).toThrow(
/missing or malformed/,
);
expect(() => expect(() =>
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address), verifySignedTx("nope", TX_PARAMS, signer.address, SELECTED),
).toThrow(/missing or malformed/);
expect(() =>
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address, SELECTED),
).toThrow(/could not be decoded/); ).toThrow(/could not be decoded/);
}); });
test("every rejection message is a full sentence", async () => { test("every rejection message is a full sentence", async () => {
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT }); const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
try { try {
verifySignedTx(raw, TX_PARAMS, signer.address); verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
throw new Error("expected a rejection"); throw new Error("expected a rejection");
} catch (e) { } catch (e) {
expect(e.message).toMatch(/^[A-Z].*\.$/); expect(e.message).toMatch(/^[A-Z].*\.$/);
@@ -162,6 +194,552 @@ describe("verifySignedTx", () => {
}); });
}); });
// One case per consequential field: the field alone differs from what was
// approved, and that alone must refuse the signature.
describe("verifySignedTx field comparison", () => {
test("rejects a chain id that is not the selected network", async () => {
const raw = await signedWith({ chainId: 11155111 });
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/different network than the one that is selected/);
});
test("rejects a chain id that is not the approved one", async () => {
// Selected network and signed chain id agree; the dApp asked for a
// different chain, so the artifact is not what was approved.
const approved = { ...TX_PARAMS, chainId: SEPOLIA };
const raw = await signedWith({});
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/different network than the one that was approved/);
});
test("refuses when the selected network is unknown", async () => {
const raw = await signedWith({});
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, undefined),
).toThrow(/selected network is unknown/);
});
test("rejects a substituted nonce", async () => {
const approved = { ...TX_PARAMS, nonce: 7 };
const raw = await signedWith({ nonce: 8 });
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved nonce/);
});
test("rejects a substituted gas limit", async () => {
const approved = { ...TX_PARAMS, gasLimit: "0x186a0" };
const raw = await signedWith({ gasLimit: 250000n });
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved gas limit/);
});
test("rejects a substituted maximum fee per gas", async () => {
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
const raw = await signedWith({ maxFeePerGas: 900000000000n });
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved maximum fee per gas/);
});
test("rejects a substituted maximum priority fee per gas", async () => {
const approved = { ...TX_PARAMS, maxPriorityFeePerGas: "0x3b9aca00" };
const raw = await signedWith({ maxPriorityFeePerGas: 1500000000n });
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved maximum priority fee per gas/);
});
test("rejects a substituted legacy gas price", async () => {
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
const legacy = {
type: 0,
gasPrice: 9000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
};
const raw = await signedWith(legacy);
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved gas price/);
});
test("rejects an approved legacy fee signed as an EIP-1559 fee", async () => {
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
const raw = await signedWith({});
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved fee mechanism/);
});
test("rejects an approved EIP-1559 fee signed as a legacy fee", async () => {
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
const raw = await signedWith({
type: 0,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
});
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved fee mechanism/);
});
test("rejects a gas limit above anything a supported network accepts", async () => {
const raw = await signedWith({ gasLimit: MAX_GAS_LIMIT + 1n });
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/gas limit no network this wallet supports/);
});
test("rejects an absurd fee per gas the approval never fixed", async () => {
const raw = await signedWith({
maxFeePerGas: MAX_FEE_PER_GAS + 1n,
maxPriorityFeePerGas: MAX_FEE_PER_GAS + 1n,
});
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/fee per gas far above any plausible value/);
});
test("every field mismatch is a refusal, not a warning", async () => {
const raw = await signedWith({ nonce: 8 });
try {
verifySignedTx(
raw,
{ ...TX_PARAMS, nonce: 7 },
signer.address,
SELECTED,
);
throw new Error("expected a rejection");
} catch (e) {
expect(e.approvalMismatch).toBe(true);
expect(e.message).toMatch(/^[A-Z].*\.$/);
}
});
});
// The transaction type decides which fields exist, so an artifact of a type
// this wallet does not sign carries consequences the approval cannot describe
// and none of the field comparisons can see. The approval used here is the
// ordinary dApp shape with no fee fields — the common case, since
// populateTransaction() fills them — which is exactly the case the
// fee-mechanism check cannot catch by accident.
describe("verifySignedTx transaction type", () => {
const BARE_APPROVAL = {
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0x",
};
// An EIP-7702 artifact that pays the approved amount to the approved
// recipient and, in the same transaction, installs the attacker's code at
// the signer's own account for good. Every field the approval screen shows
// matches; only the type and the authorization list do not.
test("refuses a type 4 artifact that delegates the signer's own account", async () => {
const authorization = await signer.authorize({
address: OTHER_RECIPIENT,
chainId: 1,
nonce: 8,
});
const raw = await signedFor(BARE_APPROVAL, signer, {
type: 4,
authorizationList: [authorization],
});
const parsed = Transaction.from(raw);
expect(parsed.type).toBe(4);
expect(parsed.authorizationList[0].address).toBe(OTHER_RECIPIENT);
expect(() =>
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
).toThrow(/type this wallet does not sign/);
});
test("refuses a type 3 blob artifact", async () => {
const raw = await signedFor(BARE_APPROVAL, signer, {
type: 3,
maxFeePerBlobGas: 1000000000n,
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
});
expect(Transaction.from(raw).type).toBe(3);
expect(() =>
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
).toThrow(/type this wallet does not sign/);
});
test("refuses every type outside the allowlist, not just the known ones", async () => {
for (const type of [3, 4]) {
expect(ALLOWED_TX_TYPES).not.toContain(type);
}
expect(ALLOWED_TX_TYPES).toEqual([0, 1, 2]);
});
test("a type refusal is a refusal, not a warning", async () => {
const authorization = await signer.authorize({
address: OTHER_RECIPIENT,
chainId: 1,
nonce: 8,
});
const raw = await signedFor(BARE_APPROVAL, signer, {
type: 4,
authorizationList: [authorization],
});
try {
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED);
throw new Error("expected a rejection");
} catch (e) {
expect(e.approvalMismatch).toBe(true);
expect(e.message).toMatch(/^[A-Z].*\.$/);
}
});
test("accepts a legacy type 0 transaction", async () => {
const approved = { ...BARE_APPROVAL, gasPrice: "0x77359400" };
const raw = await signedFor(approved, signer, {
type: 0,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
});
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
test("accepts a type 1 transaction whose access list is the approved one", async () => {
const accessList = [{ address: OTHER_RECIPIENT, storageKeys: [] }];
const approved = {
...BARE_APPROVAL,
gasPrice: "0x77359400",
accessList,
};
const raw = await signedFor(approved, signer, {
type: 1,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
accessList,
});
expect(Transaction.from(raw).type).toBe(1);
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
test("refuses an access list the approval never carried", async () => {
const raw = await signedFor(BARE_APPROVAL, signer, {
accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }],
});
expect(() =>
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
).toThrow(/approved access list/);
});
test("treats an absent access list and an empty one as the same thing", async () => {
const approved = { ...BARE_APPROVAL, accessList: [] };
const raw = await signedFor(BARE_APPROVAL, signer, {});
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
});
// The allowlist is only exhaustive while it accounts for every field an
// artifact can carry. These tests are what makes that claim checkable rather
// than asserted.
describe("verifySignedTx exhaustiveness", () => {
// Every accessor ethers exposes on a parsed transaction, and where this
// module deals with it. If an ethers upgrade adds a transaction field,
// this fails and forces a decision about it instead of letting it default
// to unchecked.
test("every field ethers can parse is accounted for", () => {
const derived = [
// Recovered from the signature or computed from the payload, not
// independent content: covered by the signer check and by the
// fields below.
"from",
"fromPublicKey",
"hash",
"serialized",
"signature",
"type",
"typeName",
"unsignedHash",
"unsignedSerialized",
// Blob sidecar machinery, meaningful only alongside `blobs`,
// which is refused outright.
"kzg",
"blobWrapperVersion",
];
const accounted = new Set([
...derived,
...FORBIDDEN_FIELDS.map((f) => f.key),
...Object.values(SERIALIZED_FIELDS).flat(),
]);
const exposed = Object.getOwnPropertyNames(Transaction.prototype)
.filter((name) => {
const d = Object.getOwnPropertyDescriptor(
Transaction.prototype,
name,
);
return d && typeof d.get === "function";
})
.sort();
expect(exposed.filter((name) => !accounted.has(name))).toEqual([]);
});
// The two layers behind the type allowlist. Nothing reachable through
// verifySignedTx can trip either of them while the allowlist holds — that
// is what they are for — so they are exercised directly rather than taken
// on trust.
test("a forbidden field is refused even on an allowed type", async () => {
const authorization = await signer.authorize({
address: OTHER_RECIPIENT,
chainId: 1,
nonce: 8,
});
const carriers = {
authorizationList: [authorization],
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
blobs: ["0x00"],
maxFeePerBlobGas: 1n,
};
for (const key of Object.keys(carriers)) {
expect(FORBIDDEN_FIELDS.map((f) => f.key)).toContain(key);
let thrown;
try {
assertNoForbiddenFields({ type: 2, [key]: carriers[key] });
throw new Error("expected a rejection");
} catch (e) {
thrown = e;
}
expect(thrown.approvalMismatch).toBe(true);
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
}
expect(() => assertNoForbiddenFields({ type: 2 })).not.toThrow();
});
// Stands in for a future ethers that parses a field this module does not
// know about onto an allowed type: every field the module checks is
// identical, and the bytes are not.
test("an artifact carrying more than the checked fields is refused", async () => {
const parsed = Transaction.from(await signedWith({}));
const smuggled = { type: parsed.type };
for (const key of SERIALIZED_FIELDS[parsed.type]) {
smuggled[key] = parsed[key];
}
smuggled.unsignedSerialized = parsed.unsignedSerialized + "ff";
expect(() => assertNothingUnchecked(smuggled)).toThrow(
/beyond the fields that were checked/,
);
expect(() => assertNothingUnchecked(parsed)).not.toThrow();
});
// The closing check rebuilds the artifact from the fields the module
// compared and compares the bytes, so an artifact carrying anything else
// is refused without the module having to name it. Assert the rebuild is
// faithful for every accepted shape, since a rebuild that dropped a
// legitimate field would refuse honest transactions.
test("an accepted artifact of each allowed type rebuilds byte for byte", async () => {
const shapes = [
{
approved: { ...TX_PARAMS, gasPrice: "0x77359400" },
overrides: {
type: 0,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
},
},
{
approved: {
...TX_PARAMS,
gasPrice: "0x77359400",
accessList: [
{
address: RECIPIENT,
storageKeys: ["0x" + "11".repeat(32)],
},
],
},
overrides: {
type: 1,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
accessList: [
{
address: RECIPIENT,
storageKeys: ["0x" + "11".repeat(32)],
},
],
},
},
{ approved: TX_PARAMS, overrides: {} },
];
for (const shape of shapes) {
const raw = await signedFor(
shape.approved,
signer,
shape.overrides,
);
const parsed = verifySignedTx(
raw,
shape.approved,
signer.address,
SELECTED,
);
const fields = { type: parsed.type };
for (const key of SERIALIZED_FIELDS[parsed.type]) {
fields[key] = parsed[key];
}
expect(Transaction.from(fields).unsignedSerialized).toBe(
parsed.unsignedSerialized,
);
}
});
});
// The approval and the artifact spell the same values differently. None of
// these differences is tampering, so none may refuse the signature.
describe("verifySignedTx normalization", () => {
test("accepts a decimal chain id against a hex selected network", async () => {
const raw = await signedWith({});
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, 1),
).not.toThrow();
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, "1"),
).not.toThrow();
});
test("accepts an approved chain id written in hex", async () => {
const raw = await signedWith({});
const approved = { ...TX_PARAMS, chainId: "0x1" };
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
test("accepts a hex nonce against a numeric one", async () => {
const raw = await signedWith({ nonce: 7 });
expect(() =>
verifySignedTx(
raw,
{ ...TX_PARAMS, nonce: "0x7" },
signer.address,
SELECTED,
),
).not.toThrow();
});
test("accepts a decimal gas limit against a hex one", async () => {
const raw = await signedWith({ gasLimit: 100000n });
expect(() =>
verifySignedTx(
raw,
{ ...TX_PARAMS, gasLimit: "100000" },
signer.address,
SELECTED,
),
).not.toThrow();
});
test("accepts fee fields spelled as hex, decimal, number and bigint", async () => {
const raw = await signedWith({});
for (const maxFee of [
"0x77359400",
"2000000000",
2000000000,
2000000000n,
]) {
expect(() =>
verifySignedTx(
raw,
{ ...TX_PARAMS, maxFeePerGas: maxFee },
signer.address,
SELECTED,
),
).not.toThrow();
}
});
test("accepts an approval that fixes no nonce, gas or fee at all", async () => {
const raw = await signedWith({});
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).not.toThrow();
});
test("accepts an approval whose recipient case differs", async () => {
const raw = await signedWith({});
const approved = { ...TX_PARAMS, to: RECIPIENT.toLowerCase() };
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
test("accepts absent call data against 0x", async () => {
const approved = { to: RECIPIENT, value: "0x0" };
const raw = await signedFor({ ...approved, data: "0x" });
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
test("refuses an approved quantity that is not a number", async () => {
const raw = await signedWith({});
expect(() =>
verifySignedTx(
raw,
{ ...TX_PARAMS, maxFeePerGas: "cheap" },
signer.address,
SELECTED,
),
).toThrow(/is not a number/);
});
// The value is page-controlled. A refusal is correct; a raw BigInt
// conversion error is not, because it is not a mismatch, so it would be
// reported retryable and leave the approval unspent behind a live button
// that can never succeed.
test("refuses an approved value that is not a number, as a mismatch", async () => {
const raw = await signedWith({});
for (const value of ["cheap", 1.5, "1e18", {}]) {
let thrown;
try {
verifySignedTx(
raw,
{ ...TX_PARAMS, value },
signer.address,
SELECTED,
);
throw new Error("expected a rejection");
} catch (e) {
thrown = e;
}
expect(thrown.approvalMismatch).toBe(true);
expect(thrown.message).toMatch(/approved value is not a number/);
expect(failureIsRetryable(thrown)).toBe(false);
}
});
test("refuses an approved access list that is not an access list", async () => {
const raw = await signedWith({});
expect(() =>
verifySignedTx(
raw,
{ ...TX_PARAMS, accessList: ["nope"] },
signer.address,
SELECTED,
),
).toThrow(/not a valid access list/);
});
});
const TYPED_DATA = JSON.stringify({ const TYPED_DATA = JSON.stringify({
domain: { domain: {
name: "AutistMask Test", name: "AutistMask Test",
@@ -281,6 +859,133 @@ describe("verifySignature", () => {
}); });
}); });
// What happens after a signing attempt fails: the background keeps the
// approval for anything the user can correct, and the popup only offers the
// button again when it did.
describe("signing failure and retry", () => {
test("a failure that is not a mismatch leaves the approval retryable", () => {
expect(failureIsRetryable(new Error("The node is unreachable."))).toBe(
true,
);
expect(failureIsRetryable(undefined)).toBe(true);
});
test("a mismatch spends the approval", async () => {
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
try {
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
throw new Error("expected a rejection");
} catch (e) {
expect(failureIsRetryable(e)).toBe(false);
}
});
test("a retryable failure keeps the button usable and says only what failed", () => {
const outcome = describeSigningFailure(
{ error: "The node rejected the transaction.", retryable: true },
"The transaction could not be sent.",
);
expect(outcome.retryable).toBe(true);
expect(outcome.message).toBe("The node rejected the transaction.");
});
test("a refusal tells the user to start again from the site", () => {
const outcome = describeSigningFailure(
{
error: "The signed transaction does not go to the approved recipient.",
retryable: false,
},
"The transaction could not be sent.",
);
expect(outcome.retryable).toBe(false);
expect(outcome.message).toMatch(/start it again from the site\.$/);
});
test("a response the background never sent is treated as a spent approval", () => {
const outcome = describeSigningFailure(
undefined,
"The transaction could not be sent.",
);
expect(outcome.retryable).toBe(false);
expect(outcome.message).toMatch(/^The transaction could not be sent\./);
});
test("every failure message is a full sentence", () => {
const outcome = describeSigningFailure(
{ error: "The node is on fire", retryable: true },
"The transaction could not be sent.",
);
expect(outcome.message).toMatch(/^[A-Z].*\.$/);
});
test("a popup that could not sign leaves the approval standing", () => {
const outcome = describeTxFailure(
TX_STAGE_SIGN,
"That password is incorrect. Please try again.",
);
expect(outcome.retryable).toBe(true);
expect(outcome.spendApproval).toBe(false);
expect(outcome.error).toMatch(/password is incorrect/);
});
test("a mismatch found at verification spends the approval", async () => {
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
let outcome;
try {
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
} catch (e) {
outcome = describeTxFailure(TX_STAGE_VERIFY, e);
}
expect(outcome.retryable).toBe(false);
expect(outcome.spendApproval).toBe(true);
});
test("a failure before the check ran is still retryable", () => {
const outcome = describeTxFailure(
TX_STAGE_VERIFY,
new Error("The wallet state could not be read."),
);
expect(outcome.retryable).toBe(true);
expect(outcome.spendApproval).toBe(false);
});
// A broadcast that throws after the node took the transaction is routine:
// a timeout, a dropped response, a node answering "already known". The
// popup's retry does not re-broadcast the same bytes — it re-populates and
// re-signs at a freshly fetched nonce — so a retryable broadcast failure
// would put the approved transfer on the chain twice.
test("a failed broadcast is terminal, whatever the node said", () => {
for (const message of [
"already known",
"timeout of 30000ms exceeded",
"could not coalesce error",
"replacement transaction underpriced",
]) {
const outcome = describeTxFailure(
TX_STAGE_BROADCAST,
new Error(message),
);
expect(outcome.retryable).toBe(false);
expect(outcome.spendApproval).toBe(true);
expect(outcome.error).toBe(message);
}
});
test("a failed broadcast does not tell the user to send it again", () => {
const outcome = describeSigningFailure(
{
error: "The node did not answer.",
retryable: false,
stage: TX_STAGE_BROADCAST,
},
"The transaction could not be sent.",
);
expect(outcome.retryable).toBe(false);
expect(outcome.message).toMatch(/may still have reached the network/);
expect(outcome.message).not.toMatch(/start it again from the site/);
});
});
// End-to-end over the messaging boundary, without a browser: run the exact // End-to-end over the messaging boundary, without a browser: run the exact
// sequence the approval popup runs, then hand the artifact to the exact check // sequence the approval popup runs, then hand the artifact to the exact check
// the background runs before it broadcasts or resolves. Only what the popup // the background runs before it broadcasts or resolves. Only what the popup
@@ -314,7 +1019,12 @@ describe("popup signing sequence to background verification", () => {
test("a populated, signed transaction is accepted and broadcastable", async () => { test("a populated, signed transaction is accepted and broadcastable", async () => {
const rawSignedTx = await popupSignsTx(TX_PARAMS); const rawSignedTx = await popupSignsTx(TX_PARAMS);
const parsed = verifySignedTx(rawSignedTx, TX_PARAMS, signer.address); const parsed = verifySignedTx(
rawSignedTx,
TX_PARAMS,
signer.address,
SELECTED,
);
expect(parsed.nonce).toBe(7); expect(parsed.nonce).toBe(7);
expect(parsed.chainId).toBe(1n); expect(parsed.chainId).toBe(1n);
expect(parsed.gasLimit).toBe(21000n); expect(parsed.gasLimit).toBe(21000n);
@@ -349,7 +1059,14 @@ describe("popup signing sequence to background verification", () => {
to: OTHER_RECIPIENT, to: OTHER_RECIPIENT,
}); });
expect(() => expect(() =>
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address), verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SELECTED),
).toThrow(/approved recipient/); ).toThrow(/approved recipient/);
}); });
test("the background rejects a transaction populated on another network", async () => {
const rawSignedTx = await popupSignsTx(TX_PARAMS);
expect(() =>
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SEPOLIA),
).toThrow(/different network than the one that is selected/);
});
}); });

104
tests/state.test.js Normal file
View File

@@ -0,0 +1,104 @@
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
function oneWallet() {
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
}
// state.js resolves the storage API at require time, so the stub has to exist
// before the module is loaded, and the module registry has to be reset between
// cases because `state` is a module-level singleton.
function loadModuleWith(persisted) {
jest.resetModules();
const set = jest.fn(async () => {});
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set,
},
},
};
return { mod: require("../src/shared/state"), set };
}
afterEach(() => {
delete global.chrome;
});
describe("loadState hasWallet reconciliation", () => {
// A profile that deleted its last wallet on a build predating the write
// path fix keeps hasWallet: true forever. It must load as no wallet, which
// is what sends the popup to the welcome view.
test("stored hasWallet true with zero wallets loads as no wallet", async () => {
const { mod } = loadModuleWith({ hasWallet: true, wallets: [] });
await mod.loadState();
expect(mod.state.hasWallet).toBe(false);
});
test("stored hasWallet true with a missing wallets key loads as no wallet", async () => {
const { mod } = loadModuleWith({ hasWallet: true });
await mod.loadState();
expect(mod.state.wallets).toEqual([]);
expect(mod.state.hasWallet).toBe(false);
});
test("stored hasWallet false with one wallet loads as having a wallet", async () => {
const { mod } = loadModuleWith({
hasWallet: false,
wallets: oneWallet(),
});
await mod.loadState();
expect(mod.state.hasWallet).toBe(true);
});
test("absent hasWallet with wallets present loads as having a wallet", async () => {
const { mod } = loadModuleWith({ wallets: oneWallet() });
await mod.loadState();
expect(mod.state.hasWallet).toBe(true);
});
test("consistent stored states are preserved", async () => {
const withWallet = loadModuleWith({
hasWallet: true,
wallets: oneWallet(),
});
await withWallet.mod.loadState();
expect(withWallet.mod.state.hasWallet).toBe(true);
const without = loadModuleWith({ hasWallet: false, wallets: [] });
await without.mod.loadState();
expect(without.mod.state.hasWallet).toBe(false);
});
test("empty storage leaves the default no-wallet state", async () => {
const { mod } = loadModuleWith(null);
await mod.loadState();
expect(mod.state.hasWallet).toBe(false);
expect(mod.state.wallets).toEqual([]);
});
// The correction is derived on every load rather than written back, so a
// load never has a storage side effect.
test("loadState does not write to storage", async () => {
const { mod, set } = loadModuleWith({ hasWallet: true, wallets: [] });
await mod.loadState();
expect(set).not.toHaveBeenCalled();
});
// Deriving must not disturb the rest of the load.
test("other persisted fields still load", async () => {
const { mod } = loadModuleWith({
hasWallet: false,
wallets: oneWallet(),
networkId: "sepolia",
theme: "dark",
activeAddress: ADDRESS,
});
await mod.loadState();
expect(mod.state.networkId).toBe("sepolia");
expect(mod.state.theme).toBe("dark");
expect(mod.state.activeAddress).toBe(ADDRESS);
});
});

View File

@@ -36,6 +36,7 @@ global.chrome = { storage: { local: {} } };
const { const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,
mergeTransactions,
} = require("../src/shared/transactions"); } = require("../src/shared/transactions");
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList"); const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { debugFetch } = require("../src/shared/log"); const { debugFetch } = require("../src/shared/log");
@@ -685,6 +686,339 @@ describe("legitimate transactions are never filtered", () => {
}); });
}); });
// ---------------------------------------------------------------------------
// mergeTransactions is the pure core of the merge: it takes parsed native
// entries and parsed token transfers and decides how many rows one on-chain
// transaction becomes. One transaction is one row per distinct value
// movement, so the native side of a plain ERC-20 transfer must not survive
// next to its token row (the duplicate-row bug), while a hash that really
// did move several things must keep a row for each.
// ---------------------------------------------------------------------------
// A native entry as parseTx produces it for a decoded contract call: the
// amount fields are blanked and direction is "contract".
function contractCallTx(overrides = {}) {
return nativeTx({
from: VICTIM,
to: USDC_CONTRACT,
value: "",
exactValue: "",
rawAmount: "",
rawUnit: "",
valueGwei: 0,
direction: "contract",
directionLabel: "Approve",
isContractCall: true,
method: "approve",
...overrides,
});
}
// The native entry parseTx produces for a plain ERC-20 transfer: sent to the
// token contract, no ETH, and method "transfer", which is exactly why it is
// not marked as a display-level contract call.
function erc20CallTx(overrides = {}) {
return nativeTx({
from: VICTIM,
to: USDC_CONTRACT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
method: "transfer",
...overrides,
});
}
describe("mergeTransactions: one row per value movement", () => {
const HASH = "0x" + "d".repeat(64);
const OTHER_HASH = "0x" + "e".repeat(64);
const ROUTER = "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad";
test("a plain ERC-20 transfer yields one row, the token row", () => {
const native = erc20CallTx({ hash: HASH });
const token = tokenTx({
hash: HASH,
from: VICTIM,
to: ORDINARY_PEER,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
});
test("an ETH-only transfer keeps its row unchanged", () => {
const merged = mergeTransactions([legitimateEthSend()], []);
expect(merged).toHaveLength(1);
expect(merged[0]).toEqual(legitimateEthSend());
});
test("a genuine zero-value native transaction is still displayed", () => {
const zero = nativeTx({
hash: HASH,
from: VICTIM,
to: ORDINARY_PEER,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([zero], []);
expect(merged).toEqual([zero]);
});
test("a zero-value native row is only absorbed by a transfer sharing its hash", () => {
const zero = erc20CallTx({ hash: HASH });
const unrelated = tokenTx({ hash: OTHER_HASH });
const merged = mergeTransactions([zero], [unrelated]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.hash).sort()).toEqual(
[HASH, OTHER_HASH].sort(),
);
});
test("a native transaction that moved ETH keeps its row beside the token row", () => {
// An undecoded call (no method name) carrying ETH that also emitted
// a token transfer: two real movements, so two rows.
const native = nativeTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
valueGwei: 250000000,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
});
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
});
test("a sub-gwei ETH movement keeps its row beside the token row", () => {
// 500000000 wei is 0.5 gwei, so parseTx's valueGwei floors to 0 while
// rawAmount stays nonzero. Deciding "moved no ETH" on valueGwei would
// delete this row and lose a real ETH movement, so the decision is made
// on rawAmount as a BigInt.
const native = nativeTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.0000",
exactValue: "0.0000000005",
rawAmount: "500000000",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
});
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
expect(merged.find((t) => t.symbol === "ETH").rawAmount).toBe(
"500000000",
);
});
test("a swap consolidates every token leg into one row, preferring the received leg", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "execute",
});
const sentLeg = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
direction: "sent",
directionLabel: "Sent",
});
const receivedLeg = tokenTx({
hash: HASH,
from: ROUTER,
to: VICTIM,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
rawUnit: "WETH base units (10^-18)",
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
});
const merged = mergeTransactions([native], [sentLeg, receivedLeg]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("WETH");
expect(merged[0].exactValue).toBe("0.25");
// The user's own address and the contract called are preserved.
expect(merged[0].from).toBe(VICTIM);
expect(merged[0].to).toBe(ROUTER);
expect(merged[0].directionLabel).toBe("Swap");
});
test("a swap whose legs are all sent takes its amount from the first sent leg", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "execute",
});
const firstSent = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
direction: "sent",
directionLabel: "Sent",
});
const secondSent = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
rawUnit: "WETH base units (10^-18)",
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [firstSent, secondSent]);
expect(merged).toHaveLength(1);
// With no received leg the display amount comes from the first sent
// leg, and a later sent leg does not overwrite it.
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
expect(merged[0].holders).toBe(3500000);
});
test("a contract call carrying ETH plus a token transfer stays one row", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "swapExactETHForTokens",
valueGwei: 250000000,
});
const received = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [received]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
// The ETH leg is still visible as the row's native quantity.
expect(merged[0].valueGwei).toBe(250000000);
});
test("an approve keeps its row and survives the filters", () => {
const approve = contractCallTx({ hash: HASH });
const merged = mergeTransactions([approve], []);
expect(merged).toEqual([approve]);
expect(filterTransactions(merged, filters()).transactions).toEqual([
approve,
]);
});
test("a contract creation keeps its row", () => {
const creation = nativeTx({
hash: HASH,
from: VICTIM,
to: "",
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
});
expect(mergeTransactions([creation], [])).toEqual([creation]);
});
test("a native self-send keeps its single row", () => {
const selfSend = nativeTx({
hash: HASH,
from: VICTIM,
to: VICTIM,
direction: "sent",
directionLabel: "Sent",
});
expect(mergeTransactions([selfSend], [])).toEqual([selfSend]);
});
test("a token self-send yields one row", () => {
const native = erc20CallTx({ hash: HASH });
const token = tokenTx({
hash: HASH,
from: VICTIM,
to: VICTIM,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].from).toBe(VICTIM);
expect(merged[0].to).toBe(VICTIM);
});
test("several distinct tokens moved by one ERC-20 call keep a row each", () => {
const native = erc20CallTx({ hash: HASH });
const usdc = tokenTx({ hash: HASH });
const weth = tokenTx({
hash: HASH,
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
});
const merged = mergeTransactions([native], [usdc, weth]);
expect(merged.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
test("rows are sorted by block number, newest first", () => {
const older = nativeTx({ hash: HASH, blockNumber: 21000000 });
const newer = nativeTx({ hash: OTHER_HASH, blockNumber: 21000010 });
const merged = mergeTransactions([older, newer], []);
expect(merged.map((t) => t.blockNumber)).toEqual([21000010, 21000000]);
});
test("the entries handed in are never mutated", () => {
const native = contractCallTx({ hash: HASH, method: "execute" });
const token = tokenTx({ hash: HASH });
const before = JSON.stringify([native, token]);
mergeTransactions([native], [token]);
expect(JSON.stringify([native, token])).toBe(before);
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// fetchRecentTransactions owns the per-address merge of normal transactions // fetchRecentTransactions owns the per-address merge of normal transactions
// with ERC-20 transfers. (The cross-address merge Home performs lives in // with ERC-20 transfers. (The cross-address merge Home performs lives in
@@ -886,13 +1220,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]); expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
}); });
// Documents current behaviour: for a plain ERC-20 transfer the method is // Regression guard for the duplicate-row bug: for a plain ERC-20
// "transfer", so parseTx does not mark the entry as a contract call in // transfer the method is "transfer", so parseTx does not mark the entry
// the display sense and the merge loop does not consolidate the token // as a contract call in the display sense. The native side of that
// transfer into it. The result is two entries for one transaction: a // transaction moved no ETH and is represented by the token row, so it
// zero-value native row and the real token row. The zero-value row also // must not survive the merge as a second, zero-value row.
// escapes dust filtering because isContractCall is true. test("a plain ERC-20 transfer produces exactly one entry", async () => {
test("current behaviour: a plain ERC-20 transfer produces two entries", async () => {
const hash = "0x" + "5".repeat(64); const hash = "0x" + "5".repeat(64);
respondWith( respondWith(
[ [
@@ -925,14 +1258,15 @@ describe("fetchRecentTransactions merge and dedup", () => {
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(2); expect(txs).toHaveLength(1);
expect(txs.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]); expect(txs[0].symbol).toBe("USDC");
const nativeRow = txs.find((t) => t.symbol === "ETH"); expect(txs[0].exactValue).toBe("1.0");
expect(nativeRow.exactValue).toBe("0.0"); expect(txs[0].direction).toBe("sent");
expect(nativeRow.isContractCall).toBe(true); expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
// And the zero-value row is not removed by the dust filter. // The surviving row is the token row, and the filters keep it.
const kept = filterTransactions(txs, filters()).transactions; const kept = filterTransactions(txs, filters()).transactions;
expect(kept).toHaveLength(2); expect(kept).toHaveLength(1);
expect(kept[0].symbol).toBe("USDC");
}); });
test("entries are sorted by block number descending and capped at count", async () => { test("entries are sorted by block number descending and capped at count", async () => {