Compare commits

...

4 Commits

Author SHA1 Message Date
clawbot
dc7451a4e2 fix: count the network fee in the confirm-screen balance check (closes #154)
Some checks failed
check / check (push) Has been cancelled
The Send button was enabled whenever the amount alone fit the balance, so
a max-value ETH send passed the confirmation screen and failed at
broadcast, after the user had committed to it.

The arithmetic moves into src/shared/txValidation.js as a pure function
over 18-decimal fixed point: native ETH now requires amount + fee <=
balance, and an ERC-20 transfer requires the ETH balance to cover the fee
on top of the token check, reported as its own error. Validation re-runs
when the async estimate resolves; Send stays disabled while the estimate
is pending and when it fails, so an unknown fee is never treated as zero.
The fee messages are static elements that already reserve their space, so
nothing moves when the estimate lands.

The fee reserved is the one the node will actually require. The send pins
no fee fields, so ethers broadcasts a type-2 transaction and the node
validates it against value + gasLimit * maxFeePerGas; gating on gasPrice
would under-reserve by roughly gasLimit * baseFee and let through exactly
the broadcast failure this change exists to prevent. feeReserveWei()
derives that reserve, falling back to gasPrice only where no type-2
pricing exists, and the fee shown on screen is the same figure so the
displayed number and the gate can never contradict each other.

validateTransfer() fails closed: a feeWei that is not a non-negative
bigint under FEE_KNOWN, and any unrecognised feeStatus, block exactly as
FEE_UNAVAILABLE does rather than counting as a fee of zero.
2026-08-11 12:39:49 +00: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
19cb1ca1b0 docs: correct docs/README.md external services and remove competitor names (closes #163)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:25:57 +02:00
b882cede9f fix: repair wallet state on delete (closes #156)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:23:06 +02:00
10 changed files with 1278 additions and 262 deletions

409
README.md
View File

@@ -271,10 +271,10 @@ on a different table knows exactly tf I am talking about.
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).
Text that triggers an action (e.g. "Import private key") uses an underline. No
invisible hit targets, no bare text that happens to have a click handler. If it
does something when you click it, it must look like it does something when you
click it.
Text that triggers an action (e.g. "Add additional wallet...") uses an
underline. No invisible hit targets, no bare text that happens to have a click
handler. If it does something when you click it, it must look like it does
something when you click it.
#### Display Consistency
@@ -334,12 +334,18 @@ attack.
The core hierarchy is **Wallets → Addresses**:
- A **wallet** is either:
- An **HD wallet** (recovery phrase): generates multiple addresses from a
single 12/24 word recovery phrase using BIP-39/BIP-44 derivation. The user
can add more addresses with a "+" button.
- A **key wallet** (private key): a single address imported directly from a
private key. No "+" button since there is only one address.
- A **wallet** is one of three types:
- An **HD wallet** (`type: "hd"`, recovery phrase): generates multiple
addresses from a single 12/24 word recovery phrase using BIP-39/BIP-44
derivation. The user can add more addresses with a "+" button.
- A **key wallet** (`type: "key"`, private key): a single address imported
directly from a private key. No "+" button since there is only one
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 any user-added ERC-20 tokens.
- The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key).
@@ -354,95 +360,140 @@ menus.
### Screen Map
Navigation uses a stack model (like iOS): each action pushes a screen onto the
stack, and "Back" pops it. The root screen is either Welcome (no wallets) or
Home (has wallets). Screens are listed below with their elements and
transitions.
Navigation uses a stack model (like iOS): each forward action pushes the current
screen onto `state.viewStack`, and "Back" pops it (`pushCurrentView()` and
`goBack()` in `src/popup/views/helpers.js`). The root screen is either Welcome
(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.
- **Elements**: "AutistMask" heading, brief intro text, "Add wallet" button.
Closing and reopening the popup returns to the screen the user was last on only
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**:
- "Add wallet" → **AddWallet**
#### Home
#### Home (`main`)
- **When**: At least one wallet exists. This is the root screen.
- **Elements**:
- Header: "AutistMask", Settings gear button
- Active address ETH balance (large) + USD value (inline parentheses)
- Total USD value across all tokens (small text)
- Active address ETH balance (large) + USD value in parentheses
- "Total:" USD value across ETH and all tracked tokens of the active address
- 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
- Wallet list: each wallet shows name (tap to rename), "+" button (HD only),
and its addresses with color dots, balances, and `[info]` buttons
- Recent transactions across all addresses (merged, deduplicated, filtered)
- Wallet list: each wallet shows its name (tap to rename inline) and a "+"
button for HD and xprv wallets, then one block per address with "Address
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 tracked token
- "Recent Transactions": up to 25 transactions merged across every address
of every wallet, deduplicated by hash and filtered
- "Add additional wallet..." link at bottom
- **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**
- "Send" → **Send** (selects active address)
- "Send" → **Send** (refuses with a flash message on a zero balance)
- "Receive" → **Receive** (shows active address QR)
- "+" on wallet → derives next address inline
- Tap home tx row → **TransactionDetail**
- "Add additional wallet..." → **AddWallet**
- 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**:
- "Add Wallet" heading, "Back" button
- Instruction text
- Die button `[die]` (generates random recovery phrase)
- Recovery phrase textarea
- Backup warning box (shown after die is clicked)
- Password + confirm password inputs
- "Add" button
- "Have a private key instead?" link
- **Transitions**:
- "Add" (valid phrase + password) → **Home**
- "Back" → previous screen (Home or Welcome)
- "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
- "Back" button, "Add Wallet" heading
- Three tabs — "From Phrase" (`tab-mnemonic`), "From Key" (`tab-privkey`),
"From xprv" (`tab-xprv`) — each showing its own form section:
- **From Phrase**: instruction text, a die button that generates a
random recovery phrase, a recovery phrase textarea, and a backup
warning box that becomes visible once the die button has been used
- **From Key**: instruction text and a masked private key input
- **From xprv**: instruction text and a masked extended private key
input
- Password + confirm password inputs, with a hint line whose wording depends
on the selected tab
- "Import" button
- **Transitions**:
- "Import" (valid key + password) → **Home**
- "Back" → **AddWallet**
- "Import" with a valid entry and a matching password of at least 12
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.
- **Elements**:
- "Back" button
- Blockie identicon (48px, centered)
- 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)
- USD total for address
- Balance list: ETH + tracked ERC-20 tokens (4 decimal 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)
- **Transitions**:
- Tap balance row → **AddressToken** (for that token)
- "Send" → **Send**
- "Send" → **Send** (refuses with a flash message on a zero balance)
- "Receive" → **Receive**
- "+ Token" → **AddToken**
- "···" → "Export Private Key" → **ExportPrivKey**
- 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.
- **Elements**:
@@ -453,49 +504,67 @@ transitions.
- USD total for this token
- Single token balance line (4 decimal places)
- 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)
- **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)
- 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**:
- "Send" heading, "Back" button
- "Back" button, "Send" heading
- From: address with color dot + etherscan link
- What to send: token dropdown (or static display with contract address when
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
- "Review" button
- "Review" button, disabled until the recipient validates
- **Transitions**:
- "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.
- **Elements**:
- "Confirm Transaction" heading, "Back" button
- "Back" button, "Confirm Transaction" heading
- Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)"
- Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses)
- Your balance: value + symbol (USD in parentheses)
- Estimated network fee: ETH amount (USD in parentheses), fetched async
- Warnings (scam address, self-send)
- Maximum network fee: "Estimating..." then the ETH amount (USD in
parentheses) or "Unable to estimate", fetched async. This is the
`gasLimit * maxFeePerGas` reserve the node requires, which is also what
the balance check gates on; the transaction typically costs less once the
base fee is settled
- 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)
- "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**:
- "Send" → password modal → broadcast tx → **WaitTx**
- "Send" → password modal → broadcast fails → **ErrorTx**
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Back" → **Send**
#### WaitTx
#### WaitTx (`wait-tx`)
- **When**: Transaction has been broadcast, waiting for on-chain confirmation.
- **Elements**:
@@ -509,20 +578,24 @@ transitions.
- Receipt found → **SuccessTx**
- 60 seconds without confirmation → **ErrorTx** (timeout message)
#### SuccessTx
#### SuccessTx (`success-tx`)
- **When**: Transaction confirmed on-chain.
- **Elements**:
- "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
- To: color dot + full address + etherscan link
- Block number
- Transaction hash: full hash (tap to copy) + etherscan link
- "Done" button
- **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.
- **Elements**:
@@ -534,24 +607,28 @@ transitions.
full hash (tap to copy) + etherscan link
- "Done" button
- **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**:
- "Receive" heading, "Back" button
- "Back" button, "Receive" heading
- Instruction text
- QR code encoding the address
- Full address (color dot, selectable, etherscan link)
- "Copy address" button
- ERC-20 warning (shown when navigating from AddressToken for non-ETH token)
- **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
labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button
@@ -576,91 +653,182 @@ transitions.
- Raw data (shown when calldata is present): full calldata in monospace
dashed border
- **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**:
- "Add Token" heading, "Back" button
- "Back" button, "Add Token" heading
- Instruction text (find contract address on Etherscan)
- Contract address input
- Token info preview (name, symbol — fetched from contract)
- Common token quick-pick buttons
- Status line ("Looking up token...", cleared or replaced on failure)
- Common token quick-pick buttons (top 25 by market cap), which fill the
contract address input
- "Add" button
- **Transitions**:
- "Add" (valid contract) → **AddressDetail**
- "Back" → **AddressDetail**
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
**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**:
- "Settings" heading, "Back" button
- Wallets: "+ Add wallet" button
- Display: "Show tracked tokens with zero balance" checkbox
- Ethereum RPC: endpoint URL input + "Save" button
- Blockscout API: endpoint URL input + "Save" button
- "Back" button, "Settings" heading
- Wallets: one row per wallet with its name (tap to rename inline) and an
`[x]` delete button, plus a "+ Add wallet" button
- Tracked Tokens: one row per tracked token with an `[x]` remove 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:
- "Hide tokens with fewer than 1,000 holders" checkbox
- "Hide transactions from detected fraud contracts" checkbox
- "Hide dust transactions below N gwei" checkbox + threshold input
- "UTC Timestamps" checkbox
- Allowed 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**:
- "+ 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
in a separate popup by the background script.
- **When**: User tapped the `[x]` next to a wallet in Settings.
- **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**:
- "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)
- "Remember my choice for this site" checkbox
- "Allow" / "Deny" buttons
- **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
`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**:
- "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site hostname (bold) + "wants to send a transaction"
- Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline (see Transaction Decoding)
- 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
- 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)
- Password input
- Password input and an error line
- "Confirm" / "Reject" buttons
- **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)
- 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
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened via the toolbar
popup by the background script.
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
TxApproval, in a separate popup window.
- **Elements**:
- "Signature Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- 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)"
- From: color dot + full address + etherscan link
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
message fields (EIP-712 typed data)
- Password input
- Password input and an error line
- "Sign" / "Reject" buttons
- **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)
- Popup window closed without answering → the request is rejected with
EIP-1193 code 4001
### External Services
@@ -815,6 +983,7 @@ hardcoded test phrase.
- Create new HD wallet (generates 12-word recovery phrase)
- Import HD wallet from existing 12 or 24 word recovery phrase
- Import single-address wallet from private key
- Import multi-address wallet from an extended private key (`xprv`)
- Add multiple addresses within an HD wallet
- Manage multiple wallets simultaneously
- View ETH balance per address
@@ -980,13 +1149,13 @@ Currently supported:
### Wallet Management
- [ ] Delete wallet (with confirmation)
- [x] Delete wallet (with confirmation)
- [ ] Delete address from HD wallet (with confirmation)
- [ ] Show wallet's recovery phrase (requires password)
### Transactions
- [ ] Gas estimation and fee display before confirming
- [x] Gas estimation and fee display before confirming
### Testing

15
TODO.md
View File

@@ -44,6 +44,21 @@ undefined identifiers, which is how
# Completed Steps
- 2026-08-11: Network fee counted in the confirmation-screen balance check for
both ETH and ERC-20 sends, reserving what the node actually charges a type-2
transaction, with the arithmetic in a pure, unit-tested
`src/shared/txValidation.js`
([#154](https://git.eeqj.de/sneak/AutistMask/issues/154)).
- 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,
all five network destinations documented, password/Settings/Add Wallet
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
remaining wallets, the selection only moves when it was deleted, and the
active-address change is broadcast to connected sites
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
on `next`, with Status and Next Step refreshed
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).

View File

@@ -6,10 +6,10 @@ and ERC-20 tokens, and connects to web3 sites. Nothing else.
## Why AutistMask Exists
MetaMask has become bloated with swap UIs, portfolio dashboards, analytics,
tracking, and advertisements. It is no longer a simple wallet. Most alternatives
(Rabby, Rainbow, etc.) only support Chromium browsers, leaving Firefox users
without a usable option.
The most popular browser-based EVM wallet has become bloated with swap UIs,
portfolio dashboards, analytics, tracking, and advertisements. It is no longer a
simple wallet. The common alternatives only support Chromium browsers, leaving
Firefox users without a usable option.
AutistMask exists because a wallet should be a wallet. You should be able to see
your balances, send tokens, receive tokens, and connect to sites. That is all a
@@ -27,9 +27,10 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
- **Encrypt your recovery phrase and private keys at rest.** Your secrets are
encrypted on disk using Argon2id key derivation and XSalsa20-Poly1305
authenticated encryption (via libsodium). Your password is required only when
signing a transaction. Viewing balances and addresses never requires a
password.
authenticated encryption (via libsodium). Your password is required whenever a
secret has to be decrypted: signing a transaction, signing a message or typed
data, exporting a private key, and deleting a wallet. Viewing balances and
addresses never requires a password.
- **Let you choose your own RPC endpoint.** The default is a public Ethereum
RPC, but you can point it at your own node or any provider you trust. No
@@ -56,23 +57,25 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
- **No NFT galleries or portfolio views.** This is a wallet, not a dashboard.
- **No token auto-discovery.** AutistMask does not scan the blockchain for
tokens you might hold. You add tokens manually by contract address. This
prevents scam tokens from appearing in your wallet uninvited.
- **No third-party token list APIs.** Token balances come from the same block
explorer you configure for transaction history, and the extension ships its
own hardcoded list of top ERC-20 contract addresses for symbol-spoofing
detection. Any token you want tracked across all your addresses, you add
yourself by contract address.
- **No phishing blocklists from third parties.** AutistMask does not phone home
to check URLs against a remote blocklist. It does maintain a local list of
known scam addresses, but this is shipped with the extension, not fetched from
a server.
- **No backend servers operated by the developer.** Nothing is sent to any
server run by AutistMask. Every network destination is listed below.
## How It Works
AutistMask is a browser extension that runs entirely in your browser. It does
not have a backend server. It communicates with three external services:
not have a backend server. It communicates with five external destinations:
three you configure yourself, and two fixed ones used for scam detection.
### External Services
**Ethereum JSON-RPC endpoint** (default: `ethereum-rpc.publicnode.com`)
**Ethereum JSON-RPC endpoint** (default: `ethereum-rpc.publicnode.com`;
`ethereum-sepolia-rpc.publicnode.com` on Sepolia)
This is how AutistMask talks to the Ethereum network. Every wallet needs an
Ethereum node to check balances, estimate gas, broadcast transactions, and
@@ -80,27 +83,73 @@ verify confirmations. The default is a free public RPC endpoint. You can change
this in Settings to any Ethereum JSON-RPC endpoint, including your own local
node.
What gets sent: standard Ethereum JSON-RPC requests (balance queries,
transaction broadcasts, gas estimates, ENS lookups). Your addresses are
necessarily visible to the RPC provider when querying balances.
When it is contacted: on every balance refresh (every 10 seconds while the popup
is open, every 60 seconds in the background), when you type an ENS name into the
Send screen, when a send is prepared and broadcast, while a pending transaction
is polled for its receipt, and for the reverse ENS lookups used to label
addresses (cached for 12 hours).
**Blockscout API** (default: `eth.blockscout.com/api/v2`)
What gets sent: standard Ethereum JSON-RPC requests (balance queries,
transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your
addresses are necessarily visible to the RPC provider when querying balances.
**Blockscout API** (default: `eth.blockscout.com/api/v2`;
`eth-sepolia.blockscout.com/api/v2` on Sepolia)
Used to fetch token balances and transaction history. Blockscout is an
open-source blockchain explorer. AutistMask queries it for your ERC-20 token
balances and recent transactions. You can change this in Settings to a
balances (including the holder counts used for spam filtering) and your recent
transactions and token transfers. You can change this in Settings to a
self-hosted Blockscout instance.
When it is contacted: on every balance refresh, and whenever a screen showing
transaction history is opened.
What gets sent: your Ethereum addresses (to look up balances and transactions).
**CoinDesk CADLI price API** (`data-api.coindesk.com`)
Used to fetch current USD prices for ETH and ERC-20 tokens. Prices are cached
for 5 minutes. No API key is required. No user data is sent -- only a list of
token symbols (e.g. "ETH", "USDC") to get their prices.
Used to fetch current USD prices for ETH and the top 25 tokens. Prices are
cached for 5 minutes. No API key is required. This endpoint is not
user-configurable, and it is not contacted at all while you are on a testnet,
where no USD values are shown.
What gets sent: token symbol names. No addresses, no balances, no identifying
information.
When it is contacted: while the popup is open, at most once every 5 minutes.
What gets sent: token symbol names (e.g. "ETH", "USDC"). No addresses, no
balances, no identifying information. As with any request, CoinDesk sees your IP
address.
**Phishing domain blocklist** (`raw.githubusercontent.com`)
A community-maintained list of phishing domains, used to warn you when a site
that asks to connect, or to have a transaction or signature approved, is a known
scam. A copy is bundled into the extension at build time, so the protection
works before any network request happens. At runtime the extension fetches the
live list to pick up newly added domains, keeping only the entries not already
in the bundled copy (persisted locally if under 256 KiB). This endpoint is not
user-configurable.
When it is contacted: once when the background script starts, and every 24 hours
after that. It is a plain download of a public file — nothing about you is sent,
but the host sees your IP address. If the fetch fails, the bundled copy is still
used.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
When you review a send, AutistMask fetches the recipient's public Etherscan
address page and looks for a "Fake_Phishing"/"Phish/Hack" label or a scam
warning, and shows a red warning if it finds one. This is a plain page fetch
with no API key, made by your browser. It is best-effort: if it fails, it is
silently ignored. This endpoint is not user-configurable.
When it is contacted: each time you reach the send confirmation screen.
What gets sent: the recipient address you are about to send to, and your IP
address. Your own addresses are not sent.
Etherscan links shown elsewhere in the UI (on addresses, transactions, and token
contracts) are ordinary links. They contact nothing until you click them.
### What Stays Local
@@ -123,8 +172,11 @@ word recovery phrase can restore your wallet on any device without your
password. The password only protects the copy stored in this browser. If you
lose your recovery phrase, your password cannot help you recover it.
Your password is only requested when you send a transaction. Viewing balances,
receiving funds, and browsing transaction history never require your password.
Your password is requested whenever an encrypted secret must be decrypted: when
you send a transaction, when a site asks you to sign a message or typed data,
when you export an address's private key, and when you delete a wallet. Viewing
balances, receiving funds, and browsing transaction history never require your
password.
## Installation
@@ -147,34 +199,46 @@ receiving funds, and browsing transaction history never require your password.
### Creating a New Wallet
1. Click the AutistMask icon in your browser toolbar.
2. Click "Add wallet".
3. Click the die button to generate a random 12-word recovery phrase.
2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet".
3. On the "From Phrase" tab, click the die button to generate a random 12-word
recovery phrase.
4. **Write down the recovery phrase and store it safely.** Anyone with these
words can take your funds. If you lose them, your wallet is gone. AutistMask
cannot recover them for you.
5. Choose a password. This encrypts your recovery phrase on this device.
6. Click "Add".
5. Choose a password and confirm it. This encrypts your recovery phrase on this
device.
6. Click "Import".
### Importing an Existing Wallet
**From a recovery phrase:** Follow the same steps as creating a wallet, but
paste your existing 12 or 24 word recovery phrase instead of generating a new
one. AutistMask uses the same derivation path as MetaMask (`m/44'/60'/0'/0`), so
your addresses will match.
The Add Wallet screen has three tabs:
**From a private key:** On the Add Wallet screen, click "Have a private key
instead?" and paste your private key. This creates a single-address wallet.
**From Phrase:** Paste your existing 12 or 24 word recovery phrase instead of
generating a new one. AutistMask uses the standard BIP-44 Ethereum derivation
path (`m/44'/60'/0'/0`), which is what other wallets use by default, so your
addresses will match and your phrase stays portable in both directions.
**From Key:** Paste a single private key. This creates a single-address wallet.
**From xprv:** Paste an extended private key. This imports the HD wallet and
scans for used addresses.
All three tabs ask for the same password fields, and the "Import" button
finishes the job.
### Adding More Addresses
HD wallets (created from a recovery phrase) can derive multiple addresses. On
the home screen, click the "+" button next to a wallet name to add the next
address. These are deterministic -- the same recovery phrase will always produce
the same sequence of addresses.
HD wallets (created from a recovery phrase or an xprv) can derive multiple
addresses. On the home screen, click the "+" button next to a wallet name to add
the next address. These are deterministic -- the same recovery phrase will
always produce the same sequence of addresses.
### Adding ERC-20 Tokens
AutistMask does not auto-discover tokens. To track a token:
Tokens you hold show up automatically only if they are in the extension's
bundled list of well-known tokens or have at least 1,000 holders; everything
else is treated as spam and hidden. To track a token explicitly (which also
shows it at zero balance), add it by contract address:
1. Go to an address detail view (click `[info]` on any address).
2. Click "+ Token".
@@ -183,12 +247,13 @@ AutistMask does not auto-discover tokens. To track a token:
4. Click "Add".
The token balance will appear on the address detail screen and on the home
screen.
screen. Tokens can also be added from Settings, under "Tracked Tokens".
## Sending
1. Click "Send" from the home screen or an address detail view.
2. Select what to send (ETH or any tracked ERC-20 token).
2. Select what to send (ETH, or any ERC-20 token with a balance on this address
that survives the spam filters).
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
4. Enter the amount.
5. Click "Review" to see the confirmation screen.
@@ -200,12 +265,16 @@ The confirmation screen shows:
- **From and To addresses** with identicons and Etherscan links
- **Amount** with USD estimate
- **Your current balance** with USD estimate
- **Estimated network fee** in ETH with USD estimate
- **Maximum network fee** in ETH with USD estimate — the reserve the network
requires, which the balance check gates on; the transaction usually costs less
- **Warnings** if the recipient is a contract, a burn address, one of your own
addresses, on the bundled scam-address list, or labelled as a phisher on
Etherscan
After reviewing, click "Send" and enter your password. The transaction will be
broadcast to the network and you will see a waiting screen with a timer. Once
confirmed (or after 60 seconds), you will see either a success or error screen
with the transaction hash and an Etherscan link.
After reviewing, enter your password and click "Sign & Send". The transaction
will be broadcast to the network and you will see a waiting screen with a timer.
Once confirmed (or after 60 seconds), you will see either a success or error
screen with the transaction hash and an Etherscan link.
### Sending a Specific Token
@@ -219,10 +288,10 @@ cannot accidentally switch to a different one.
1. Click "Receive" from the home screen or an address detail view.
2. Share the QR code or copy the address using the "Copy address" button.
When receiving ERC-20 tokens, make sure the sender is sending on the Ethereum
network. AutistMask is an Ethereum mainnet wallet. Tokens sent on other networks
(Polygon, Arbitrum, BSC, etc.) to the same address will not appear and may be
permanently lost.
When receiving ERC-20 tokens, make sure the sender is sending on the network you
are using. AutistMask supports Ethereum mainnet and the Sepolia testnet. Tokens
sent on other networks (Polygon, Arbitrum, BSC, etc.) to the same address will
not appear and may be permanently lost.
## Connecting to Web3 Sites
@@ -237,7 +306,12 @@ pages. When a site requests access to your wallet:
When a connected site requests a transaction, a separate approval popup appears
showing the transaction details (from, to, value, data). You must enter your
password and click "Confirm" to authorize it.
password and click "Confirm" to authorize it. Message and typed-data signature
requests work the same way, with a "Sign" button, and also require your
password.
If the requesting site's domain is on the phishing blocklist, all three approval
screens show a red phishing warning before you decide.
You can manage site permissions in Settings. Allowed and denied sites can be
individually removed to reset their permissions.
@@ -247,15 +321,16 @@ individually removed to reset their permissions.
AutistMask includes several defenses against common Ethereum scams, all enabled
by default:
**Known token symbol verification.** AutistMask ships a list of ~250 legitimate
ERC-20 tokens with their contract addresses. If a transaction claims to involve
a known symbol (like "ETH" or "USDT") but comes from an unrecognized contract,
it is identified as a spoof and hidden.
**Known token symbol verification.** AutistMask ships a list of roughly 500
legitimate ERC-20 tokens with their contract addresses. If a transaction or
balance claims to involve a known symbol (like "ETH" or "USDT") but comes from
an unrecognized contract, it is identified as a spoof and hidden.
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
from transaction history and the send token list. Legitimate tokens have
substantial holder counts; scam tokens deployed for address poisoning typically
have zero.
from transaction history and the send token list, and are left out of your
balances unless they are on the bundled known-token list or you added them
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
for address poisoning typically have zero.
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
adds the contract address to a local blocklist. Future transactions from that
@@ -266,31 +341,47 @@ ETH by default) are hidden. Scammers send dust from look-alike addresses to
plant them in your transaction history. The threshold is configurable in
Settings.
All of these filters can be individually disabled in Settings if you prefer to
**Scam address list.** A list of known fraud, drainer, and phishing addresses is
shipped with the extension. Sending to one of them raises a warning on the
confirmation screen. It contains only addresses involved in fraud -- it is not a
sanctions list.
**Phishing domain warnings.** Sites asking to connect or to have something
approved are checked against the phishing domain blocklist described under
External Services, and flagged with a red banner if they match.
The first four filters can be individually disabled in Settings if you prefer to
see everything unfiltered.
## Settings
Click the gear icon on the home screen to access settings:
- **Wallets**: Add a new wallet.
- **Display**: Toggle whether tracked tokens with zero balance are shown.
- **Wallets**: Your wallets, and "+ Add wallet".
- **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add
token".
- **Display**: Toggle whether tracked tokens with zero balance are shown, and
choose the theme (System, Light, or Dark).
- **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching
resets the RPC and Blockscout endpoints to that network's defaults.
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
You can use your own node for maximum privacy.
- **Blockscout API**: Change the Blockscout instance used for token balances and
transaction history. You can use a self-hosted instance.
- **Token Spam Protection**: Toggle individual scam filters and set the dust
transaction threshold.
- **Token Spam Protection**: Toggle individual scam filters, set the dust
transaction threshold, and switch timestamps to UTC.
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions.
- **About**: License, author, version, release date, and a link to the commit
this build came from.
## Frequently Asked Questions
**Is AutistMask compatible with MetaMask?**
**Can I use AutistMask alongside another wallet?**
Yes. AutistMask uses the same derivation path (`m/44'/60'/0'/0`) as MetaMask. If
you import the same recovery phrase, you will get the same addresses. You can
use both wallets side by side, though only one can be the active
`window.ethereum` provider at a time.
Yes. AutistMask uses the standard `m/44'/60'/0'/0` derivation path, so importing
the same recovery phrase gives you the same addresses as any other wallet using
that path. Two wallet extensions can be installed side by side, though only one
can be the active `window.ethereum` provider at a time.
**Can I use AutistMask with a hardware wallet?**
@@ -298,8 +389,9 @@ Not yet. Hardware wallet support may be added in the future.
**Does AutistMask support networks other than Ethereum mainnet?**
Not currently. AutistMask is Ethereum mainnet only. Multi-chain support may be
added in the future.
Ethereum mainnet and the Sepolia testnet, selectable in Settings. No other
networks are supported today. On Sepolia, USD values are not shown, because
testnet tokens have no market value.
**Where is my data stored?**
@@ -312,7 +404,7 @@ to any server operated by AutistMask.
Your data is deleted. Make sure you have your recovery phrase backed up before
uninstalling. With your recovery phrase, you can restore your wallet in
AutistMask or any other compatible wallet (MetaMask, etc.) at any time.
AutistMask or any other wallet that uses the standard derivation path.
**What happens if a transaction times out?**

View File

@@ -583,7 +583,7 @@
</div>
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
<div class="text-xs text-muted mb-1">
Estimated network fee
Maximum network fee
</div>
<div id="confirm-fee-amount" class="text-xs"></div>
</div>
@@ -647,6 +647,31 @@
class="mb-2 border border-border border-dashed p-2"
style="visibility: hidden; min-height: 1.25rem"
></div>
<div
id="confirm-amount-fee-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
Your balance does not cover this amount plus the network
fee. Please go back and send a smaller amount.
</div>
<div
id="confirm-gas-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
You do not have enough ETH to pay the network fee for this
transfer. Please add ETH to this address and try again.
</div>
<div
id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
The network fee could not be estimated, so this transaction
cannot be checked against your balance. Please go back and
try again.
</div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input

View File

@@ -32,11 +32,23 @@ const {
getFullWarnings,
} = require("../../shared/addressWarnings");
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const {
CODES,
FEE_PENDING,
FEE_KNOWN,
FEE_UNAVAILABLE,
feeReserveWei,
validateTransfer,
} = require("../../shared/txValidation");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus");
let pendingTx = null;
// Network fee for the transaction currently on screen. Reset by show() and
// filled in by estimateGas() when the estimate resolves or fails.
let feeStatus = FEE_PENDING;
let feeWei = null;
function restore() {
const d = state.viewData;
@@ -67,6 +79,8 @@ function valueWithUsd(text, usdAmount) {
function show(txInfo) {
pendingTx = txInfo;
feeStatus = FEE_PENDING;
feeWei = null;
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
@@ -153,50 +167,14 @@ function show(txInfo) {
warningsEl.style.visibility = "hidden";
}
// Check for errors
const errors = [];
if (isErc20) {
const tokenBal = parseFloat(txInfo.tokenBalance || "0");
if (parseFloat(txInfo.amount) > tokenBal) {
errors.push(
"Insufficient " +
symbol +
" balance. You have " +
txInfo.tokenBalance +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
} else if (parseFloat(txInfo.amount) > parseFloat(txInfo.balance)) {
errors.push(
"Insufficient balance. You have " +
txInfo.balance +
" ETH but are trying to send " +
txInfo.amount +
" ETH.",
);
}
// The two fee messages are mutually exclusive per transaction type, and
// the type is known here, before the first paint. Drop the one that can
// never apply and reserve the space of the one that can, so the async
// estimate landing later never moves anything.
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
const errorsEl = $("confirm-errors");
const sendBtn = $("btn-confirm-send");
if (errors.length > 0) {
errorsEl.innerHTML = errors
.map((e) => `<div class="text-xs">${e}</div>`)
.join("");
errorsEl.style.visibility = "visible";
sendBtn.disabled = true;
sendBtn.classList.add("text-muted");
} else {
errorsEl.innerHTML = "";
errorsEl.style.visibility = "hidden";
sendBtn.disabled = false;
sendBtn.classList.remove("text-muted");
}
renderValidation(txInfo);
// Reset password field and error
$("confirm-tx-password").value = "";
@@ -224,11 +202,93 @@ function show(txInfo) {
checkRecipientHistory(txInfo);
}
// Render the balance check for the transaction on screen. Called once during
// show() and again when the fee estimate resolves or fails. Every element it
// touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
const { canSend, codes } = validateTransfer({
isErc20,
amount: txInfo.amount,
ethBalance: txInfo.balance,
tokenBalance: txInfo.tokenBalance,
feeStatus,
feeWei,
});
// Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html.
const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send.");
}
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
messages.push(
"Insufficient " +
symbol +
" balance. You have " +
txInfo.tokenBalance +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push(
"Insufficient balance. You have " +
txInfo.balance +
" ETH but are trying to send " +
txInfo.amount +
" ETH.",
);
}
const errorsEl = $("confirm-errors");
if (messages.length > 0) {
errorsEl.innerHTML = messages
.map((m) => `<div class="text-xs">${escapeHtml(m)}</div>`)
.join("");
errorsEl.style.visibility = "visible";
} else {
errorsEl.innerHTML = "";
errorsEl.style.visibility = "hidden";
}
setVisible(
"confirm-amount-fee-error",
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
);
setVisible(
"confirm-gas-error",
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
);
setVisible(
"confirm-fee-unknown-error",
codes.includes(CODES.FEE_UNAVAILABLE),
);
// While the estimate is in flight there is no error to show — the fee
// line already reads "Estimating..." — but sending stays blocked so a
// transaction the fee would break cannot be signed in the meantime.
const sendBtn = $("btn-confirm-send");
sendBtn.disabled = !canSend;
sendBtn.classList.toggle("text-muted", !canSend);
}
function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl);
const feeData = await provider.getFeeData();
const gasPrice = feeData.gasPrice;
let gasLimit;
if (txInfo.token === "ETH") {
@@ -246,7 +306,17 @@ async function estimateGas(txInfo) {
});
}
const gasCostWei = gasLimit * gasPrice;
// What the node will require to be reserved, which is what both the
// gate and the displayed figure must be: the send pins no fee fields,
// so it is broadcast as a type-2 transaction priced at maxFeePerGas.
const gasCostWei = feeReserveWei(gasLimit, feeData);
if (gasCostWei === null) {
throw new Error("no usable gas price from the provider");
}
// The user may have left this transaction while the estimate was in
// flight; a stale fee must not reach the screen or the balance check.
if (pendingTx !== txInfo) return;
const gasCostEth = formatEther(gasCostWei);
// Format to 6 significant decimal places
const parts = gasCostEth.split(".");
@@ -258,9 +328,16 @@ async function estimateGas(txInfo) {
const ethPrice = getPrice("ETH");
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null;
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd);
feeStatus = FEE_KNOWN;
feeWei = gasCostWei;
renderValidation(txInfo);
} catch (e) {
log.errorf("gas estimation failed:", e.message);
if (pendingTx !== txInfo) return;
$("confirm-fee-amount").textContent = "Unable to estimate";
feeStatus = FEE_UNAVAILABLE;
feeWei = null;
renderValidation(txInfo);
}
}

View File

@@ -1,6 +1,10 @@
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
const {
removeWalletFromState,
broadcastActiveChanged,
} = require("../../shared/walletDelete");
let deleteWalletIndex = null;
let ctx = null;
@@ -58,35 +62,24 @@ function init(_ctx) {
return;
}
// Collect addresses to clean up from allowedSites/deniedSites
const addresses = (wallet.addresses || []).map((a) => a.address);
// Remove wallet
state.wallets.splice(walletIdx, 1);
// Clean up site permissions for deleted addresses
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
// Remove the wallet and repair selection, permissions and hasWallet
const { activeAddressChanged } = removeWalletFromState(
state,
walletIdx,
);
deleteWalletIndex = null;
if (state.wallets.length === 0) {
// No wallets left — reset selection and show welcome
state.selectedWallet = null;
state.selectedAddress = null;
state.activeAddress = null;
if (!state.hasWallet) {
clearViewStack();
await saveState();
// Save before broadcasting: the background reads the active
// address back out of storage to build accountsChanged.
if (activeAddressChanged) broadcastActiveChanged();
showView("welcome");
} else {
// Switch to first wallet if deleted wallet was active
state.selectedWallet = 0;
state.selectedAddress = 0;
state.activeAddress =
state.wallets[0].addresses[0]?.address || null;
await saveState();
if (activeAddressChanged) broadcastActiveChanged();
// Reset stack to [main] so Settings back goes home.
// Use require() lazily to avoid circular dependency
// (settings.js requires deleteWallet.js).

150
src/shared/txValidation.js Normal file
View File

@@ -0,0 +1,150 @@
// Balance arithmetic for the transaction confirmation screen.
//
// Pure: no DOM, no network, no state. Everything is exact integer math on
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
// instead of through the confirmation view. The caller maps the returned
// codes to the reserved message elements on the screen.
//
// Human decimal strings ("1.25") are scaled to 18 decimals for comparison.
// That scale is independent of a token's own decimals: both the amount and
// the token balance arrive as human decimal strings, so comparing them at a
// common scale is exact.
const { parseUnits } = require("ethers");
const SCALE_DECIMALS = 18;
// Whether the asynchronous fee estimate has arrived yet.
const FEE_PENDING = "pending";
const FEE_KNOWN = "known";
const FEE_UNAVAILABLE = "unavailable";
const CODES = {
// The amount is not a number we can do exact arithmetic on.
AMOUNT_INVALID: "amount-invalid",
// ERC-20: the token amount exceeds the token balance.
INSUFFICIENT_TOKEN: "insufficient-token",
// ETH: the amount alone already exceeds the ETH balance.
INSUFFICIENT_ETH: "insufficient-eth",
// ETH: the amount fits, the amount plus the network fee does not.
INSUFFICIENT_ETH_WITH_FEE: "insufficient-eth-with-fee",
// ERC-20: the token balance covers the transfer, the ETH balance does
// not cover the network fee it costs.
INSUFFICIENT_ETH_FOR_FEE: "insufficient-eth-for-fee",
// The fee estimate has not arrived yet.
FEE_PENDING: "fee-pending",
// The fee estimate failed. Unknown is never treated as zero.
FEE_UNAVAILABLE: "fee-unavailable",
};
// The fee that must be reserved for a transaction, in wei: the amount the
// node will require, not the amount the transaction is expected to cost.
//
// A send that pins no fee fields is populated by ethers as a type-2
// (EIP-1559) transaction, and a node validates that against
// `value + gasLimit * maxFeePerGas`. ethers derives maxFeePerGas as
// `baseFeePerGas * 2 + maxPriorityFeePerGas`, so reserving `gasPrice`
// (roughly `baseFee + tip`) under-reserves by about `gasLimit * baseFee` and
// lets through a transaction the node then rejects with "insufficient funds
// for gas * price + value". gasPrice is the fallback only for a network that
// offers no type-2 pricing at all.
//
// Returns null when no usable price is available, which the caller must treat
// as a failed estimate rather than as a free transaction.
function feeReserveWei(gasLimit, feeData) {
if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null;
const price = feeData?.maxFeePerGas ?? feeData?.gasPrice;
if (typeof price !== "bigint" || price < 0n) return null;
return gasLimit * price;
}
// Scale a human decimal string to 18-decimal fixed point. Returns null when
// the value is not a decimal number or carries more precision than the scale
// can hold, which the caller must treat as unusable rather than as zero.
function toFixedPoint(value) {
if (typeof value !== "string" && typeof value !== "number") return null;
const text = String(value).trim();
if (text === "") return null;
try {
return parseUnits(text, SCALE_DECIMALS);
} catch (e) {
return null;
}
}
// Validate a pending transfer against the balances that must cover it.
//
// isErc20 — token transfer rather than a native ETH transfer
// amount — human decimal string being sent
// ethBalance — human decimal string, the sender's ETH balance
// tokenBalance — human decimal string, the sender's token balance
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
// is treated as FEE_UNAVAILABLE.
// feeWei — the fee reserve in wei from feeReserveWei(), as a
// non-negative bigint, when FEE_KNOWN. Any other value makes
// the fee unavailable rather than zero.
//
// Returns { canSend, codes }. Every code blocks sending: canSend is true
// only when nothing was found.
function validateTransfer({
isErc20 = false,
amount,
ethBalance,
tokenBalance,
feeStatus = FEE_PENDING,
feeWei = null,
} = {}) {
const codes = [];
const amountFp = toFixedPoint(amount);
const ethFp = toFixedPoint(ethBalance) ?? 0n;
if (amountFp === null) {
codes.push(CODES.AMOUNT_INVALID);
return { canSend: false, codes };
}
// Fail closed. Anything that is not a usable fee under a recognised
// status — a malformed feeWei, or a status this module does not know —
// is an unavailable estimate, never a fee of zero. Every such input errs
// in the direction that lets money out, so none of them is trusted.
const known =
feeStatus === FEE_KNOWN && typeof feeWei === "bigint" && feeWei >= 0n;
let status = feeStatus;
if (feeStatus === FEE_KNOWN && !known) status = FEE_UNAVAILABLE;
if (status !== FEE_KNOWN && status !== FEE_PENDING) {
status = FEE_UNAVAILABLE;
}
const feeFp = known ? feeWei : null;
if (isErc20) {
const tokenFp = toFixedPoint(tokenBalance) ?? 0n;
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
if (feeFp !== null && feeFp > ethFp) {
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
}
} else if (amountFp > ethFp) {
codes.push(CODES.INSUFFICIENT_ETH);
} else if (feeFp !== null && amountFp + feeFp > ethFp) {
codes.push(CODES.INSUFFICIENT_ETH_WITH_FEE);
}
// An unknown fee is never assumed to be zero: sending stays blocked
// until the estimate arrives, and stays blocked if it never does.
if (status === FEE_PENDING) codes.push(CODES.FEE_PENDING);
if (status === FEE_UNAVAILABLE) codes.push(CODES.FEE_UNAVAILABLE);
return { canSend: codes.length === 0, codes };
}
module.exports = {
CODES,
FEE_PENDING,
FEE_KNOWN,
FEE_UNAVAILABLE,
SCALE_DECIMALS,
feeReserveWei,
toFixedPoint,
validateTransfer,
};

View File

@@ -0,0 +1,70 @@
// Wallet deletion state transition, kept out of the view so the selection
// and broadcast rules are testable without a DOM.
// Remove wallet `walletIdx` from `state` and repair the derived state.
//
// Rules:
// - `hasWallet` tracks whether any wallet remains.
// - Site permissions are dropped for every address of the deleted wallet.
// - `selectedWallet` follows the splice: it is decremented when a wallet
// before it was removed, and falls back to the first remaining wallet's
// first address only when the selection itself was deleted.
// - `activeAddress` is only moved when it belonged to the deleted wallet;
// the fallback is the first remaining wallet's first address, or null
// when no wallet remains.
//
// Returns whether `activeAddress` changed, so the caller can broadcast it.
function removeWalletFromState(state, walletIdx) {
const wallet = state.wallets[walletIdx];
const addresses = (wallet.addresses || []).map((a) => a.address);
const previousActive = state.activeAddress;
const activeWasDeleted =
previousActive !== null &&
previousActive !== undefined &&
addresses.some(
(a) => a.toLowerCase() === String(previousActive).toLowerCase(),
);
state.wallets.splice(walletIdx, 1);
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
state.hasWallet = state.wallets.length > 0;
const fallbackAddress = state.hasWallet
? state.wallets[0].addresses[0]?.address || null
: null;
if (!state.hasWallet) {
state.selectedWallet = null;
state.selectedAddress = null;
} else if (state.selectedWallet === walletIdx) {
state.selectedWallet = 0;
state.selectedAddress = 0;
} else if (
typeof state.selectedWallet === "number" &&
state.selectedWallet > walletIdx
) {
state.selectedWallet -= 1;
}
if (activeWasDeleted || !state.hasWallet) {
state.activeAddress = fallbackAddress;
}
return { activeAddressChanged: state.activeAddress !== previousActive };
}
// Tell the background the active address changed, so it re-emits
// accountsChanged to connected sites. Same call shape as the address
// switch in the home view.
function broadcastActiveChanged() {
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
}
module.exports = { removeWalletFromState, broadcastActiveChanged };

296
tests/txValidation.test.js Normal file
View File

@@ -0,0 +1,296 @@
const { parseEther } = require("ethers");
const {
CODES,
FEE_PENDING,
FEE_KNOWN,
FEE_UNAVAILABLE,
feeReserveWei,
toFixedPoint,
validateTransfer,
} = require("../src/shared/txValidation");
// A plausible mainnet fee: 21000 gas at 20 gwei.
const FEE = 21000n * 20000000000n; // 0.00042 ETH
const GWEI = 1000000000n;
const GAS_LIMIT = 21000n;
describe("toFixedPoint", () => {
test("scales human decimals to 18 places", () => {
expect(toFixedPoint("1.5")).toBe(parseEther("1.5"));
expect(toFixedPoint("0")).toBe(0n);
});
test("rejects values it cannot represent exactly", () => {
expect(toFixedPoint("not a number")).toBe(null);
expect(toFixedPoint("")).toBe(null);
expect(toFixedPoint(null)).toBe(null);
// More precision than 18 decimals can hold.
expect(toFixedPoint("0.0000000000000000001")).toBe(null);
});
});
describe("validateTransfer, native ETH", () => {
const eth = (over) => ({
isErc20: false,
amount: "0.5",
ethBalance: "1.0",
feeStatus: FEE_KNOWN,
feeWei: FEE,
...over,
});
test("allows a send comfortably within balance", () => {
const r = validateTransfer(eth());
expect(r).toEqual({ canSend: true, codes: [] });
});
test("blocks a send whose amount plus fee exceeds the balance", () => {
// The whole balance: passes an amount-only check, fails once the fee
// is counted. This is the bug this module exists to prevent.
const r = validateTransfer(eth({ amount: "1.0", ethBalance: "1.0" }));
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
});
test("blocks a send left short by less than one fee", () => {
const balance = "1.0";
// One wei less headroom than the fee needs.
const amount = "0.99958000000000001"; // 1.0 - 0.00042 + 1e-17
const r = validateTransfer(eth({ amount, ethBalance: balance }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
});
test("allows a send that leaves exactly the fee behind", () => {
const r = validateTransfer(
eth({ amount: "0.99958", ethBalance: "1.0" }),
);
expect(r).toEqual({ canSend: true, codes: [] });
});
test("reports plain insufficient balance when the amount alone is too big", () => {
const r = validateTransfer(eth({ amount: "2.0", ethBalance: "1.0" }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
});
test("blocks while the fee estimate is still pending", () => {
const r = validateTransfer(
eth({ feeStatus: FEE_PENDING, feeWei: null }),
);
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.FEE_PENDING]);
});
test("blocks when the fee estimate failed, without assuming zero", () => {
const r = validateTransfer(
eth({
amount: "1.0",
ethBalance: "1.0",
feeStatus: FEE_UNAVAILABLE,
feeWei: null,
}),
);
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.FEE_UNAVAILABLE]);
// A zero fee would have let this exact transfer through.
expect(
validateTransfer(
eth({ amount: "1.0", ethBalance: "1.0", feeWei: 0n }),
).canSend,
).toBe(true);
});
test("still reports an over-balance amount before the estimate lands", () => {
const r = validateTransfer(
eth({
amount: "2.0",
ethBalance: "1.0",
feeStatus: FEE_PENDING,
feeWei: null,
}),
);
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH, CODES.FEE_PENDING]);
});
test("rejects an amount it cannot do exact arithmetic on", () => {
const r = validateTransfer(eth({ amount: "abc" }));
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.AMOUNT_INVALID]);
});
test("treats a missing balance as zero, not as unlimited", () => {
const r = validateTransfer(eth({ ethBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
});
});
describe("validateTransfer, ERC-20", () => {
const erc20 = (over) => ({
isErc20: true,
amount: "100.0",
tokenBalance: "250.0",
ethBalance: "1.0",
feeStatus: FEE_KNOWN,
feeWei: FEE,
...over,
});
test("allows a transfer with tokens to spend and ETH for the fee", () => {
expect(validateTransfer(erc20())).toEqual({ canSend: true, codes: [] });
});
test("checks the token amount against the token balance", () => {
const r = validateTransfer(erc20({ amount: "250.000001" }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
});
test("does not charge the fee against the token balance", () => {
// The full token balance is sendable: the fee is paid in ETH.
expect(validateTransfer(erc20({ amount: "250.0" })).canSend).toBe(true);
});
test("blocks when the ETH balance does not cover the fee", () => {
const r = validateTransfer(erc20({ ethBalance: "0.0001" }));
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_FOR_FEE]);
});
test("allows a fee exactly equal to the ETH balance", () => {
const r = validateTransfer(erc20({ ethBalance: "0.00042" }));
expect(r).toEqual({ canSend: true, codes: [] });
});
test("reports both shortfalls when tokens and ETH are both short", () => {
const r = validateTransfer(
erc20({ amount: "300.0", ethBalance: "0.0" }),
);
expect(r.codes).toEqual([
CODES.INSUFFICIENT_TOKEN,
CODES.INSUFFICIENT_ETH_FOR_FEE,
]);
});
test("blocks while the fee estimate is pending or failed", () => {
expect(
validateTransfer(erc20({ feeStatus: FEE_PENDING, feeWei: null }))
.codes,
).toEqual([CODES.FEE_PENDING]);
expect(
validateTransfer(
erc20({ feeStatus: FEE_UNAVAILABLE, feeWei: null }),
).codes,
).toEqual([CODES.FEE_UNAVAILABLE]);
});
test("treats a missing token balance as zero", () => {
const r = validateTransfer(erc20({ tokenBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
});
});
// The reserve a node requires, not the fee the transaction is expected to
// actually cost. An unpinned send goes out as type-2, and the node checks it
// against maxFeePerGas; reserving gasPrice lets a transaction the node will
// reject pass the gate.
describe("feeReserveWei", () => {
// baseFee 20 gwei, tip 1 gwei: eth_gasPrice reports ~21 gwei, while
// ethers populates maxFeePerGas as baseFee * 2 + tip = 41 gwei.
const type2 = {
gasPrice: 21n * GWEI,
maxFeePerGas: 41n * GWEI,
maxPriorityFeePerGas: 1n * GWEI,
};
test("reserves gasLimit * maxFeePerGas, not gasLimit * gasPrice", () => {
expect(feeReserveWei(GAS_LIMIT, type2)).toBe(GAS_LIMIT * 41n * GWEI);
expect(feeReserveWei(GAS_LIMIT, type2)).toBe(861000000000000n);
// The number the node would not have accepted.
expect(feeReserveWei(GAS_LIMIT, type2)).not.toBe(441000000000000n);
});
test("gates out a send the type-2 reserve cannot fund", () => {
// Exactly fundable against a gasPrice reserve (0.999559 + 0.000441 is
// the whole balance to the wei), and short against the reserve the
// node will actually require.
const send = {
isErc20: false,
amount: "0.999559",
ethBalance: "1.0",
feeStatus: FEE_KNOWN,
};
expect(
validateTransfer({
...send,
feeWei: GAS_LIMIT * type2.gasPrice,
}).canSend,
).toBe(true);
const r = validateTransfer({
...send,
feeWei: feeReserveWei(GAS_LIMIT, type2),
});
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
});
test("falls back to gasPrice on a network with no type-2 pricing", () => {
const legacy = { gasPrice: 21n * GWEI, maxFeePerGas: null };
expect(feeReserveWei(GAS_LIMIT, legacy)).toBe(GAS_LIMIT * 21n * GWEI);
});
test("returns null when no usable price or gas limit is available", () => {
expect(feeReserveWei(GAS_LIMIT, { gasPrice: null })).toBe(null);
expect(feeReserveWei(GAS_LIMIT, {})).toBe(null);
expect(feeReserveWei(GAS_LIMIT, null)).toBe(null);
expect(feeReserveWei(21000, type2)).toBe(null);
});
});
// Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does.
// Each of these previously returned { canSend: true, codes: [] } — counting no
// fee at all, on a full-balance send, in the direction that lets money out.
describe("validateTransfer, unusable fee input fails closed", () => {
const fullBalanceSend = (over) => ({
isErc20: false,
amount: "1.0",
ethBalance: "1.0",
...over,
});
test("blocks a null fee claiming to be known", () => {
const r = validateTransfer(
fullBalanceSend({ feeStatus: FEE_KNOWN, feeWei: null }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
test("blocks a known fee that is a number rather than a bigint", () => {
const r = validateTransfer(
fullBalanceSend({ feeStatus: FEE_KNOWN, feeWei: 420000000000000 }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
test("blocks an unrecognised fee status", () => {
const r = validateTransfer(fullBalanceSend({ feeStatus: "bogus" }));
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
test("blocks a negative fee", () => {
const r = validateTransfer(
fullBalanceSend({ feeStatus: FEE_KNOWN, feeWei: -1n }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
test("blocks an ERC-20 transfer on an unusable fee too", () => {
const r = validateTransfer({
isErc20: true,
amount: "100.0",
tokenBalance: "250.0",
ethBalance: "1.0",
feeStatus: FEE_KNOWN,
feeWei: null,
});
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
});

129
tests/walletDelete.test.js Normal file
View File

@@ -0,0 +1,129 @@
const {
removeWalletFromState,
broadcastActiveChanged,
} = require("../src/shared/walletDelete");
// Fixed addresses — never used for anything but these tests.
const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
function wallet(name, addresses) {
return {
name,
addresses: addresses.map((address) => ({ address })),
};
}
// A three-wallet state; wallet A is an HD wallet with two addresses.
function makeState(overrides = {}) {
return {
hasWallet: true,
wallets: [wallet("A", [A0, A1]), wallet("B", [B0]), wallet("C", [C0])],
selectedWallet: 0,
selectedAddress: 0,
activeAddress: A0,
allowedSites: {
[A0]: ["a.example"],
[A1]: ["b.example"],
[B0]: ["c.example"],
},
deniedSites: { [A1]: ["d.example"], [C0]: ["e.example"] },
...overrides,
};
}
describe("removeWalletFromState", () => {
test("deleting the last wallet clears hasWallet", () => {
const state = makeState({
wallets: [wallet("A", [A0])],
allowedSites: { [A0]: ["a.example"] },
deniedSites: {},
});
const { activeAddressChanged } = removeWalletFromState(state, 0);
expect(state.hasWallet).toBe(false);
expect(state.wallets).toEqual([]);
expect(state.selectedWallet).toBeNull();
expect(state.selectedAddress).toBeNull();
expect(state.activeAddress).toBeNull();
expect(activeAddressChanged).toBe(true);
});
test("deleting a non-selected wallet leaves the selection intact", () => {
const state = makeState({
selectedWallet: 2,
selectedAddress: 0,
activeAddress: C0,
});
const { activeAddressChanged } = removeWalletFromState(state, 1);
// Wallet C moved from index 2 to index 1 by the splice.
expect(state.wallets.map((w) => w.name)).toEqual(["A", "C"]);
expect(state.selectedWallet).toBe(1);
expect(state.selectedAddress).toBe(0);
expect(state.activeAddress).toBe(C0);
expect(activeAddressChanged).toBe(false);
expect(state.hasWallet).toBe(true);
});
test("deleting a wallet after the selection does not shift it", () => {
const state = makeState({
selectedWallet: 1,
selectedAddress: 0,
activeAddress: B0,
});
const { activeAddressChanged } = removeWalletFromState(state, 2);
expect(state.selectedWallet).toBe(1);
expect(state.activeAddress).toBe(B0);
expect(activeAddressChanged).toBe(false);
});
test("deleting the active wallet falls back to the first remaining address", () => {
const state = makeState({
selectedWallet: 0,
selectedAddress: 1,
activeAddress: A1,
});
const { activeAddressChanged } = removeWalletFromState(state, 0);
expect(state.wallets.map((w) => w.name)).toEqual(["B", "C"]);
expect(state.selectedWallet).toBe(0);
expect(state.selectedAddress).toBe(0);
expect(state.activeAddress).toBe(B0);
expect(activeAddressChanged).toBe(true);
expect(state.hasWallet).toBe(true);
});
test("site permissions are dropped for every address of the wallet", () => {
const state = makeState();
removeWalletFromState(state, 0);
expect(state.allowedSites).toEqual({ [B0]: ["c.example"] });
expect(state.deniedSites).toEqual({ [C0]: ["e.example"] });
});
});
describe("broadcastActiveChanged", () => {
afterEach(() => {
delete global.chrome;
});
test("sends AUTISTMASK_ACTIVE_CHANGED to the background", () => {
const sendMessage = jest.fn();
global.chrome = { runtime: { sendMessage } };
broadcastActiveChanged();
expect(sendMessage).toHaveBeenCalledWith({
type: "AUTISTMASK_ACTIVE_CHANGED",
});
});
});