Compare commits
4 Commits
c05e69393f
...
dc7451a4e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc7451a4e2 | ||
| b9bc226ae1 | |||
| 19cb1ca1b0 | |||
| b882cede9f |
409
README.md
409
README.md
@@ -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
|
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,12 +334,18 @@ 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
|
||||||
|
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.
|
- An **address** holds ETH and any user-added 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).
|
||||||
@@ -354,95 +360,140 @@ menus.
|
|||||||
|
|
||||||
### 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 all tracked tokens of 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 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
|
- "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 + tracked ERC-20 tokens (4 decimal places, USD inline).
|
||||||
Each balance row is clickable → **AddressToken**
|
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 +504,67 @@ 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
|
- Maximum network fee: "Estimating..." then the ETH amount (USD in
|
||||||
- Warnings (scam address, self-send)
|
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)
|
- 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 +578,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 +607,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 +653,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
|
||||||
|
|
||||||
@@ -815,6 +983,7 @@ 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
|
||||||
@@ -980,13 +1149,13 @@ Currently supported:
|
|||||||
|
|
||||||
### Wallet Management
|
### Wallet Management
|
||||||
|
|
||||||
- [ ] Delete wallet (with confirmation)
|
- [x] Delete wallet (with confirmation)
|
||||||
- [ ] Delete address from HD wallet (with confirmation)
|
- [ ] Delete address from HD wallet (with confirmation)
|
||||||
- [ ] Show wallet's recovery phrase (requires password)
|
- [ ] Show wallet's recovery phrase (requires password)
|
||||||
|
|
||||||
### Transactions
|
### Transactions
|
||||||
|
|
||||||
- [ ] Gas estimation and fee display before confirming
|
- [x] Gas estimation and fee display before confirming
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
|||||||
15
TODO.md
15
TODO.md
@@ -44,6 +44,21 @@ undefined identifiers, which is how
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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)).
|
||||||
|
|||||||
242
docs/README.md
242
docs/README.md
@@ -6,10 +6,10 @@ and ERC-20 tokens, and connects to web3 sites. Nothing else.
|
|||||||
|
|
||||||
## Why AutistMask Exists
|
## Why AutistMask Exists
|
||||||
|
|
||||||
MetaMask has become bloated with swap UIs, portfolio dashboards, analytics,
|
The most popular browser-based EVM wallet has become bloated with swap UIs,
|
||||||
tracking, and advertisements. It is no longer a simple wallet. Most alternatives
|
portfolio dashboards, analytics, tracking, and advertisements. It is no longer a
|
||||||
(Rabby, Rainbow, etc.) only support Chromium browsers, leaving Firefox users
|
simple wallet. The common alternatives only support Chromium browsers, leaving
|
||||||
without a usable option.
|
Firefox users without a usable option.
|
||||||
|
|
||||||
AutistMask exists because a wallet should be a wallet. You should be able to see
|
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
|
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
|
- **Encrypt your recovery phrase and private keys at rest.** Your secrets are
|
||||||
encrypted on disk using Argon2id key derivation and XSalsa20-Poly1305
|
encrypted on disk using Argon2id key derivation and XSalsa20-Poly1305
|
||||||
authenticated encryption (via libsodium). Your password is required only when
|
authenticated encryption (via libsodium). Your password is required whenever a
|
||||||
signing a transaction. Viewing balances and addresses never requires a
|
secret has to be decrypted: signing a transaction, signing a message or typed
|
||||||
password.
|
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
|
- **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
|
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 NFT galleries or portfolio views.** This is a wallet, not a dashboard.
|
||||||
|
|
||||||
- **No token auto-discovery.** AutistMask does not scan the blockchain for
|
- **No third-party token list APIs.** Token balances come from the same block
|
||||||
tokens you might hold. You add tokens manually by contract address. This
|
explorer you configure for transaction history, and the extension ships its
|
||||||
prevents scam tokens from appearing in your wallet uninvited.
|
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
|
- **No backend servers operated by the developer.** Nothing is sent to any
|
||||||
to check URLs against a remote blocklist. It does maintain a local list of
|
server run by AutistMask. Every network destination is listed below.
|
||||||
known scam addresses, but this is shipped with the extension, not fetched from
|
|
||||||
a server.
|
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
AutistMask is a browser extension that runs entirely in your browser. It does
|
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
|
### 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
|
This is how AutistMask talks to the Ethereum network. Every wallet needs an
|
||||||
Ethereum node to check balances, estimate gas, broadcast transactions, and
|
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
|
this in Settings to any Ethereum JSON-RPC endpoint, including your own local
|
||||||
node.
|
node.
|
||||||
|
|
||||||
What gets sent: standard Ethereum JSON-RPC requests (balance queries,
|
When it is contacted: on every balance refresh (every 10 seconds while the popup
|
||||||
transaction broadcasts, gas estimates, ENS lookups). Your addresses are
|
is open, every 60 seconds in the background), when you type an ENS name into the
|
||||||
necessarily visible to the RPC provider when querying balances.
|
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
|
Used to fetch token balances and transaction history. Blockscout is an
|
||||||
open-source blockchain explorer. AutistMask queries it for your ERC-20 token
|
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.
|
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).
|
What gets sent: your Ethereum addresses (to look up balances and transactions).
|
||||||
|
|
||||||
**CoinDesk CADLI price API** (`data-api.coindesk.com`)
|
**CoinDesk CADLI price API** (`data-api.coindesk.com`)
|
||||||
|
|
||||||
Used to fetch current USD prices for ETH and ERC-20 tokens. Prices are cached
|
Used to fetch current USD prices for ETH and the top 25 tokens. Prices are
|
||||||
for 5 minutes. No API key is required. No user data is sent -- only a list of
|
cached for 5 minutes. No API key is required. This endpoint is not
|
||||||
token symbols (e.g. "ETH", "USDC") to get their prices.
|
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
|
When it is contacted: while the popup is open, at most once every 5 minutes.
|
||||||
information.
|
|
||||||
|
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
|
### 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
|
password. The password only protects the copy stored in this browser. If you
|
||||||
lose your recovery phrase, your password cannot help you recover it.
|
lose your recovery phrase, your password cannot help you recover it.
|
||||||
|
|
||||||
Your password is only requested when you send a transaction. Viewing balances,
|
Your password is requested whenever an encrypted secret must be decrypted: when
|
||||||
receiving funds, and browsing transaction history never require your password.
|
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
|
## Installation
|
||||||
|
|
||||||
@@ -147,34 +199,46 @@ receiving funds, and browsing transaction history never require your password.
|
|||||||
### Creating a New Wallet
|
### Creating a New Wallet
|
||||||
|
|
||||||
1. Click the AutistMask icon in your browser toolbar.
|
1. Click the AutistMask icon in your browser toolbar.
|
||||||
2. Click "Add wallet".
|
2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet".
|
||||||
3. Click the die button to generate a random 12-word recovery phrase.
|
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
|
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
|
words can take your funds. If you lose them, your wallet is gone. AutistMask
|
||||||
cannot recover them for you.
|
cannot recover them for you.
|
||||||
5. Choose a password. This encrypts your recovery phrase on this device.
|
5. Choose a password and confirm it. This encrypts your recovery phrase on this
|
||||||
6. Click "Add".
|
device.
|
||||||
|
6. Click "Import".
|
||||||
|
|
||||||
### Importing an Existing Wallet
|
### Importing an Existing Wallet
|
||||||
|
|
||||||
**From a recovery phrase:** Follow the same steps as creating a wallet, but
|
The Add Wallet screen has three tabs:
|
||||||
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.
|
|
||||||
|
|
||||||
**From a private key:** On the Add Wallet screen, click "Have a private key
|
**From Phrase:** Paste your existing 12 or 24 word recovery phrase instead of
|
||||||
instead?" and paste your private key. This creates a single-address wallet.
|
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
|
### Adding More Addresses
|
||||||
|
|
||||||
HD wallets (created from a recovery phrase) can derive multiple addresses. On
|
HD wallets (created from a recovery phrase or an xprv) can derive multiple
|
||||||
the home screen, click the "+" button next to a wallet name to add the next
|
addresses. On the home screen, click the "+" button next to a wallet name to add
|
||||||
address. These are deterministic -- the same recovery phrase will always produce
|
the next address. These are deterministic -- the same recovery phrase will
|
||||||
the same sequence of addresses.
|
always produce the same sequence of addresses.
|
||||||
|
|
||||||
### Adding ERC-20 Tokens
|
### 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).
|
1. Go to an address detail view (click `[info]` on any address).
|
||||||
2. Click "+ Token".
|
2. Click "+ Token".
|
||||||
@@ -183,12 +247,13 @@ AutistMask does not auto-discover tokens. To track a token:
|
|||||||
4. Click "Add".
|
4. Click "Add".
|
||||||
|
|
||||||
The token balance will appear on the address detail screen and on the home
|
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
|
## Sending
|
||||||
|
|
||||||
1. Click "Send" from the home screen or an address detail view.
|
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`).
|
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
|
||||||
4. Enter the amount.
|
4. Enter the amount.
|
||||||
5. Click "Review" to see the confirmation screen.
|
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
|
- **From and To addresses** with identicons and Etherscan links
|
||||||
- **Amount** with USD estimate
|
- **Amount** with USD estimate
|
||||||
- **Your current balance** 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
|
After reviewing, enter your password and click "Sign & Send". The transaction
|
||||||
broadcast to the network and you will see a waiting screen with a timer. Once
|
will be broadcast to the network and you will see a waiting screen with a timer.
|
||||||
confirmed (or after 60 seconds), you will see either a success or error screen
|
Once confirmed (or after 60 seconds), you will see either a success or error
|
||||||
with the transaction hash and an Etherscan link.
|
screen with the transaction hash and an Etherscan link.
|
||||||
|
|
||||||
### Sending a Specific Token
|
### 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.
|
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.
|
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
|
When receiving ERC-20 tokens, make sure the sender is sending on the network you
|
||||||
network. AutistMask is an Ethereum mainnet wallet. Tokens sent on other networks
|
are using. AutistMask supports Ethereum mainnet and the Sepolia testnet. Tokens
|
||||||
(Polygon, Arbitrum, BSC, etc.) to the same address will not appear and may be
|
sent on other networks (Polygon, Arbitrum, BSC, etc.) to the same address will
|
||||||
permanently lost.
|
not appear and may be permanently lost.
|
||||||
|
|
||||||
## Connecting to Web3 Sites
|
## 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
|
When a connected site requests a transaction, a separate approval popup appears
|
||||||
showing the transaction details (from, to, value, data). You must enter your
|
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
|
You can manage site permissions in Settings. Allowed and denied sites can be
|
||||||
individually removed to reset their permissions.
|
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
|
AutistMask includes several defenses against common Ethereum scams, all enabled
|
||||||
by default:
|
by default:
|
||||||
|
|
||||||
**Known token symbol verification.** AutistMask ships a list of ~250 legitimate
|
**Known token symbol verification.** AutistMask ships a list of roughly 500
|
||||||
ERC-20 tokens with their contract addresses. If a transaction claims to involve
|
legitimate ERC-20 tokens with their contract addresses. If a transaction or
|
||||||
a known symbol (like "ETH" or "USDT") but comes from an unrecognized contract,
|
balance claims to involve a known symbol (like "ETH" or "USDT") but comes from
|
||||||
it is identified as a spoof and hidden.
|
an unrecognized contract, it is identified as a spoof and hidden.
|
||||||
|
|
||||||
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are 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
|
from transaction history and the send token list, and are left out of your
|
||||||
substantial holder counts; scam tokens deployed for address poisoning typically
|
balances unless they are on the bundled known-token list or you added them
|
||||||
have zero.
|
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
|
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
|
||||||
adds the contract address to a local blocklist. Future transactions from that
|
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
|
plant them in your transaction history. The threshold is configurable in
|
||||||
Settings.
|
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.
|
see everything unfiltered.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
Click the gear icon on the home screen to access settings:
|
Click the gear icon on the home screen to access settings:
|
||||||
|
|
||||||
- **Wallets**: Add a new wallet.
|
- **Wallets**: Your wallets, and "+ Add wallet".
|
||||||
- **Display**: Toggle whether tracked tokens with zero balance are shown.
|
- **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.
|
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
|
||||||
You can use your own node for maximum privacy.
|
You can use your own node for maximum privacy.
|
||||||
- **Blockscout API**: Change the Blockscout instance used for token balances and
|
- **Blockscout API**: Change the Blockscout instance used for token balances and
|
||||||
transaction history. You can use a self-hosted instance.
|
transaction history. You can use a self-hosted instance.
|
||||||
- **Token Spam Protection**: Toggle individual scam filters and set the dust
|
- **Token Spam Protection**: Toggle individual scam filters, set the dust
|
||||||
transaction threshold.
|
transaction threshold, and switch timestamps to UTC.
|
||||||
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions.
|
- **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
|
## 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
|
Yes. AutistMask uses the standard `m/44'/60'/0'/0` derivation path, so importing
|
||||||
you import the same recovery phrase, you will get the same addresses. You can
|
the same recovery phrase gives you the same addresses as any other wallet using
|
||||||
use both wallets side by side, though only one can be the active
|
that path. Two wallet extensions can be installed side by side, though only one
|
||||||
`window.ethereum` provider at a time.
|
can be the active `window.ethereum` provider at a time.
|
||||||
|
|
||||||
**Can I use AutistMask with a hardware wallet?**
|
**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?**
|
**Does AutistMask support networks other than Ethereum mainnet?**
|
||||||
|
|
||||||
Not currently. AutistMask is Ethereum mainnet only. Multi-chain support may be
|
Ethereum mainnet and the Sepolia testnet, selectable in Settings. No other
|
||||||
added in the future.
|
networks are supported today. On Sepolia, USD values are not shown, because
|
||||||
|
testnet tokens have no market value.
|
||||||
|
|
||||||
**Where is my data stored?**
|
**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
|
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
|
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?**
|
**What happens if a transaction times out?**
|
||||||
|
|
||||||
|
|||||||
@@ -583,7 +583,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
|
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
|
||||||
<div class="text-xs text-muted mb-1">
|
<div class="text-xs text-muted mb-1">
|
||||||
Estimated network fee
|
Maximum network fee
|
||||||
</div>
|
</div>
|
||||||
<div id="confirm-fee-amount" class="text-xs"></div>
|
<div id="confirm-fee-amount" class="text-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -647,6 +647,31 @@
|
|||||||
class="mb-2 border border-border border-dashed p-2"
|
class="mb-2 border border-border border-dashed p-2"
|
||||||
style="visibility: hidden; min-height: 1.25rem"
|
style="visibility: hidden; min-height: 1.25rem"
|
||||||
></div>
|
></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">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -32,11 +32,23 @@ const {
|
|||||||
getFullWarnings,
|
getFullWarnings,
|
||||||
} = require("../../shared/addressWarnings");
|
} = require("../../shared/addressWarnings");
|
||||||
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
|
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 { log } = require("../../shared/log");
|
||||||
const makeBlockie = require("ethereum-blockies-base64");
|
const makeBlockie = require("ethereum-blockies-base64");
|
||||||
const txStatus = require("./txStatus");
|
const txStatus = require("./txStatus");
|
||||||
|
|
||||||
let pendingTx = null;
|
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() {
|
function restore() {
|
||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
@@ -67,6 +79,8 @@ function valueWithUsd(text, usdAmount) {
|
|||||||
|
|
||||||
function show(txInfo) {
|
function show(txInfo) {
|
||||||
pendingTx = txInfo;
|
pendingTx = txInfo;
|
||||||
|
feeStatus = FEE_PENDING;
|
||||||
|
feeWei = null;
|
||||||
|
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
||||||
@@ -153,50 +167,14 @@ function show(txInfo) {
|
|||||||
warningsEl.style.visibility = "hidden";
|
warningsEl.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for errors
|
// The two fee messages are mutually exclusive per transaction type, and
|
||||||
const errors = [];
|
// the type is known here, before the first paint. Drop the one that can
|
||||||
if (isErc20) {
|
// never apply and reserve the space of the one that can, so the async
|
||||||
const tokenBal = parseFloat(txInfo.tokenBalance || "0");
|
// estimate landing later never moves anything.
|
||||||
if (parseFloat(txInfo.amount) > tokenBal) {
|
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
||||||
errors.push(
|
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
||||||
"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.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorsEl = $("confirm-errors");
|
renderValidation(txInfo);
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset password field and error
|
// Reset password field and error
|
||||||
$("confirm-tx-password").value = "";
|
$("confirm-tx-password").value = "";
|
||||||
@@ -224,11 +202,93 @@ function show(txInfo) {
|
|||||||
checkRecipientHistory(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) {
|
async function estimateGas(txInfo) {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(state.rpcUrl);
|
const provider = getProvider(state.rpcUrl);
|
||||||
const feeData = await provider.getFeeData();
|
const feeData = await provider.getFeeData();
|
||||||
const gasPrice = feeData.gasPrice;
|
|
||||||
let gasLimit;
|
let gasLimit;
|
||||||
|
|
||||||
if (txInfo.token === "ETH") {
|
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);
|
const gasCostEth = formatEther(gasCostWei);
|
||||||
// Format to 6 significant decimal places
|
// Format to 6 significant decimal places
|
||||||
const parts = gasCostEth.split(".");
|
const parts = gasCostEth.split(".");
|
||||||
@@ -258,9 +328,16 @@ async function estimateGas(txInfo) {
|
|||||||
const ethPrice = getPrice("ETH");
|
const ethPrice = getPrice("ETH");
|
||||||
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null;
|
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null;
|
||||||
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd);
|
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd);
|
||||||
|
feeStatus = FEE_KNOWN;
|
||||||
|
feeWei = gasCostWei;
|
||||||
|
renderValidation(txInfo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("gas estimation failed:", e.message);
|
log.errorf("gas estimation failed:", e.message);
|
||||||
|
if (pendingTx !== txInfo) return;
|
||||||
$("confirm-fee-amount").textContent = "Unable to estimate";
|
$("confirm-fee-amount").textContent = "Unable to estimate";
|
||||||
|
feeStatus = FEE_UNAVAILABLE;
|
||||||
|
feeWei = null;
|
||||||
|
renderValidation(txInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
|
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
|
const {
|
||||||
|
removeWalletFromState,
|
||||||
|
broadcastActiveChanged,
|
||||||
|
} = require("../../shared/walletDelete");
|
||||||
|
|
||||||
let deleteWalletIndex = null;
|
let deleteWalletIndex = null;
|
||||||
let ctx = null;
|
let ctx = null;
|
||||||
@@ -58,35 +62,24 @@ function init(_ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect addresses to clean up from allowedSites/deniedSites
|
// Remove the wallet and repair selection, permissions and hasWallet
|
||||||
const addresses = (wallet.addresses || []).map((a) => a.address);
|
const { activeAddressChanged } = removeWalletFromState(
|
||||||
|
state,
|
||||||
// Remove wallet
|
walletIdx,
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteWalletIndex = null;
|
deleteWalletIndex = null;
|
||||||
|
|
||||||
if (state.wallets.length === 0) {
|
if (!state.hasWallet) {
|
||||||
// No wallets left — reset selection and show welcome
|
|
||||||
state.selectedWallet = null;
|
|
||||||
state.selectedAddress = null;
|
|
||||||
state.activeAddress = null;
|
|
||||||
clearViewStack();
|
clearViewStack();
|
||||||
await saveState();
|
await saveState();
|
||||||
|
// Save before broadcasting: the background reads the active
|
||||||
|
// address back out of storage to build accountsChanged.
|
||||||
|
if (activeAddressChanged) broadcastActiveChanged();
|
||||||
showView("welcome");
|
showView("welcome");
|
||||||
} else {
|
} 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();
|
await saveState();
|
||||||
|
if (activeAddressChanged) broadcastActiveChanged();
|
||||||
// Reset stack to [main] so Settings back goes home.
|
// Reset stack to [main] so Settings back goes home.
|
||||||
// Use require() lazily to avoid circular dependency
|
// Use require() lazily to avoid circular dependency
|
||||||
// (settings.js requires deleteWallet.js).
|
// (settings.js requires deleteWallet.js).
|
||||||
|
|||||||
150
src/shared/txValidation.js
Normal file
150
src/shared/txValidation.js
Normal 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,
|
||||||
|
};
|
||||||
70
src/shared/walletDelete.js
Normal file
70
src/shared/walletDelete.js
Normal 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
296
tests/txValidation.test.js
Normal 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
129
tests/walletDelete.test.js
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user