Compare commits
7 Commits
979bea2d0d
...
a94110ed6c
| Author | SHA1 | Date | |
|---|---|---|---|
| a94110ed6c | |||
| 86cdea5e4e | |||
| f271bcd7b4 | |||
| 93e3f6e4e2 | |||
| 9b957ffd69 | |||
| cf5f582be9 | |||
| b9bc226ae1 |
@@ -1,3 +1,6 @@
|
|||||||
|
# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for
|
||||||
|
# build-info stamping and the Dockerfile runs `make build`, so excluding it
|
||||||
|
# would make every built extension report commitHash "unknown".
|
||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -11,7 +11,7 @@ setup:
|
|||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
install:
|
install:
|
||||||
@yarn install
|
@yarn install --frozen-lockfile
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|||||||
473
README.md
473
README.md
@@ -31,10 +31,13 @@ list exists to detect symbol spoofing attacks and improve UX.
|
|||||||
```bash
|
```bash
|
||||||
git clone https://git.eeqj.de/sneak/autistmask.git
|
git clone https://git.eeqj.de/sneak/autistmask.git
|
||||||
cd autistmask
|
cd autistmask
|
||||||
make install
|
make setup
|
||||||
make build
|
make build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`make setup` is the entrypoint for a fresh clone: it installs dependencies from
|
||||||
|
the lockfile and installs the git pre-commit hook.
|
||||||
|
|
||||||
Load the extension:
|
Load the extension:
|
||||||
|
|
||||||
- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click
|
- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click
|
||||||
@@ -97,6 +100,19 @@ provide:
|
|||||||
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook
|
- `script/install-precommit` — install the git pre-commit hook
|
||||||
|
|
||||||
|
The Makefile shims to those. It also carries a few targets that have no
|
||||||
|
`script/` counterpart and are Makefile-only conveniences:
|
||||||
|
|
||||||
|
- `make install` — `yarn install --frozen-lockfile` on its own, without the rest
|
||||||
|
of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being
|
||||||
|
silently rewritten. Use `make setup` for a fresh clone.
|
||||||
|
- `make hooks` — shims to `script/install-precommit`
|
||||||
|
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`
|
||||||
|
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see
|
||||||
|
[Debug Builds](#debug-builds))
|
||||||
|
- `make clean` — remove `dist/`
|
||||||
|
- `make dev` — build in watch mode
|
||||||
|
|
||||||
## End-to-End Tests
|
## End-to-End Tests
|
||||||
|
|
||||||
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
|
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
|
||||||
@@ -271,10 +287,10 @@ on a different table knows exactly tf I am talking about.
|
|||||||
|
|
||||||
Every interactive element must visually indicate that it is clickable. Buttons
|
Every interactive element must visually indicate that it is clickable. Buttons
|
||||||
use a visible border, padding, and a hover state (invert to white-on-black).
|
use a visible border, padding, and a hover state (invert to white-on-black).
|
||||||
Text that triggers an action (e.g. "Import private key") uses an underline. No
|
Text that triggers an action (e.g. "Add additional wallet...") uses an
|
||||||
invisible hit targets, no bare text that happens to have a click handler. If it
|
underline. No invisible hit targets, no bare text that happens to have a click
|
||||||
does something when you click it, it must look like it does something when you
|
handler. If it does something when you click it, it must look like it does
|
||||||
click it.
|
something when you click it.
|
||||||
|
|
||||||
#### Display Consistency
|
#### Display Consistency
|
||||||
|
|
||||||
@@ -334,115 +350,181 @@ attack.
|
|||||||
|
|
||||||
The core hierarchy is **Wallets → Addresses**:
|
The core hierarchy is **Wallets → Addresses**:
|
||||||
|
|
||||||
- A **wallet** is either:
|
- A **wallet** is one of three types:
|
||||||
- An **HD wallet** (recovery phrase): generates multiple addresses from a
|
- An **HD wallet** (`type: "hd"`, recovery phrase): generates multiple
|
||||||
single 12/24 word recovery phrase using BIP-39/BIP-44 derivation. The user
|
addresses from a single 12/24 word recovery phrase using BIP-39/BIP-44
|
||||||
can add more addresses with a "+" button.
|
derivation. The user can add more addresses with a "+" button.
|
||||||
- A **key wallet** (private key): a single address imported directly from a
|
- A **key wallet** (`type: "key"`, private key): a single address imported
|
||||||
private key. No "+" button since there is only one address.
|
directly from a private key. No "+" button since there is only one
|
||||||
- An **address** holds ETH and any user-added ERC-20 tokens.
|
address.
|
||||||
|
- An **xprv wallet** (`type: "xprv"`, extended private key): the same
|
||||||
|
multi-address behavior as an HD wallet, including the "+" button and the
|
||||||
|
address scan on import, but imported from an extended private key rather
|
||||||
|
than a recovery phrase. It therefore has no recovery phrase to display or
|
||||||
|
back up.
|
||||||
|
- An **address** holds ETH and ERC-20 tokens.
|
||||||
- The user can have multiple wallets, each with multiple addresses (HD) or a
|
- The user can have multiple wallets, each with multiple addresses (HD) or a
|
||||||
single address (key).
|
single address (key).
|
||||||
|
|
||||||
|
Which tokens an address shows is decided by `fetchTokenBalances()` in
|
||||||
|
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
|
||||||
|
tokens do appear without the user adding them. An ERC-20 is shown when its
|
||||||
|
balance is nonzero and it is in the bundled top-250 token list, is tracked by
|
||||||
|
the user, or has 1,000 or more holders; a token claiming a symbol from the
|
||||||
|
bundled list from any other contract address is always dropped. That filter is
|
||||||
|
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
|
||||||
|
the transaction history and the send-screen token selector, not this list.
|
||||||
|
Tracked tokens with a zero balance are listed as well while "Show tracked tokens
|
||||||
|
with zero balance" is on.
|
||||||
|
|
||||||
#### Navigation
|
#### Navigation
|
||||||
|
|
||||||
The main view shows all addresses grouped by wallet, with ETH balances inline.
|
The main view shows all addresses grouped by wallet, with ETH balances inline.
|
||||||
The user taps an address to see its detail view (full address, balance, tokens,
|
The user taps an address to see its detail view (full address, balance, tokens,
|
||||||
send/receive). Navigation is flat — every view has a "Back" or "Cancel" button
|
send/receive). Navigation is a stack: each forward action pushes the current
|
||||||
that returns to the previous context. No deep nesting, no tabs, no hamburger
|
screen, and every view has a "Back" or "Cancel" button that pops back to it (see
|
||||||
menus.
|
the Screen Map below). There is no hamburger menu and no persistent tab bar; the
|
||||||
|
Settings gear in the title bar is the only global control. Two screens carry an
|
||||||
|
in-screen control beyond that: AddWallet uses three tabs to select the import
|
||||||
|
mode, and AddressDetail keeps its one rarely-used action ("Export Private Key")
|
||||||
|
behind a "···" menu.
|
||||||
|
|
||||||
### Screen Map
|
### Screen Map
|
||||||
|
|
||||||
Navigation uses a stack model (like iOS): each action pushes a screen onto the
|
Navigation uses a stack model (like iOS): each forward action pushes the current
|
||||||
stack, and "Back" pops it. The root screen is either Welcome (no wallets) or
|
screen onto `state.viewStack`, and "Back" pops it (`pushCurrentView()` and
|
||||||
Home (has wallets). Screens are listed below with their elements and
|
`goBack()` in `src/popup/views/helpers.js`). The root screen is either Welcome
|
||||||
transitions.
|
(no wallets) or Home (has wallets). Each screen below gives its view id in
|
||||||
|
parentheses; the registry of view ids is the `VIEWS` array in
|
||||||
|
`src/popup/views/helpers.js`, and the markup for a screen is the element with id
|
||||||
|
`view-` plus that view id in `src/popup/index.html`.
|
||||||
|
|
||||||
#### Welcome
|
Three elements sit outside the screens and are present on all of them: the title
|
||||||
|
bar ("AutistMask by @sneak" plus the Settings gear), the flash message line
|
||||||
|
under it, and the red banner at the very top that appears on a debug build, when
|
||||||
|
runtime debug mode is on, or when the active network is a testnet. They are not
|
||||||
|
repeated in the element lists below.
|
||||||
|
|
||||||
- **When**: No wallets exist yet.
|
Closing and reopening the popup returns to the screen the user was last on only
|
||||||
- **Elements**: "AutistMask" heading, brief intro text, "Add wallet" button.
|
for the views listed in `RESTORABLE_VIEWS` (`src/popup/index.js`). Every other
|
||||||
|
screen, including ExportPrivKey, falls back to Home.
|
||||||
|
|
||||||
|
#### Welcome (`welcome`)
|
||||||
|
|
||||||
|
- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
|
||||||
|
screen in that case.
|
||||||
|
- **Elements**:
|
||||||
|
- "Welcome! To get started, add a wallet." text
|
||||||
|
- "Add wallet" button
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Add wallet" → **AddWallet**
|
- "Add wallet" → **AddWallet**
|
||||||
|
|
||||||
#### Home
|
#### Home (`main`)
|
||||||
|
|
||||||
- **When**: At least one wallet exists. This is the root screen.
|
- **When**: At least one wallet exists. This is the root screen.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- Header: "AutistMask", Settings gear button
|
- Active address ETH balance (large) + USD value in parentheses
|
||||||
- Active address ETH balance (large) + USD value (inline parentheses)
|
- "Total:" USD value across ETH and every token shown for the active address
|
||||||
- Total USD value across all tokens (small text)
|
|
||||||
- Active address (color dot, full address, etherscan link, tap to copy)
|
- Active address (color dot, full address, etherscan link, tap to copy)
|
||||||
- Send / Receive quick-action buttons
|
- Send / Receive quick-action buttons, both acting on the active address
|
||||||
- ETH/USD price display
|
- ETH/USD price display
|
||||||
- Wallet list: each wallet shows name (tap to rename), "+" button (HD only),
|
- Wallet list: each wallet shows its name (tap to rename inline) and a "+"
|
||||||
and its addresses with color dots, balances, and `[info]` buttons
|
button for HD and xprv wallets, then one block per address with "Address
|
||||||
- Recent transactions across all addresses (merged, deduplicated, filtered)
|
N" (bold when active), the ENS name if resolved, the full address, an
|
||||||
|
`[info]` button, the address USD total, and a balance line for ETH and for
|
||||||
|
each token shown for that address
|
||||||
|
- "Recent Transactions": up to 25 transactions merged across every address
|
||||||
|
of every wallet, deduplicated by hash and filtered
|
||||||
- "Add additional wallet..." link at bottom
|
- "Add additional wallet..." link at bottom
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Tap address row → sets active address (no screen change)
|
- Tap address row → sets the active address and broadcasts
|
||||||
|
`AUTISTMASK_ACTIVE_CHANGED` (no screen change)
|
||||||
|
- Tap wallet name → inline rename field (no screen change)
|
||||||
|
- "+" on wallet → derives the next address inline (no screen change)
|
||||||
- `[info]` on address → **AddressDetail**
|
- `[info]` on address → **AddressDetail**
|
||||||
- "Send" → **Send** (selects active address)
|
- "Send" → **Send** (refuses with a flash message on a zero balance)
|
||||||
- "Receive" → **Receive** (shows active address QR)
|
- "Receive" → **Receive** (shows active address QR)
|
||||||
- "+" on wallet → derives next address inline
|
- Tap home tx row → **TransactionDetail**
|
||||||
- "Add additional wallet..." → **AddWallet**
|
- "Add additional wallet..." → **AddWallet**
|
||||||
- Settings gear → **Settings** (toggles; tap again to return)
|
- Settings gear → **Settings** (toggles; tap again to return)
|
||||||
- Tap home tx row → **AddressDetail** (for the address involved)
|
|
||||||
|
|
||||||
#### AddWallet
|
#### AddWallet (`add-wallet`)
|
||||||
|
|
||||||
- **When**: User wants to add a new wallet (from Home, Welcome, or Settings).
|
- **When**: User wants to add a new wallet (from Welcome, Home, or Settings).
|
||||||
|
This one screen covers all three import modes; there is no separate import
|
||||||
|
screen.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Add Wallet" heading, "Back" button
|
- "Back" button, "Add Wallet" heading
|
||||||
- Instruction text
|
- Three tabs — "From Phrase" (`tab-mnemonic`), "From Key" (`tab-privkey`),
|
||||||
- Die button `[die]` (generates random recovery phrase)
|
"From xprv" (`tab-xprv`) — each showing its own form section:
|
||||||
- Recovery phrase textarea
|
- **From Phrase**: instruction text, a die button that generates a
|
||||||
- Backup warning box (shown after die is clicked)
|
random recovery phrase, a recovery phrase textarea, and a backup
|
||||||
- Password + confirm password inputs
|
warning box that becomes visible once the die button has been used
|
||||||
- "Add" button
|
- **From Key**: instruction text and a masked private key input
|
||||||
- "Have a private key instead?" link
|
- **From xprv**: instruction text and a masked extended private key
|
||||||
- **Transitions**:
|
input
|
||||||
- "Add" (valid phrase + password) → **Home**
|
- Password + confirm password inputs, with a hint line whose wording depends
|
||||||
- "Back" → previous screen (Home or Welcome)
|
on the selected tab
|
||||||
- "Have a private key instead?" → **ImportKey**
|
|
||||||
|
|
||||||
#### ImportKey
|
|
||||||
|
|
||||||
- **When**: User wants to import a single private key.
|
|
||||||
- **Elements**:
|
|
||||||
- "Import Private Key" heading, "Back" button
|
|
||||||
- Instruction text
|
|
||||||
- Private key input (password-masked)
|
|
||||||
- Password + confirm password inputs
|
|
||||||
- "Import" button
|
- "Import" button
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Import" (valid key + password) → **Home**
|
- "Import" with a valid entry and a matching password of at least 12
|
||||||
- "Back" → **AddWallet**
|
characters → creates the wallet, clears the navigation stack, and →
|
||||||
|
**Home**. The phrase and xprv modes then scan for further used addresses
|
||||||
|
and report the count as a flash message.
|
||||||
|
- "Import" with an invalid entry, a duplicate wallet or address, or a short
|
||||||
|
or mismatched password → flash message, no screen change
|
||||||
|
- "Back" → previous screen (Welcome, Home, or Settings)
|
||||||
|
|
||||||
#### AddressDetail
|
#### AddressDetail (`address`)
|
||||||
|
|
||||||
- **When**: User tapped `[info]` on an address from Home.
|
- **When**: User tapped `[info]` on an address from Home.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Back" button
|
- "Back" button
|
||||||
- Blockie identicon (48px, centered)
|
- Blockie identicon (48px, centered)
|
||||||
- Title: "Wallet Name — Address N"
|
- Title: "Wallet Name — Address N"
|
||||||
- ENS name (if resolved, bold with color dot)
|
- ENS name (if resolved, bold above the address)
|
||||||
- Full address (color dot, etherscan link, tap to copy)
|
- Full address (color dot, etherscan link, tap to copy)
|
||||||
- USD total for address
|
- USD total for address
|
||||||
- Balance list: ETH + tracked ERC-20 tokens (4 decimal places, USD inline).
|
- Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
|
||||||
Each balance row is clickable → **AddressToken**
|
places, USD inline). Each balance row is clickable → **AddressToken**
|
||||||
- Send / Receive / + Token buttons
|
- Send / Receive / + Token buttons and a "···" menu button
|
||||||
|
- "···" dropdown containing a single "Export Private Key" entry
|
||||||
- Transaction list (with ENS resolution for counterparties)
|
- Transaction list (with ENS resolution for counterparties)
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Tap balance row → **AddressToken** (for that token)
|
- Tap balance row → **AddressToken** (for that token)
|
||||||
- "Send" → **Send**
|
- "Send" → **Send** (refuses with a flash message on a zero balance)
|
||||||
- "Receive" → **Receive**
|
- "Receive" → **Receive**
|
||||||
- "+ Token" → **AddToken**
|
- "+ Token" → **AddToken**
|
||||||
|
- "···" → "Export Private Key" → **ExportPrivKey**
|
||||||
- Tap transaction row → **TransactionDetail**
|
- Tap transaction row → **TransactionDetail**
|
||||||
- "Back" → **Home**
|
- "Back" → previous screen (Home)
|
||||||
|
|
||||||
#### AddressToken
|
#### ExportPrivKey (`export-privkey`)
|
||||||
|
|
||||||
|
- **When**: User chose "Export Private Key" from the "···" menu on
|
||||||
|
AddressDetail. This screen discloses secret material.
|
||||||
|
- **Elements**:
|
||||||
|
- "Back" button
|
||||||
|
- Blockie identicon (48px, centered)
|
||||||
|
- "Export Private Key" heading
|
||||||
|
- "Wallet Name — Address N" and the full address (etherscan link, tap to
|
||||||
|
copy)
|
||||||
|
- Warning that anyone holding the private key can transfer all funds from
|
||||||
|
the address
|
||||||
|
- Error line
|
||||||
|
- Password input and "Reveal" button, shown until the key is revealed
|
||||||
|
- The private key on a highlighted background, tap to copy, shown only after
|
||||||
|
the password has been accepted
|
||||||
|
- **Transitions**:
|
||||||
|
- "Reveal" (correct password) → decrypts the wallet secret, derives this
|
||||||
|
address's key, hides the password input and shows the key (no screen
|
||||||
|
change)
|
||||||
|
- "Reveal" (wrong password) → "Wrong password." on the error line, nothing
|
||||||
|
revealed
|
||||||
|
- "Back" → clears the key and password from the DOM, then → previous screen
|
||||||
|
(AddressDetail)
|
||||||
|
|
||||||
|
#### AddressToken (`address-token`)
|
||||||
|
|
||||||
- **When**: User clicked a specific token balance on AddressDetail.
|
- **When**: User clicked a specific token balance on AddressDetail.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
@@ -453,49 +535,64 @@ transitions.
|
|||||||
- USD total for this token
|
- USD total for this token
|
||||||
- Single token balance line (4 decimal places)
|
- Single token balance line (4 decimal places)
|
||||||
- Send / Receive buttons
|
- Send / Receive buttons
|
||||||
|
- Token contract well (ERC-20 only): full contract address (tap to copy,
|
||||||
|
etherscan link) plus name, symbol, decimals, holder count and project
|
||||||
|
website where known
|
||||||
- Token-filtered transaction list (only this token's transfers)
|
- Token-filtered transaction list (only this token's transfers)
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Send" → **Send** (token pre-selected and locked in dropdown)
|
- "Send" → **Send** (token locked: the dropdown is replaced by a static
|
||||||
|
symbol and contract address)
|
||||||
- "Receive" → **Receive** (ERC-20 warning shown for non-ETH tokens)
|
- "Receive" → **Receive** (ERC-20 warning shown for non-ETH tokens)
|
||||||
- Tap transaction row → **TransactionDetail**
|
- Tap transaction row → **TransactionDetail**
|
||||||
- "Back" → **AddressDetail**
|
- "Back" → previous screen (AddressDetail)
|
||||||
|
|
||||||
#### Send
|
#### Send (`send`)
|
||||||
|
|
||||||
- **When**: User wants to send ETH or a token from this address.
|
- **When**: User wants to send ETH or a token, from Home, AddressDetail, or
|
||||||
|
AddressToken.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Send" heading, "Back" button
|
- "Back" button, "Send" heading
|
||||||
- From: address with color dot + etherscan link
|
- From: address with color dot + etherscan link
|
||||||
- What to send: token dropdown (or static display with contract address when
|
- What to send: token dropdown (or static display with contract address when
|
||||||
locked from AddressToken)
|
locked from AddressToken)
|
||||||
- To: address or ENS name input
|
- To: address or ENS name input, with an inline validation message
|
||||||
- Amount input with current balance display
|
- Amount input with current balance display
|
||||||
- "Review" button
|
- "Review" button, disabled until the recipient validates
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||||||
- "Back" → **AddressToken** (if came from token view) or **AddressDetail**
|
- "Review" with an unresolvable ENS name or an invalid amount → flash
|
||||||
|
message, no screen change
|
||||||
|
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
|
||||||
|
|
||||||
#### ConfirmTx
|
#### ConfirmTx (`confirm-tx`)
|
||||||
|
|
||||||
- **When**: User reviewed send details and is ready to authorize.
|
- **When**: User reviewed send details and is ready to authorize.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Confirm Transaction" heading, "Back" button
|
- "Back" button, "Confirm Transaction" heading
|
||||||
- Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)"
|
- Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)"
|
||||||
- Token contract: full address + etherscan link (ERC-20 only)
|
- Token contract: full address + etherscan link (ERC-20 only)
|
||||||
- From: blockie + color dot + full address + etherscan link + wallet title
|
- From: blockie + color dot + full address + etherscan link + wallet title
|
||||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||||
- Amount: value + symbol (USD in parentheses)
|
- Amount: value + symbol (USD in parentheses)
|
||||||
- Your balance: value + symbol (USD in parentheses)
|
- Your balance: value + symbol (USD in parentheses)
|
||||||
- Estimated network fee: ETH amount (USD in parentheses), fetched async
|
- Estimated network fee: "Estimating..." then the ETH amount (USD in
|
||||||
- Warnings (scam address, self-send)
|
parentheses) or "Unable to estimate", fetched async
|
||||||
|
- Warnings: inline warnings from the local checks (scam address, self-send)
|
||||||
|
plus four reserved warning boxes made visible by the async checks —
|
||||||
|
recipient with no transaction history, recipient is a contract, burn
|
||||||
|
address, and an Etherscan phishing/scam label
|
||||||
- Errors (insufficient balance)
|
- Errors (insufficient balance)
|
||||||
- "Send" button (disabled if errors)
|
- Password: an inline field on this screen, not a modal, with its own error
|
||||||
|
line
|
||||||
|
- "Sign & Send" button (disabled if errors)
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Send" → password modal → broadcast tx → **WaitTx**
|
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
|
||||||
- "Send" → password modal → broadcast fails → **ErrorTx**
|
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
|
||||||
|
- "Sign & Send" (wrong password) → "Wrong password." on the password error
|
||||||
|
line, no screen change
|
||||||
- "Back" → **Send**
|
- "Back" → **Send**
|
||||||
|
|
||||||
#### WaitTx
|
#### WaitTx (`wait-tx`)
|
||||||
|
|
||||||
- **When**: Transaction has been broadcast, waiting for on-chain confirmation.
|
- **When**: Transaction has been broadcast, waiting for on-chain confirmation.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
@@ -509,20 +606,24 @@ transitions.
|
|||||||
- Receipt found → **SuccessTx**
|
- Receipt found → **SuccessTx**
|
||||||
- 60 seconds without confirmation → **ErrorTx** (timeout message)
|
- 60 seconds without confirmation → **ErrorTx** (timeout message)
|
||||||
|
|
||||||
#### SuccessTx
|
#### SuccessTx (`success-tx`)
|
||||||
|
|
||||||
- **When**: Transaction confirmed on-chain.
|
- **When**: Transaction confirmed on-chain.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Transaction Confirmed" heading
|
- "Transaction Confirmed" heading
|
||||||
|
- Decoded action well (shown when the transaction carried recognized
|
||||||
|
calldata; the top-level Amount and To are hidden in that case)
|
||||||
- Amount + symbol
|
- Amount + symbol
|
||||||
- To: color dot + full address + etherscan link
|
- To: color dot + full address + etherscan link
|
||||||
- Block number
|
- Block number
|
||||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||||
- "Done" button
|
- "Done" button
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Done" → **AddressToken** (if `selectedToken` set) or **AddressDetail**
|
- "Done" in the approval popup → closes the popup window
|
||||||
|
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
|
||||||
|
(if `selectedToken` set) or **AddressDetail**
|
||||||
|
|
||||||
#### ErrorTx
|
#### ErrorTx (`error-tx`)
|
||||||
|
|
||||||
- **When**: Transaction broadcast failed, or timed out waiting for confirmation.
|
- **When**: Transaction broadcast failed, or timed out waiting for confirmation.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
@@ -534,24 +635,28 @@ transitions.
|
|||||||
full hash (tap to copy) + etherscan link
|
full hash (tap to copy) + etherscan link
|
||||||
- "Done" button
|
- "Done" button
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Done" → **AddressToken** (if `selectedToken` set) or **AddressDetail**
|
- "Done" in the approval popup → closes the popup window
|
||||||
|
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
|
||||||
|
(if `selectedToken` set) or **AddressDetail**
|
||||||
|
|
||||||
#### Receive
|
#### Receive (`receive`)
|
||||||
|
|
||||||
- **When**: User wants to receive funds at this address.
|
- **When**: User wants to receive funds at this address, from Home,
|
||||||
|
AddressDetail, or AddressToken.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Receive" heading, "Back" button
|
- "Back" button, "Receive" heading
|
||||||
- Instruction text
|
- Instruction text
|
||||||
- QR code encoding the address
|
- QR code encoding the address
|
||||||
- Full address (color dot, selectable, etherscan link)
|
- Full address (color dot, selectable, etherscan link)
|
||||||
- "Copy address" button
|
- "Copy address" button
|
||||||
- ERC-20 warning (shown when navigating from AddressToken for non-ETH token)
|
- ERC-20 warning (shown when navigating from AddressToken for non-ETH token)
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail**
|
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
|
||||||
|
|
||||||
#### TransactionDetail
|
#### TransactionDetail (`transaction`)
|
||||||
|
|
||||||
- **When**: User tapped a transaction row from AddressDetail or AddressToken.
|
- **When**: User tapped a transaction row on Home, AddressDetail, or
|
||||||
|
AddressToken.
|
||||||
- **Elements** (grouped into logical blocks using light well containers; field
|
- **Elements** (grouped into logical blocks using light well containers; field
|
||||||
labels are self-explanatory so groups have no headings):
|
labels are self-explanatory so groups have no headings):
|
||||||
- "Transaction" heading, "Back" button
|
- "Transaction" heading, "Back" button
|
||||||
@@ -576,91 +681,182 @@ transitions.
|
|||||||
- Raw data (shown when calldata is present): full calldata in monospace
|
- Raw data (shown when calldata is present): full calldata in monospace
|
||||||
dashed border
|
dashed border
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail**
|
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
|
||||||
|
|
||||||
#### AddToken
|
#### AddToken (`add-token`)
|
||||||
|
|
||||||
- **When**: User wants to track an ERC-20 token on this address.
|
- **When**: User wants to track an ERC-20 token, reached from "+ Token" on
|
||||||
|
AddressDetail.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Add Token" heading, "Back" button
|
- "Back" button, "Add Token" heading
|
||||||
- Instruction text (find contract address on Etherscan)
|
- Instruction text (find contract address on Etherscan)
|
||||||
- Contract address input
|
- Contract address input
|
||||||
- Token info preview (name, symbol — fetched from contract)
|
- Status line ("Looking up token...", cleared or replaced on failure)
|
||||||
- Common token quick-pick buttons
|
- Common token quick-pick buttons (top 25 by market cap), which fill the
|
||||||
|
contract address input
|
||||||
- "Add" button
|
- "Add" button
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Add" (valid contract) → **AddressDetail**
|
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
|
||||||
- "Back" → **AddressDetail**
|
**AddressDetail**
|
||||||
|
- "Add" with a token already tracked, a scam-listed address, or a failed
|
||||||
|
contract lookup → flash message, no screen change
|
||||||
|
- "Back" → previous screen (AddressDetail)
|
||||||
|
|
||||||
#### Settings
|
#### Settings (`settings`)
|
||||||
|
|
||||||
- **When**: User tapped Settings gear from Home.
|
- **When**: User tapped the Settings gear.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Settings" heading, "Back" button
|
- "Back" button, "Settings" heading
|
||||||
- Wallets: "+ Add wallet" button
|
- Wallets: one row per wallet with its name (tap to rename inline) and an
|
||||||
- Display: "Show tracked tokens with zero balance" checkbox
|
`[x]` delete button, plus a "+ Add wallet" button
|
||||||
- Ethereum RPC: endpoint URL input + "Save" button
|
- Tracked Tokens: one row per tracked token with an `[x]` remove button,
|
||||||
- Blockscout API: endpoint URL input + "Save" button
|
plus a "+ Add token" button
|
||||||
|
- Display: "Show tracked tokens with zero balance" checkbox and a Theme
|
||||||
|
selector (System / Light / Dark)
|
||||||
|
- Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
|
||||||
|
resets the RPC and Blockscout endpoints to that network's defaults
|
||||||
|
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
||||||
|
`eth_chainId` before being saved)
|
||||||
|
- Blockscout API: endpoint URL input + "Save" button (validated against
|
||||||
|
`/stats` before being saved)
|
||||||
- Token Spam Protection:
|
- Token Spam Protection:
|
||||||
- "Hide tokens with fewer than 1,000 holders" checkbox
|
- "Hide tokens with fewer than 1,000 holders" checkbox
|
||||||
- "Hide transactions from detected fraud contracts" checkbox
|
- "Hide transactions from detected fraud contracts" checkbox
|
||||||
- "Hide dust transactions below N gwei" checkbox + threshold input
|
- "Hide dust transactions below N gwei" checkbox + threshold input
|
||||||
|
- "UTC Timestamps" checkbox
|
||||||
- Allowed Sites: list with remove buttons
|
- Allowed Sites: list with remove buttons
|
||||||
- Denied Sites: list with remove buttons
|
- Denied Sites: list with remove buttons
|
||||||
|
- About: project link, license, author, version, release date, and the
|
||||||
|
commit, which links to the commit in the repository
|
||||||
|
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
|
||||||
|
turns on the red banner and verbose logging
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "+ Add wallet" → **AddWallet**
|
- "+ Add wallet" → **AddWallet**
|
||||||
- "Back" (or Settings gear again) → **Home**
|
- "+ Add token" → **SettingsAddToken**
|
||||||
|
- `[x]` on a wallet → **DeleteWallet**
|
||||||
|
- Tap wallet name → inline rename field (no screen change)
|
||||||
|
- `[x]` on a tracked token or a site → removes it in place (no screen
|
||||||
|
change)
|
||||||
|
- Ten clicks on the version → reveals the Debug well (no screen change)
|
||||||
|
- "Back" (or Settings gear again) → previous screen (Home)
|
||||||
|
|
||||||
#### SiteApproval
|
#### DeleteWallet (`delete-wallet-confirm`)
|
||||||
|
|
||||||
- **When**: A website requests wallet access via `eth_requestAccounts`. Opened
|
- **When**: User tapped the `[x]` next to a wallet in Settings.
|
||||||
in a separate popup by the background script.
|
- **Elements**:
|
||||||
|
- "Back" button, "Delete Wallet" heading
|
||||||
|
- Warning naming the wallet and stating that deletion is permanent and any
|
||||||
|
funds are unrecoverable without the recovery phrase
|
||||||
|
- Error line
|
||||||
|
- Password input
|
||||||
|
- "Confirm Delete" button
|
||||||
|
- **Transitions**:
|
||||||
|
- "Confirm Delete" (correct password, other wallets remain) → deletes the
|
||||||
|
wallet and its site permissions, then → **Settings** with a "Wallet
|
||||||
|
deleted." flash message
|
||||||
|
- "Confirm Delete" (correct password, last wallet) → deletes the wallet,
|
||||||
|
clears the selection and the navigation stack, then → **Welcome**
|
||||||
|
- Either way, the active address moves only if it belonged to the deleted
|
||||||
|
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
|
||||||
|
(`src/shared/walletDelete.js`)
|
||||||
|
- "Confirm Delete" (wrong password) → "Wrong password." on the error line,
|
||||||
|
nothing deleted
|
||||||
|
- "Back" → previous screen (Settings)
|
||||||
|
|
||||||
|
#### SettingsAddToken (`settings-addtoken`)
|
||||||
|
|
||||||
|
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
|
||||||
|
across every address, unlike AddToken which is reached from one address.
|
||||||
|
- **Elements**:
|
||||||
|
- "Back" button, "Add Token" heading
|
||||||
|
- Instruction text
|
||||||
|
- "Top tokens:" quick-pick buttons (top 10 by market cap; already-tracked
|
||||||
|
tokens are disabled)
|
||||||
|
- "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
|
||||||
|
"Add selected" button
|
||||||
|
- "Or enter contract address:" input, a status line, and an "Add" button
|
||||||
|
- **Transitions**:
|
||||||
|
- Any of the three add paths, on success → adds the token and shows an
|
||||||
|
"Added SYMBOL" flash message (no screen change)
|
||||||
|
- A duplicate, a scam-listed address, or a failed contract lookup → flash
|
||||||
|
message, no screen change
|
||||||
|
- "Back" → previous screen (Settings)
|
||||||
|
|
||||||
|
#### SiteApproval (`approve-site`)
|
||||||
|
|
||||||
|
- **When**: A website requests wallet access via `eth_requestAccounts` or
|
||||||
|
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
|
||||||
|
The background script prefers the toolbar popup (`action.openPopup()`) and
|
||||||
|
falls back to a separate popup window (`src/background/index.js`,
|
||||||
|
`requestApproval()`).
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Connection Request" heading
|
- "Connection Request" heading
|
||||||
- Site hostname (bold)
|
- Phishing warning banner (shown when the hostname is on the phishing
|
||||||
|
blocklist)
|
||||||
|
- Site hostname (bold) + "wants to connect to your wallet"
|
||||||
- Address that will be shared (color dot + full address + etherscan link)
|
- Address that will be shared (color dot + full address + etherscan link)
|
||||||
- "Remember my choice for this site" checkbox
|
- "Remember my choice for this site" checkbox
|
||||||
- "Allow" / "Deny" buttons
|
- "Allow" / "Deny" buttons
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Allow" / "Deny" → closes popup (returns result to background script)
|
- "Allow" / "Deny" → closes popup (returns result to background script; the
|
||||||
|
choice is persisted to the allowed or denied list when "Remember" is
|
||||||
|
checked)
|
||||||
|
- Popup closed without answering → treated as a denial
|
||||||
|
|
||||||
#### TxApproval
|
#### TxApproval (`approve-tx`)
|
||||||
|
|
||||||
- **When**: A connected website requests a transaction via
|
- **When**: A connected website requests a transaction via
|
||||||
`eth_sendTransaction`. Opened via the toolbar popup by the background script.
|
`eth_sendTransaction`. Always opened in a separate popup window by the
|
||||||
|
background script (`windows.create()`), because the request is triggered
|
||||||
|
programmatically rather than by a user gesture.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Transaction Request" heading
|
- "Transaction Request" heading
|
||||||
|
- Phishing warning banner (shown when the hostname is on the phishing
|
||||||
|
blocklist)
|
||||||
- Site hostname (bold) + "wants to send a transaction"
|
- Site hostname (bold) + "wants to send a transaction"
|
||||||
- Decoded action (if calldata is recognized): action name, token details,
|
- Decoded action (if calldata is recognized): action name, token details,
|
||||||
amounts, steps, deadline (see Transaction Decoding)
|
amounts, steps, deadline (see Transaction Decoding)
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
- To/Contract: color dot + full address + etherscan link (or "contract
|
- Contract: color dot + full address + etherscan link (or "contract
|
||||||
creation"), token symbol label if known
|
creation"), token symbol label if known
|
||||||
- Value: amount in ETH (4 decimal places)
|
- Value: amount in ETH (4 decimal places, USD in parentheses)
|
||||||
- Raw data: full calldata displayed inline (shown if present)
|
- Raw data: full calldata displayed inline (shown if present)
|
||||||
- Password input
|
- Password input and an error line
|
||||||
- "Confirm" / "Reject" buttons
|
- "Confirm" / "Reject" buttons
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Confirm" (with password) → closes popup (returns result to background)
|
- "Confirm" (correct password) → decrypts and signs in the popup, hands the
|
||||||
|
signed transaction to the background to broadcast, then → **WaitTx** in
|
||||||
|
the same popup window
|
||||||
|
- "Confirm" (wrong password) → error line, no screen change
|
||||||
- "Reject" → closes popup (returns rejection to background)
|
- "Reject" → closes popup (returns rejection to background)
|
||||||
|
- Popup window closed without answering → the request is rejected with
|
||||||
|
EIP-1193 code 4001
|
||||||
|
|
||||||
#### SignApproval
|
#### SignApproval (`approve-sign`)
|
||||||
|
|
||||||
- **When**: A connected website requests a message signature via
|
- **When**: A connected website requests a message signature via
|
||||||
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened via the toolbar
|
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
|
||||||
popup by the background script.
|
TxApproval, in a separate popup window.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Signature Request" heading
|
- "Signature Request" heading
|
||||||
|
- Phishing warning banner (shown when the hostname is on the phishing
|
||||||
|
blocklist)
|
||||||
- Site hostname (bold) + "wants you to sign a message"
|
- Site hostname (bold) + "wants you to sign a message"
|
||||||
|
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
||||||
- Type: "Personal message" or "Typed data (EIP-712)"
|
- Type: "Personal message" or "Typed data (EIP-712)"
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
|
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
|
||||||
message fields (EIP-712 typed data)
|
message fields (EIP-712 typed data)
|
||||||
- Password input
|
- Password input and an error line
|
||||||
- "Sign" / "Reject" buttons
|
- "Sign" / "Reject" buttons
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Sign" (with password) → signs locally → closes popup (returns signature)
|
- "Sign" (correct password) → signs locally → closes popup (returns
|
||||||
|
signature)
|
||||||
|
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
||||||
|
change
|
||||||
- "Reject" → closes popup (returns rejection to background)
|
- "Reject" → closes popup (returns rejection to background)
|
||||||
|
- Popup window closed without answering → the request is rejected with
|
||||||
|
EIP-1193 code 4001
|
||||||
|
|
||||||
### External Services
|
### External Services
|
||||||
|
|
||||||
@@ -696,7 +892,7 @@ communicates with three external services to function as a wallet:
|
|||||||
What the extension does NOT do:
|
What the extension does NOT do:
|
||||||
|
|
||||||
- No analytics or telemetry services
|
- No analytics or telemetry services
|
||||||
- No token list APIs (user adds tokens manually by contract address)
|
- No token list APIs (the top-250 token list is bundled at build time)
|
||||||
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
|
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
|
||||||
- No backend servers operated by the developer
|
- No backend servers operated by the developer
|
||||||
|
|
||||||
@@ -815,10 +1011,12 @@ hardcoded test phrase.
|
|||||||
- Create new HD wallet (generates 12-word recovery phrase)
|
- Create new HD wallet (generates 12-word recovery phrase)
|
||||||
- Import HD wallet from existing 12 or 24 word recovery phrase
|
- Import HD wallet from existing 12 or 24 word recovery phrase
|
||||||
- Import single-address wallet from private key
|
- Import single-address wallet from private key
|
||||||
|
- Import multi-address wallet from an extended private key (`xprv`)
|
||||||
- Add multiple addresses within an HD wallet
|
- Add multiple addresses within an HD wallet
|
||||||
- Manage multiple wallets simultaneously
|
- Manage multiple wallets simultaneously
|
||||||
- View ETH balance per address
|
- View ETH balance per address
|
||||||
- View ERC-20 token balances (user adds token by contract address)
|
- View ERC-20 token balances (bundled top-250 tokens, tokens with 1,000 or more
|
||||||
|
holders, and tokens the user adds by contract address)
|
||||||
- Send ETH to an address
|
- Send ETH to an address
|
||||||
- Send ERC-20 tokens to an address
|
- Send ERC-20 tokens to an address
|
||||||
- Receive ETH/tokens (display address, copy to clipboard, QR code)
|
- Receive ETH/tokens (display address, copy to clipboard, QR code)
|
||||||
@@ -964,7 +1162,8 @@ Currently supported:
|
|||||||
- Built in token swaps (use a DEX in the browser)
|
- Built in token swaps (use a DEX in the browser)
|
||||||
- Analytics, telemetry, or tracking of any kind
|
- Analytics, telemetry, or tracking of any kind
|
||||||
- Advertisements or promotions
|
- Advertisements or promotions
|
||||||
- Obscure token list auto-discovery (user adds tokens manually)
|
- Obscure token list auto-discovery — nothing outside the bundled list, the
|
||||||
|
1,000-holder floor, and the tokens the user added by contract address
|
||||||
- We detect common/popular ERC20s in the basic case
|
- We detect common/popular ERC20s in the basic case
|
||||||
- Fiat on/off ramps
|
- Fiat on/off ramps
|
||||||
- Extensive transaction decoding/parsing
|
- Extensive transaction decoding/parsing
|
||||||
@@ -986,7 +1185,7 @@ Currently supported:
|
|||||||
|
|
||||||
### Transactions
|
### Transactions
|
||||||
|
|
||||||
- [ ] Gas estimation and fee display before confirming
|
- [x] Gas estimation and fee display before confirming
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
@@ -1021,14 +1220,18 @@ This repository includes data files from third-party projects that are not
|
|||||||
covered by the GPL-3.0 license above. These files, their copyright holders, and
|
covered by the GPL-3.0 license above. These files, their copyright holders, and
|
||||||
their licenses are:
|
their licenses are:
|
||||||
|
|
||||||
| File | Source | Copyright | License |
|
| File | Source | Copyright | License |
|
||||||
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
|
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
|
||||||
| `src/shared/phishingBlocklist.json` | [eth-phishing-detect](https://github.com/AugurProject/eth-phishing-detect) community-maintained phishing domain blocklist | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
|
| `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, vendored from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
|
||||||
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
|
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
|
||||||
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
|
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
|
||||||
|
|
||||||
The full license texts for these third-party files are included in the
|
The full license texts for these third-party files are included in the
|
||||||
[LICENSE](LICENSE) file.
|
[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
|
||||||
|
link because the upstream is hosted under a competitor's organization name,
|
||||||
|
which project policy keeps out of code and documentation; the vendored copy and
|
||||||
|
the runtime refresh both come from that upstream, whose URL is the
|
||||||
|
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`.
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
|
|||||||
29
TODO.md
29
TODO.md
@@ -44,13 +44,42 @@ undefined identifiers, which is how
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-11: Approval verification became an allowlist — transaction type
|
||||||
|
restricted to 0/1/2 so an EIP-7702 delegation can no longer ride along on an
|
||||||
|
approved transfer, every consequential field compared, and the artifact
|
||||||
|
re-serialized from the checked fields alone; broadcast failure is now terminal
|
||||||
|
([#174](https://git.eeqj.de/sneak/AutistMask/issues/174)).
|
||||||
|
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
|
||||||
|
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
|
||||||
|
the Makefile-only targets documented in the README
|
||||||
|
([#166](https://git.eeqj.de/sneak/AutistMask/issues/166)).
|
||||||
|
- 2026-08-11: `script/verify-build` diagnostics corrected: the both-markers
|
||||||
|
message now states what is and is not proven, an unreadable bundle is
|
||||||
|
diagnosed as an I/O fault rather than as changed output, the `*.js` assumption
|
||||||
|
lives only in `build.js`, and the unlisted-bundle scan hard-fails when it
|
||||||
|
cannot enumerate `dist/`
|
||||||
|
([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)).
|
||||||
|
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
|
||||||
|
attribution, token-display rule, navigation model
|
||||||
|
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
|
||||||
|
- 2026-08-11: README Screen Map rebuilt from the code — every screen, element
|
||||||
|
and transition re-verified against `src/popup/`
|
||||||
|
([#164](https://git.eeqj.de/sneak/AutistMask/issues/164)).
|
||||||
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
|
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
|
||||||
all five network destinations documented, password/Settings/Add Wallet
|
all five network destinations documented, password/Settings/Add Wallet
|
||||||
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
|
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
|
||||||
|
- 2026-08-11: `loadState()` now derives `hasWallet` from the wallet list instead
|
||||||
|
of trusting the persisted flag, so a profile already saved inconsistent no
|
||||||
|
longer stays broken on every load
|
||||||
|
([#195](https://git.eeqj.de/sneak/AutistMask/issues/195)).
|
||||||
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
|
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
|
||||||
remaining wallets, the selection only moves when it was deleted, and the
|
remaining wallets, the selection only moves when it was deleted, and the
|
||||||
active-address change is broadcast to connected sites
|
active-address change is broadcast to connected sites
|
||||||
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
|
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
|
||||||
|
- 2026-08-11: One row per on-chain value movement in transaction history: the
|
||||||
|
merge moved into the pure `mergeTransactions` and the zero-ETH native side of
|
||||||
|
a plain ERC-20 transfer absorbed into its token row
|
||||||
|
([#177](https://git.eeqj.de/sneak/AutistMask/issues/177)).
|
||||||
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
|
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
|
||||||
on `next`, with Status and Next Step refreshed
|
on `next`, with Status and Next Step refreshed
|
||||||
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).
|
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).
|
||||||
|
|||||||
17
build.js
17
build.js
@@ -29,6 +29,12 @@ function repoRelative(p) {
|
|||||||
// reports every input that contributed to an output in the metafile, which is
|
// reports every input that contributed to an output in the metafile, which is
|
||||||
// the authoritative answer to "is constants.js in this bundle" — unlike
|
// the authoritative answer to "is constants.js in this bundle" — unlike
|
||||||
// searching the minified text, it does not depend on what survived minification.
|
// searching the minified text, it does not depend on what survived minification.
|
||||||
|
//
|
||||||
|
// The ".js" filter below is the only place that assumption lives:
|
||||||
|
// script/verify-build searches every file and symlink under dist/ for a
|
||||||
|
// marker, without filtering by extension, and hard-fails if it cannot walk the
|
||||||
|
// whole tree, so a bundle emitted under some other extension fails there as
|
||||||
|
// unlisted rather than escaping both checks at once.
|
||||||
function outputsContainingAuditedModule(metafile) {
|
function outputsContainingAuditedModule(metafile) {
|
||||||
return Object.entries(metafile.outputs)
|
return Object.entries(metafile.outputs)
|
||||||
.filter(([outFile, info]) => {
|
.filter(([outFile, info]) => {
|
||||||
@@ -115,8 +121,17 @@ async function build() {
|
|||||||
// build that never gets around to writing one cannot be verified against
|
// build that never gets around to writing one cannot be verified against
|
||||||
// a stale list.
|
// a stale list.
|
||||||
fs.rmSync(BUNDLE_MANIFEST, { force: true });
|
fs.rmSync(BUNDLE_MANIFEST, { force: true });
|
||||||
|
// The locally installed binary, not `npx` — npx silently fetches from the
|
||||||
|
// registry when the binary is absent, which is an unpinned network fetch
|
||||||
|
// in the middle of a build.
|
||||||
|
const tailwindBin = path.join(
|
||||||
|
__dirname,
|
||||||
|
"node_modules",
|
||||||
|
".bin",
|
||||||
|
"tailwindcss",
|
||||||
|
);
|
||||||
execSync(
|
execSync(
|
||||||
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`,
|
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
|
||||||
{ stdio: "inherit" },
|
{ stdio: "inherit" },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --forceExit",
|
"test": "jest --forceExit",
|
||||||
|
"test:verbose": "jest --forceExit --verbose",
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"lint": "prettier --check .",
|
"lint": "prettier --check .",
|
||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
echo "Running tests..."
|
echo "Running tests..."
|
||||||
timeout 30 yarn run test 2>&1
|
timeout 30 yarn run test 2>&1 || {
|
||||||
|
echo "--- Rerunning with --verbose for details ---"
|
||||||
|
timeout 30 yarn run test:verbose 2>&1 || true
|
||||||
|
# Always fail: the first run already proved the tests are broken, so a
|
||||||
|
# flaky pass on the rerun must not turn the build green.
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -34,16 +34,51 @@ fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match
|
||||||
|
# (exit 1) are answers about the emitted output. Anything else (exit 2: the
|
||||||
|
# file could not be read) is not an answer at all, and must not be reported as
|
||||||
|
# "no marker" — that would blame the bundle for a permissions or I/O fault.
|
||||||
has_marker() {
|
has_marker() {
|
||||||
grep -q -F "$1" "$2" 2>/dev/null
|
_hm_status=0
|
||||||
|
grep -q -F -e "$1" -- "$2" || _hm_status=$?
|
||||||
|
case "$_hm_status" in
|
||||||
|
0) return 0 ;;
|
||||||
|
1) return 1 ;;
|
||||||
|
*)
|
||||||
|
fail "grep exited $_hm_status reading $2, so the file could not be
|
||||||
|
searched and its DEBUG state was not checked at all. That is a permissions
|
||||||
|
or I/O fault on the artifact, not a change in the emitted output. Refusing
|
||||||
|
to report success."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Does the manifest list the path $1, as a whole line? Same discipline as
|
||||||
|
# has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the
|
||||||
|
# manifest could not be read and is not an answer at all. Without this, an
|
||||||
|
# unreadable manifest reads as "this file is not listed" and every emitted
|
||||||
|
# bundle gets reported as an unlisted one.
|
||||||
|
is_listed() {
|
||||||
|
_il_status=0
|
||||||
|
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
|
||||||
|
case "$_il_status" in
|
||||||
|
0) return 0 ;;
|
||||||
|
1) return 1 ;;
|
||||||
|
*)
|
||||||
|
fail "grep exited $_il_status reading $MANIFEST, so it could not be
|
||||||
|
searched and nothing was established about which bundles it lists. That is
|
||||||
|
a permissions or I/O fault on the manifest, not a stale manifest. Refusing
|
||||||
|
to report success."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be
|
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be
|
||||||
# present. Both means the ternary in constants.js was never folded, which is
|
# present. Both means the ternary in constants.js was never folded, which is
|
||||||
# what happens when the __BUILD_DEBUG__ define goes missing from build.js:
|
# what happens when the __BUILD_DEBUG__ define goes missing from build.js:
|
||||||
# DEBUG stops being known at build time and the debug branch is live again.
|
# DEBUG stops being known at build time. Neither means we are reading output
|
||||||
# Neither means we are reading output we do not understand. Both are hard
|
# we do not understand. Both are hard failures; neither is ever treated as
|
||||||
# failures; neither is ever treated as absence of a problem.
|
# absence of a problem.
|
||||||
read_marker() {
|
read_marker() {
|
||||||
_file="$1"
|
_file="$1"
|
||||||
_on=no
|
_on=no
|
||||||
@@ -52,9 +87,14 @@ read_marker() {
|
|||||||
if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi
|
if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi
|
||||||
|
|
||||||
if [ "$_on" = yes ] && [ "$_off" = yes ]; then
|
if [ "$_on" = yes ] && [ "$_off" = yes ]; then
|
||||||
fail "$_file carries both debug markers, so the build-time DEBUG value
|
fail "$_file carries both debug markers, so DEBUG was not resolved at
|
||||||
was never resolved and the debug branch is still live. Check that build.js
|
build time: the ternary in src/shared/constants.js survived into the
|
||||||
still defines __BUILD_DEBUG__."
|
emitted output. This does not mean the debug branch is live in this
|
||||||
|
artifact: an unresolved __BUILD_DEBUG__ is undeclared in extension
|
||||||
|
context, so DEBUG evaluates to false at runtime. It does mean the
|
||||||
|
release/debug distinction is no longer enforced at build time, and which
|
||||||
|
way that fallback happens to evaluate is then an accident a refactor can
|
||||||
|
flip. Check that build.js still defines __BUILD_DEBUG__."
|
||||||
fi
|
fi
|
||||||
if [ "$_on" = no ] && [ "$_off" = no ]; then
|
if [ "$_on" = no ] && [ "$_off" = no ]; then
|
||||||
fail "$_file carries no debug marker, so its DEBUG state cannot be
|
fail "$_file carries no debug marker, so its DEBUG state cannot be
|
||||||
@@ -70,13 +110,43 @@ read_marker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# The manifest says which bundles must carry a marker. This says no other
|
# The manifest says which bundles must carry a marker. This says no other
|
||||||
# emitted bundle may carry one, which catches a manifest that has gone stale
|
# emitted file may carry one, which catches a manifest that has gone stale
|
||||||
# or short rather than trusting whatever it happens to list.
|
# or short rather than trusting whatever it happens to list.
|
||||||
|
#
|
||||||
|
# Deliberately unfiltered by extension. build.js selects manifest entries with
|
||||||
|
# an endsWith(".js") test; repeating that literal here would mean a bundle
|
||||||
|
# emitted under some other extension escaped the manifest AND this check at
|
||||||
|
# once, which is the correlated blind spot the two-source design exists to
|
||||||
|
# avoid. Every file under dist/ is searched, so build.js's filter is the only
|
||||||
|
# place the assumption lives and this check is what catches it being wrong.
|
||||||
|
#
|
||||||
|
# That claim only holds if the walk is exhaustive, so two things are enforced
|
||||||
|
# here rather than assumed:
|
||||||
|
#
|
||||||
|
# - find's exit status is checked. A subtree it cannot descend is reported on
|
||||||
|
# stderr and then simply missing from the listing, so an unchecked status
|
||||||
|
# turns "could not look" into "nothing was there" — the same conflation
|
||||||
|
# has_marker exists to prevent. The status cannot be read off a pipeline
|
||||||
|
# ending in sort, so the sort is a separate step.
|
||||||
|
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
|
||||||
|
# reachable under an unlisted path in dist/ is a stale manifest whether the
|
||||||
|
# path is a link or a file, and grep reads through the link. A link that
|
||||||
|
# cannot be read through — dangling, or pointing at a directory — fails
|
||||||
|
# hard via has_marker's exit-2 path, which is the fail-closed answer: the
|
||||||
|
# build emits neither, so their DEBUG state is unproven, not fine.
|
||||||
check_unlisted_bundles() {
|
check_unlisted_bundles() {
|
||||||
_listing="$(find dist -type f -name '*.js' | sort)"
|
_find_status=0
|
||||||
|
_listing="$(find dist \( -type f -o -type l \) -print)" || _find_status=$?
|
||||||
|
[ "$_find_status" -eq 0 ] ||
|
||||||
|
fail "find exited $_find_status enumerating dist/, so part of the tree
|
||||||
|
was never walked and nothing was established about the files in it. Any
|
||||||
|
unlisted bundle there went unchecked. That is a permissions or I/O fault on
|
||||||
|
the artifact, not a stale manifest. Refusing to report success."
|
||||||
|
_listing="$(printf '%s\n' "$_listing" | sort)"
|
||||||
|
|
||||||
while read -r _file; do
|
while read -r _file; do
|
||||||
[ -n "$_file" ] || continue
|
[ -n "$_file" ] || continue
|
||||||
if grep -q -x -F "$_file" "$MANIFEST"; then
|
if is_listed "$_file"; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if has_marker "$MARKER_ON" "$_file" ||
|
if has_marker "$MARKER_ON" "$_file" ||
|
||||||
@@ -113,12 +183,18 @@ main() {
|
|||||||
fail "$MANIFEST is empty, so no emitted bundle was found to contain
|
fail "$MANIFEST is empty, so no emitted bundle was found to contain
|
||||||
src/shared/constants.js. That is never correct, so it is a failure and not
|
src/shared/constants.js. That is never correct, so it is a failure and not
|
||||||
a pass."
|
a pass."
|
||||||
|
[ -r "$MANIFEST" ] ||
|
||||||
|
fail "$MANIFEST is not readable, so nothing was inspected. That is a
|
||||||
|
permissions or I/O fault, not a pass."
|
||||||
|
|
||||||
count=0
|
count=0
|
||||||
while read -r file; do
|
while read -r file; do
|
||||||
[ -n "$file" ] || continue
|
[ -n "$file" ] || continue
|
||||||
[ -f "$file" ] ||
|
[ -f "$file" ] ||
|
||||||
fail "$MANIFEST lists $file, which does not exist."
|
fail "$MANIFEST lists $file, which does not exist."
|
||||||
|
[ -s "$file" ] ||
|
||||||
|
fail "$MANIFEST lists $file, which is empty. An empty bundle
|
||||||
|
carries no marker and proves nothing, so this is a failure and not a pass."
|
||||||
read_marker "$file"
|
read_marker "$file"
|
||||||
[ "$MARKER" = "$expected" ] ||
|
[ "$MARKER" = "$expected" ] ||
|
||||||
fail "$file is $MARKER but this build expects $expected."
|
fail "$file is $MARKER but this build expects $expected."
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ const {
|
|||||||
} = require("../shared/state");
|
} = require("../shared/state");
|
||||||
const { refreshBalances, getProvider } = require("../shared/balances");
|
const { refreshBalances, getProvider } = require("../shared/balances");
|
||||||
const { debugFetch } = require("../shared/log");
|
const { debugFetch } = require("../shared/log");
|
||||||
const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
|
const {
|
||||||
|
verifySignedTx,
|
||||||
|
verifySignature,
|
||||||
|
failureIsRetryable,
|
||||||
|
describeTxFailure,
|
||||||
|
TX_STAGE_SIGN,
|
||||||
|
TX_STAGE_VERIFY,
|
||||||
|
TX_STAGE_BROADCAST,
|
||||||
|
} = require("../shared/approvalVerify");
|
||||||
const {
|
const {
|
||||||
isPhishingDomain,
|
isPhishingDomain,
|
||||||
updatePhishingList,
|
updatePhishingList,
|
||||||
@@ -100,6 +108,15 @@ function resetPopupUrl() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retire a pending approval. Only called once the request it belongs to has
|
||||||
|
// an outcome: an approval that failed in a way the user can retry stays in
|
||||||
|
// pendingApprovals, so a second attempt signs the same approved payload
|
||||||
|
// instead of finding nothing to sign.
|
||||||
|
function finishApproval(id) {
|
||||||
|
delete pendingApprovals[id];
|
||||||
|
resetPopupUrl();
|
||||||
|
}
|
||||||
|
|
||||||
// Open approval in a separate popup window.
|
// Open approval in a separate popup window.
|
||||||
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
|
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
|
||||||
// not from a user gesture) and the fallback for site-connection approvals.
|
// not from a user gesture) and the fallback for site-connection approvals.
|
||||||
@@ -713,21 +730,25 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
if (msg.type === "AUTISTMASK_TX_RESPONSE") {
|
if (msg.type === "AUTISTMASK_TX_RESPONSE") {
|
||||||
const approval = pendingApprovals[msg.id];
|
const approval = pendingApprovals[msg.id];
|
||||||
if (!approval) return false;
|
if (!approval) return false;
|
||||||
delete pendingApprovals[msg.id];
|
|
||||||
resetPopupUrl();
|
|
||||||
|
|
||||||
if (!msg.approved) {
|
if (!msg.approved) {
|
||||||
|
finishApproval(msg.id);
|
||||||
approval.resolve({
|
approval.resolve({
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The popup signs; it reports back here when it could not. Fail the
|
// The popup signs; it reports back here when it could not. Keep the
|
||||||
// request the same way this handler used to when it did the signing.
|
// approval so the user can correct the problem and try again with the
|
||||||
|
// transaction they already saw.
|
||||||
if (msg.error) {
|
if (msg.error) {
|
||||||
approval.resolve({ error: { message: msg.error } });
|
const outcome = describeTxFailure(TX_STAGE_SIGN, msg.error);
|
||||||
sendResponse({ error: msg.error });
|
sendResponse({
|
||||||
|
error: outcome.error,
|
||||||
|
retryable: outcome.retryable,
|
||||||
|
stage: TX_STAGE_SIGN,
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,22 +758,52 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
const activeAddress = await getActiveAddress();
|
const activeAddress = await getActiveAddress();
|
||||||
// The popup holds the secret, but the background stays the
|
// The popup holds the secret, but the background stays the
|
||||||
// authority on what is broadcast: the raw transaction must be
|
// authority on what is broadcast: the raw transaction must be
|
||||||
// the approved one, signed by the approved address.
|
// the approved one, signed by the approved address, on the
|
||||||
|
// network that is selected.
|
||||||
verifySignedTx(
|
verifySignedTx(
|
||||||
msg.rawSignedTx,
|
msg.rawSignedTx,
|
||||||
approval.txParams,
|
approval.txParams,
|
||||||
activeAddress,
|
activeAddress,
|
||||||
|
currentNetwork().chainId,
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// A signed transaction that is not the approved one is not
|
||||||
|
// retried against that approval; it is refused outright.
|
||||||
|
// Anything else that failed before the check ran is the
|
||||||
|
// user's to retry.
|
||||||
|
const outcome = describeTxFailure(TX_STAGE_VERIFY, e);
|
||||||
|
if (outcome.spendApproval) {
|
||||||
|
finishApproval(msg.id);
|
||||||
|
approval.resolve({ error: { message: outcome.error } });
|
||||||
|
}
|
||||||
|
sendResponse({
|
||||||
|
error: outcome.error,
|
||||||
|
retryable: outcome.retryable,
|
||||||
|
stage: TX_STAGE_VERIFY,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const provider = getProvider(state.rpcUrl);
|
const provider = getProvider(state.rpcUrl);
|
||||||
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
||||||
|
finishApproval(msg.id);
|
||||||
approval.resolve({ txHash: tx.hash });
|
approval.resolve({ txHash: tx.hash });
|
||||||
sendResponse({ txHash: tx.hash });
|
sendResponse({ txHash: tx.hash });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errMsg = e.shortMessage || e.message;
|
// Terminal, never retried: the node may have accepted the
|
||||||
approval.resolve({
|
// transaction and still failed to answer, and the popup's
|
||||||
error: { message: errMsg },
|
// retry re-signs at a freshly fetched nonce rather than
|
||||||
|
// re-broadcasting these bytes. Retrying would send the
|
||||||
|
// approved transfer a second time.
|
||||||
|
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
|
||||||
|
finishApproval(msg.id);
|
||||||
|
approval.resolve({ error: { message: outcome.error } });
|
||||||
|
sendResponse({
|
||||||
|
error: outcome.error,
|
||||||
|
retryable: outcome.retryable,
|
||||||
|
stage: TX_STAGE_BROADCAST,
|
||||||
});
|
});
|
||||||
sendResponse({ error: errMsg });
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return true;
|
return true;
|
||||||
@@ -761,21 +812,20 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
if (msg.type === "AUTISTMASK_SIGN_RESPONSE") {
|
if (msg.type === "AUTISTMASK_SIGN_RESPONSE") {
|
||||||
const approval = pendingApprovals[msg.id];
|
const approval = pendingApprovals[msg.id];
|
||||||
if (!approval) return false;
|
if (!approval) return false;
|
||||||
delete pendingApprovals[msg.id];
|
|
||||||
resetPopupUrl();
|
|
||||||
|
|
||||||
if (!msg.approved) {
|
if (!msg.approved) {
|
||||||
|
finishApproval(msg.id);
|
||||||
approval.resolve({
|
approval.resolve({
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The popup signs; it reports back here when it could not. Fail the
|
// The popup signs; it reports back here when it could not. Keep the
|
||||||
// request the same way this handler used to when it did the signing.
|
// approval so the user can correct the problem and try again with the
|
||||||
|
// message they already saw.
|
||||||
if (msg.error) {
|
if (msg.error) {
|
||||||
approval.resolve({ error: { message: msg.error } });
|
sendResponse({ error: msg.error, retryable: true });
|
||||||
sendResponse({ error: msg.error });
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,14 +838,17 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
// address.
|
// address.
|
||||||
const signature = msg.signature;
|
const signature = msg.signature;
|
||||||
verifySignature(approval.signParams, signature, activeAddress);
|
verifySignature(approval.signParams, signature, activeAddress);
|
||||||
|
finishApproval(msg.id);
|
||||||
approval.resolve({ signature });
|
approval.resolve({ signature });
|
||||||
sendResponse({ signature });
|
sendResponse({ signature });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errMsg = e.shortMessage || e.message;
|
const errMsg = e.shortMessage || e.message;
|
||||||
approval.resolve({
|
const retryable = failureIsRetryable(e);
|
||||||
error: { message: errMsg },
|
if (!retryable) {
|
||||||
});
|
finishApproval(msg.id);
|
||||||
sendResponse({ error: errMsg });
|
approval.resolve({ error: { message: errMsg } });
|
||||||
|
}
|
||||||
|
sendResponse({ error: errMsg, retryable });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
|||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
|
const { describeSigningFailure } = require("../../shared/approvalVerify");
|
||||||
const txStatus = require("./txStatus");
|
const txStatus = require("./txStatus");
|
||||||
const uniswap = require("../../shared/uniswap");
|
const uniswap = require("../../shared/uniswap");
|
||||||
const runtime =
|
const runtime =
|
||||||
@@ -546,10 +547,20 @@ function init(ctx) {
|
|||||||
runtime.sendMessage(payload, (response) => {
|
runtime.sendMessage(payload, (response) => {
|
||||||
if (response && response.txHash) {
|
if (response && response.txHash) {
|
||||||
txStatus.showWait(pendingTxDetails, response.txHash);
|
txStatus.showWait(pendingTxDetails, response.txHash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A retryable failure leaves the approval pending in the
|
||||||
|
// background, so stay on this screen with a live button rather
|
||||||
|
// than sending the user to a dead end.
|
||||||
|
const outcome = describeSigningFailure(
|
||||||
|
response,
|
||||||
|
"The transaction could not be sent.",
|
||||||
|
);
|
||||||
|
if (outcome.retryable) {
|
||||||
|
showError("approve-tx-error", outcome.message);
|
||||||
|
setTxButtonBusy(false);
|
||||||
} else {
|
} else {
|
||||||
const msg =
|
txStatus.showError(pendingTxDetails, null, outcome.message);
|
||||||
(response && response.error) || "Transaction failed.";
|
|
||||||
txStatus.showError(pendingTxDetails, null, msg);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -644,11 +655,18 @@ function init(ctx) {
|
|||||||
runtime.sendMessage(payload, (response) => {
|
runtime.sendMessage(payload, (response) => {
|
||||||
if (response && response.signature) {
|
if (response && response.signature) {
|
||||||
window.close();
|
window.close();
|
||||||
} else {
|
return;
|
||||||
const msg = (response && response.error) || "Signing failed.";
|
|
||||||
showError("approve-sign-error", msg);
|
|
||||||
setSignButtonBusy(false);
|
|
||||||
}
|
}
|
||||||
|
// The button comes back only when the approval is still pending in
|
||||||
|
// the background; otherwise it stays disabled and the message says
|
||||||
|
// why, because a control that cannot succeed must not look like it
|
||||||
|
// can.
|
||||||
|
const outcome = describeSigningFailure(
|
||||||
|
response,
|
||||||
|
"The message could not be signed.",
|
||||||
|
);
|
||||||
|
showError("approve-sign-error", outcome.message);
|
||||||
|
if (outcome.retryable) setSignButtonBusy(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,17 +7,139 @@
|
|||||||
// the signer from the artifact and checks it against the approval it is
|
// the signer from the artifact and checks it against the approval it is
|
||||||
// holding before acting on it. All recovery is delegated to ethers.
|
// holding before acting on it. All recovery is delegated to ethers.
|
||||||
//
|
//
|
||||||
|
// The check is an allowlist, in both directions, because a denylist cannot be
|
||||||
|
// correct against a transaction format that keeps gaining fields:
|
||||||
|
//
|
||||||
|
// - only transaction types 0, 1 and 2 are accepted. Every later EIP-2718 type
|
||||||
|
// adds a field with consequences of its own — EIP-7702's authorizationList
|
||||||
|
// rewrites the code at the signer's own account, EIP-4844's blob
|
||||||
|
// commitments carry a separate fee — and a check that enumerates the fields
|
||||||
|
// it refuses admits every one of them by default.
|
||||||
|
// - after the per-field comparisons, the artifact is rebuilt from those
|
||||||
|
// checked fields and nothing else, and the two are compared byte for byte.
|
||||||
|
// Anything the artifact carries that this module does not name is absent
|
||||||
|
// from the rebuild and changes the bytes, so the final assertion is that
|
||||||
|
// the artifact *is* the approved transaction, not merely that it is not one
|
||||||
|
// of the tampered shapes that were thought of.
|
||||||
|
//
|
||||||
|
// Every consequential field is compared, and a mismatch is a refusal to act,
|
||||||
|
// never a warning: what the user approved is what gets broadcast, or nothing
|
||||||
|
// does.
|
||||||
|
//
|
||||||
|
// Fields the approval does not carry are not treated as zero. The popup
|
||||||
|
// populates nonce, gas limit, fee and chain id through populateTransaction()
|
||||||
|
// when the requesting page did not fix them, so there is no approved value to
|
||||||
|
// compare against; treating absent as zero would refuse every legitimate
|
||||||
|
// transaction. Those fields are instead held to the absolute ceilings below,
|
||||||
|
// and the chain id is always checked against the selected network rather than
|
||||||
|
// against the approval alone, which is what makes a cross-chain replay
|
||||||
|
// impossible.
|
||||||
|
//
|
||||||
// Every failure message is a full sentence, because these strings are shown to
|
// Every failure message is a full sentence, because these strings are shown to
|
||||||
// the user and returned to the dApp.
|
// the user and returned to the dApp.
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Transaction,
|
Transaction,
|
||||||
|
accessListify,
|
||||||
getAddress,
|
getAddress,
|
||||||
getBytes,
|
getBytes,
|
||||||
verifyMessage,
|
verifyMessage,
|
||||||
verifyTypedData,
|
verifyTypedData,
|
||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
|
|
||||||
|
// The only transaction types this wallet signs: legacy, EIP-2930 and
|
||||||
|
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
|
||||||
|
// be an artifact of an approval this wallet raised.
|
||||||
|
const ALLOWED_TX_TYPES = [0, 1, 2];
|
||||||
|
|
||||||
|
// The serialized fields of each allowed type, which is also the complete set
|
||||||
|
// of fields the checks below compare or bound. The artifact is rebuilt from
|
||||||
|
// exactly these at the end of verification and compared byte for byte, so a
|
||||||
|
// field outside this table cannot ride along unexamined.
|
||||||
|
const SERIALIZED_FIELDS = {
|
||||||
|
0: ["chainId", "nonce", "gasPrice", "gasLimit", "to", "value", "data"],
|
||||||
|
1: [
|
||||||
|
"chainId",
|
||||||
|
"nonce",
|
||||||
|
"gasPrice",
|
||||||
|
"gasLimit",
|
||||||
|
"to",
|
||||||
|
"value",
|
||||||
|
"data",
|
||||||
|
"accessList",
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
"chainId",
|
||||||
|
"nonce",
|
||||||
|
"maxPriorityFeePerGas",
|
||||||
|
"maxFeePerGas",
|
||||||
|
"gasLimit",
|
||||||
|
"to",
|
||||||
|
"value",
|
||||||
|
"data",
|
||||||
|
"accessList",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fields no allowed type may carry. The type allowlist already excludes every
|
||||||
|
// type that defines them, and the structural check at the end of verification
|
||||||
|
// would catch them anyway; they are named here so that an artifact carrying
|
||||||
|
// one is refused with a message that says what it was.
|
||||||
|
const FORBIDDEN_FIELDS = [
|
||||||
|
{
|
||||||
|
key: "authorizationList",
|
||||||
|
message:
|
||||||
|
"The signed transaction would hand the signing account over to another contract, which was not approved.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "blobVersionedHashes",
|
||||||
|
message:
|
||||||
|
"The signed transaction carries blob commitments, which were not approved.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "blobs",
|
||||||
|
message:
|
||||||
|
"The signed transaction carries blobs, which were not approved.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "maxFeePerBlobGas",
|
||||||
|
message:
|
||||||
|
"The signed transaction carries a blob gas fee, which was not approved.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Above the block gas limit of every supported network (see networks.js), so
|
||||||
|
// no transaction that could ever be included is refused by it.
|
||||||
|
const MAX_GAS_LIMIT = 100000000n;
|
||||||
|
|
||||||
|
// 100,000 gwei per gas: orders of magnitude above the highest fee either
|
||||||
|
// supported network has produced, and low enough to catch a fee that would
|
||||||
|
// hand the validator the balance.
|
||||||
|
const MAX_FEE_PER_GAS = 100000000000000n;
|
||||||
|
|
||||||
|
// A refusal to act on an artifact: it is not the thing that was approved, so
|
||||||
|
// the approval it was offered against is spent and must not be retried. Every
|
||||||
|
// throw in this module is one of these; the background distinguishes them from
|
||||||
|
// transient failures (a busy node, a failed broadcast), which leave the
|
||||||
|
// approval standing so the user can try again.
|
||||||
|
class ApprovalMismatchError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApprovalMismatchError";
|
||||||
|
this.approvalMismatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refuse(message) {
|
||||||
|
return new ApprovalMismatchError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether a signing failure leaves the approval usable. Anything that is not a
|
||||||
|
// mismatch is the user's to correct and retry.
|
||||||
|
function failureIsRetryable(err) {
|
||||||
|
return !(err && err.approvalMismatch === true);
|
||||||
|
}
|
||||||
|
|
||||||
// Case-insensitive address comparison that tolerates absent values on either
|
// Case-insensitive address comparison that tolerates absent values on either
|
||||||
// side. Two absent addresses compare equal (contract creation has no `to`).
|
// side. Two absent addresses compare equal (contract creation has no `to`).
|
||||||
function sameAddress(a, b) {
|
function sameAddress(a, b) {
|
||||||
@@ -31,11 +153,64 @@ function sameAddress(a, b) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the approval fixed a value for a field at all.
|
||||||
|
function present(v) {
|
||||||
|
return v !== null && v !== undefined && v !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether a field carries anything at all. An empty array is nothing: ethers
|
||||||
|
// reports an absent access list on a type 2 transaction as `[]`.
|
||||||
|
function carriesValue(v) {
|
||||||
|
if (!present(v)) return false;
|
||||||
|
if (Array.isArray(v)) return v.length > 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize a quantity that must be present, refusing anything that is not a
|
||||||
|
// number: an approval carrying junk in a fee field cannot be compared, and an
|
||||||
|
// uncomparable field is a refusal rather than a pass.
|
||||||
|
function normalizeQuantity(v, label) {
|
||||||
|
try {
|
||||||
|
return BigInt(v);
|
||||||
|
} catch {
|
||||||
|
throw refuse(
|
||||||
|
"The approved " +
|
||||||
|
label +
|
||||||
|
" is not a number, so it cannot be" +
|
||||||
|
" compared with the signed transaction.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize a transaction value (hex string, decimal string, number or
|
// Normalize a transaction value (hex string, decimal string, number or
|
||||||
// bigint) to a bigint. An absent value is zero, matching ethers.
|
// bigint) to a bigint. An absent value is zero, matching ethers. The value is
|
||||||
|
// page-controlled, so it goes through the same refusal as every other
|
||||||
|
// quantity rather than throwing a raw BigInt conversion error.
|
||||||
function normalizeValue(v) {
|
function normalizeValue(v) {
|
||||||
if (v === null || v === undefined || v === "") return 0n;
|
if (!present(v)) return 0n;
|
||||||
return BigInt(v);
|
return normalizeQuantity(v, "value");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize an access list to a comparable string. An absent or empty list is
|
||||||
|
// the empty string, so absent and `[]` are the same thing.
|
||||||
|
function normalizeAccessList(v) {
|
||||||
|
if (!carriesValue(v)) return "";
|
||||||
|
let list;
|
||||||
|
try {
|
||||||
|
list = accessListify(v);
|
||||||
|
} catch {
|
||||||
|
throw refuse(
|
||||||
|
"The approved access list is not a valid access list, so it cannot be compared with the signed transaction.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
.map(
|
||||||
|
(entry) =>
|
||||||
|
String(entry.address).toLowerCase() +
|
||||||
|
":" +
|
||||||
|
entry.storageKeys.map((k) => String(k).toLowerCase()).join(","),
|
||||||
|
)
|
||||||
|
.join(";");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize call data to a lowercase hex string. Absent data is "0x".
|
// Normalize call data to a lowercase hex string. Absent data is "0x".
|
||||||
@@ -44,44 +219,198 @@ function normalizeData(v) {
|
|||||||
return String(v).toLowerCase();
|
return String(v).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quantity fields the requesting page may fix in the approval. Each is
|
||||||
|
// compared exactly when the approval carries it, and left to the ceilings
|
||||||
|
// above when it does not.
|
||||||
|
const APPROVED_QUANTITIES = [
|
||||||
|
{
|
||||||
|
key: "nonce",
|
||||||
|
label: "nonce",
|
||||||
|
message: "The signed transaction does not carry the approved nonce.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gasLimit",
|
||||||
|
label: "gas limit",
|
||||||
|
message:
|
||||||
|
"The signed transaction does not carry the approved gas limit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gasPrice",
|
||||||
|
label: "gas price",
|
||||||
|
message:
|
||||||
|
"The signed transaction does not carry the approved gas price.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "maxFeePerGas",
|
||||||
|
label: "maximum fee per gas",
|
||||||
|
message:
|
||||||
|
"The signed transaction does not carry the approved maximum fee per gas.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "maxPriorityFeePerGas",
|
||||||
|
label: "maximum priority fee per gas",
|
||||||
|
message:
|
||||||
|
"The signed transaction does not carry the approved maximum priority fee per gas.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Refuse a field only a transaction type this wallet does not sign can carry.
|
||||||
|
// The type allowlist keeps these unreachable in production, which is exactly
|
||||||
|
// what they are for; it also means nothing else exercises them, so this is
|
||||||
|
// exported and tested on its own rather than left to be believed.
|
||||||
|
function assertNoForbiddenFields(parsed) {
|
||||||
|
for (const field of FORBIDDEN_FIELDS) {
|
||||||
|
if (carriesValue(parsed[field.key])) throw refuse(field.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing structural check. Rebuild the transaction from the fields the
|
||||||
|
// comparisons cover, and nothing else, then compare the unsigned bytes. Every
|
||||||
|
// field carried by the artifact but absent from the rebuild changes the
|
||||||
|
// serialization, so this refuses anything this module does not account for —
|
||||||
|
// including a field a future ethers learns to parse onto an allowed type —
|
||||||
|
// instead of waving it through by not naming it. Also exported for its own
|
||||||
|
// test: nothing reachable today can make the bytes differ.
|
||||||
|
function assertNothingUnchecked(parsed) {
|
||||||
|
let rebuilt;
|
||||||
|
try {
|
||||||
|
const fields = { type: parsed.type };
|
||||||
|
for (const key of SERIALIZED_FIELDS[parsed.type]) {
|
||||||
|
fields[key] = parsed[key];
|
||||||
|
}
|
||||||
|
rebuilt = Transaction.from(fields);
|
||||||
|
} catch {
|
||||||
|
throw refuse(
|
||||||
|
"The signed transaction could not be rebuilt from the fields that were checked, so it cannot be shown to be the approved transaction.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rebuilt.unsignedSerialized !== parsed.unsignedSerialized) {
|
||||||
|
throw refuse(
|
||||||
|
"The signed transaction carries data beyond the fields that were checked against the approval.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Assert that a raw signed transaction is the transaction the user approved,
|
// Assert that a raw signed transaction is the transaction the user approved,
|
||||||
// signed by the address the approval was raised for. Returns the parsed
|
// signed by the address the approval was raised for, on the network that is
|
||||||
// ethers Transaction on success, throws otherwise.
|
// selected. Returns the parsed ethers Transaction on success, throws
|
||||||
function verifySignedTx(rawSignedTx, txParams, expectedFrom) {
|
// otherwise.
|
||||||
|
function verifySignedTx(rawSignedTx, txParams, expectedFrom, selectedChainId) {
|
||||||
if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) {
|
if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) {
|
||||||
throw new Error("The signed transaction is missing or malformed.");
|
throw refuse("The signed transaction is missing or malformed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = Transaction.from(rawSignedTx);
|
parsed = Transaction.from(rawSignedTx);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("The signed transaction could not be decoded.");
|
throw refuse("The signed transaction could not be decoded.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.from) {
|
if (!parsed.from) {
|
||||||
throw new Error("The signed transaction carries no valid signature.");
|
throw refuse("The signed transaction carries no valid signature.");
|
||||||
}
|
}
|
||||||
if (!sameAddress(parsed.from, expectedFrom)) {
|
if (!sameAddress(parsed.from, expectedFrom)) {
|
||||||
throw new Error(
|
throw refuse(
|
||||||
"The signed transaction was signed by a different address than the one that was approved.",
|
"The signed transaction was signed by a different address than the one that was approved.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before any field is looked at: the type decides which fields exist at
|
||||||
|
// all, so an unrecognised type is refused outright rather than compared
|
||||||
|
// field by field against an approval that cannot describe it.
|
||||||
|
if (!ALLOWED_TX_TYPES.includes(parsed.type)) {
|
||||||
|
throw refuse(
|
||||||
|
"The signed transaction is of a type this wallet does not sign, so what it would do beyond the approved transfer cannot be checked.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertNoForbiddenFields(parsed);
|
||||||
|
|
||||||
|
// The selected network, not the artifact, is the authority on which chain
|
||||||
|
// this may be broadcast to; without it nothing can be verified.
|
||||||
|
if (!present(selectedChainId)) {
|
||||||
|
throw refuse(
|
||||||
|
"The selected network is unknown, so the signed transaction cannot be checked against it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parsed.chainId !== normalizeQuantity(selectedChainId, "network")) {
|
||||||
|
throw refuse(
|
||||||
|
"The signed transaction is for a different network than the one that is selected.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
present(txParams.chainId) &&
|
||||||
|
parsed.chainId !== normalizeQuantity(txParams.chainId, "network")
|
||||||
|
) {
|
||||||
|
throw refuse(
|
||||||
|
"The signed transaction is for a different network than the one that was approved.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!sameAddress(parsed.to, txParams.to)) {
|
if (!sameAddress(parsed.to, txParams.to)) {
|
||||||
throw new Error(
|
throw refuse(
|
||||||
"The signed transaction does not go to the approved recipient.",
|
"The signed transaction does not go to the approved recipient.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) {
|
if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) {
|
||||||
throw new Error(
|
throw refuse(
|
||||||
"The signed transaction does not carry the approved value.",
|
"The signed transaction does not carry the approved value.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (normalizeData(parsed.data) !== normalizeData(txParams.data)) {
|
if (normalizeData(parsed.data) !== normalizeData(txParams.data)) {
|
||||||
throw new Error(
|
throw refuse(
|
||||||
"The signed transaction does not carry the approved call data.",
|
"The signed transaction does not carry the approved call data.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
normalizeAccessList(parsed.accessList) !==
|
||||||
|
normalizeAccessList(txParams.accessList)
|
||||||
|
) {
|
||||||
|
throw refuse(
|
||||||
|
"The signed transaction does not carry the approved access list.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An approval that fixed EIP-1559 fees must not be signed as a legacy
|
||||||
|
// transaction, and vice versa: the fee the user agreed to is only
|
||||||
|
// meaningful under the mechanism it was quoted in.
|
||||||
|
const approvedEip1559 =
|
||||||
|
present(txParams.maxFeePerGas) ||
|
||||||
|
present(txParams.maxPriorityFeePerGas);
|
||||||
|
const approvedLegacy = present(txParams.gasPrice);
|
||||||
|
const signedEip1559 = parsed.type === 2;
|
||||||
|
if (
|
||||||
|
(approvedEip1559 && !signedEip1559) ||
|
||||||
|
(approvedLegacy && signedEip1559)
|
||||||
|
) {
|
||||||
|
throw refuse(
|
||||||
|
"The signed transaction does not use the approved fee mechanism.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of APPROVED_QUANTITIES) {
|
||||||
|
if (!present(txParams[field.key])) continue;
|
||||||
|
const approved = normalizeQuantity(txParams[field.key], field.label);
|
||||||
|
if (normalizeQuantity(parsed[field.key], field.label) !== approved) {
|
||||||
|
throw refuse(field.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.gasLimit > MAX_GAS_LIMIT) {
|
||||||
|
throw refuse(
|
||||||
|
"The signed transaction sets a gas limit no network this wallet supports can accept.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const key of ["gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) {
|
||||||
|
const fee = parsed[key];
|
||||||
|
if (fee !== null && fee !== undefined && fee > MAX_FEE_PER_GAS) {
|
||||||
|
throw refuse(
|
||||||
|
"The signed transaction sets a fee per gas far above any plausible value.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNothingUnchecked(parsed);
|
||||||
|
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
@@ -91,7 +420,7 @@ function verifySignedTx(rawSignedTx, txParams, expectedFrom) {
|
|||||||
// address on success, throws otherwise.
|
// address on success, throws otherwise.
|
||||||
function verifySignature(signParams, signature, expectedFrom) {
|
function verifySignature(signParams, signature, expectedFrom) {
|
||||||
if (typeof signature !== "string" || !signature.startsWith("0x")) {
|
if (typeof signature !== "string" || !signature.startsWith("0x")) {
|
||||||
throw new Error("The signature is missing or malformed.");
|
throw refuse("The signature is missing or malformed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let recovered;
|
let recovered;
|
||||||
@@ -109,11 +438,11 @@ function verifySignature(signParams, signature, expectedFrom) {
|
|||||||
recovered = verifyTypedData(domain, types, message, signature);
|
recovered = verifyTypedData(domain, types, message, signature);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("The signature could not be verified.");
|
throw refuse("The signature could not be verified.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sameAddress(recovered, expectedFrom)) {
|
if (!sameAddress(recovered, expectedFrom)) {
|
||||||
throw new Error(
|
throw refuse(
|
||||||
"The signature was produced by a different address than the one that was approved.",
|
"The signature was produced by a different address than the one that was approved.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,4 +450,83 @@ function verifySignature(signParams, signature, expectedFrom) {
|
|||||||
return recovered;
|
return recovered;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { verifySignedTx, verifySignature, sameAddress };
|
// The stage a transaction approval failed at. Which stage it is decides
|
||||||
|
// whether the approval survives the failure.
|
||||||
|
const TX_STAGE_SIGN = "sign";
|
||||||
|
const TX_STAGE_VERIFY = "verify";
|
||||||
|
const TX_STAGE_BROADCAST = "broadcast";
|
||||||
|
|
||||||
|
function errorText(err) {
|
||||||
|
if (typeof err === "string" && err !== "") return err;
|
||||||
|
if (err && (err.shortMessage || err.message)) {
|
||||||
|
return err.shortMessage || err.message;
|
||||||
|
}
|
||||||
|
return "The transaction could not be sent.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the background does with a pending transaction approval after a failed
|
||||||
|
// attempt: what it tells the popup, and whether the approval is spent
|
||||||
|
// (resolved to the requesting page as an error and deleted) or left standing
|
||||||
|
// so the user can try the transaction they already saw again.
|
||||||
|
//
|
||||||
|
// - sign: the popup could not produce an artifact, almost always a wrong
|
||||||
|
// password. Nothing left the extension, so the approval stands.
|
||||||
|
// - verify: a mismatch is a refusal and spends the approval — an artifact
|
||||||
|
// that is not the approved transaction must never be retried against that
|
||||||
|
// approval. Anything else failed before the check ran and is retryable.
|
||||||
|
// - broadcast: always terminal. A broadcast that throws after the node
|
||||||
|
// accepted the transaction is routine (a timeout, a dropped response, a
|
||||||
|
// node answering "already known"), and the popup's retry does not
|
||||||
|
// re-broadcast these bytes — it re-runs populateTransaction() and signs
|
||||||
|
// again at a freshly fetched pending-tag nonce. Retrying would therefore
|
||||||
|
// put a second transaction on the chain for one approval.
|
||||||
|
function describeTxFailure(stage, err) {
|
||||||
|
const error = errorText(err);
|
||||||
|
const retryable =
|
||||||
|
stage === TX_STAGE_SIGN ||
|
||||||
|
(stage === TX_STAGE_VERIFY && failureIsRetryable(err));
|
||||||
|
return { error, retryable, spendApproval: !retryable };
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the popup shows and does after the background reports a failed signing
|
||||||
|
// attempt. A retryable failure leaves the approval pending in the background,
|
||||||
|
// so the button goes back to being usable; a refusal spent the approval, and
|
||||||
|
// the popup says so rather than offering a button that cannot succeed.
|
||||||
|
//
|
||||||
|
// A failed broadcast gets its own wording: the transaction may already be on
|
||||||
|
// the network, so telling the user to start again from the site is exactly the
|
||||||
|
// wrong instruction.
|
||||||
|
function describeSigningFailure(response, fallbackMessage) {
|
||||||
|
let message = (response && response.error) || fallbackMessage;
|
||||||
|
if (!/[.!?]$/.test(message)) message += ".";
|
||||||
|
const retryable = !!(response && response.retryable);
|
||||||
|
if (!retryable) {
|
||||||
|
message +=
|
||||||
|
response && response.stage === TX_STAGE_BROADCAST
|
||||||
|
? " The transaction may still have reached the network." +
|
||||||
|
" Check the account before sending it again."
|
||||||
|
: " This request can no longer be signed. Please start it" +
|
||||||
|
" again from the site.";
|
||||||
|
}
|
||||||
|
return { message, retryable };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
verifySignedTx,
|
||||||
|
verifySignature,
|
||||||
|
assertNoForbiddenFields,
|
||||||
|
assertNothingUnchecked,
|
||||||
|
sameAddress,
|
||||||
|
failureIsRetryable,
|
||||||
|
describeTxFailure,
|
||||||
|
describeSigningFailure,
|
||||||
|
ApprovalMismatchError,
|
||||||
|
ALLOWED_TX_TYPES,
|
||||||
|
SERIALIZED_FIELDS,
|
||||||
|
FORBIDDEN_FIELDS,
|
||||||
|
TX_STAGE_SIGN,
|
||||||
|
TX_STAGE_VERIFY,
|
||||||
|
TX_STAGE_BROADCAST,
|
||||||
|
MAX_GAS_LIMIT,
|
||||||
|
MAX_FEE_PER_GAS,
|
||||||
|
};
|
||||||
|
|||||||
@@ -84,8 +84,11 @@ async function loadState() {
|
|||||||
const result = await storageApi.get("autistmask");
|
const result = await storageApi.get("autistmask");
|
||||||
if (result.autistmask) {
|
if (result.autistmask) {
|
||||||
const saved = result.autistmask;
|
const saved = result.autistmask;
|
||||||
state.hasWallet = saved.hasWallet;
|
|
||||||
state.wallets = saved.wallets || [];
|
state.wallets = saved.wallets || [];
|
||||||
|
// Derived, never read from storage: a profile persisted with the flag
|
||||||
|
// out of step with the wallet list would otherwise stay broken on
|
||||||
|
// every load. Nothing depends on the two disagreeing.
|
||||||
|
state.hasWallet = state.wallets.length > 0;
|
||||||
state.trackedTokens = saved.trackedTokens || [];
|
state.trackedTokens = saved.trackedTokens || [];
|
||||||
state.networkId = saved.networkId || DEFAULT_STATE.networkId;
|
state.networkId = saved.networkId || DEFAULT_STATE.networkId;
|
||||||
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
|
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
|
||||||
|
|||||||
@@ -113,6 +113,85 @@ function parseTokenTransfer(tt, addrLower) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True when a parsed native entry moved no ETH. Contract-call entries have
|
||||||
|
// their amount fields blanked by parseTx, so they are never judged here.
|
||||||
|
function movedNoEther(tx) {
|
||||||
|
if (tx.direction === "contract") return false;
|
||||||
|
return BigInt(tx.rawAmount || "0") === BigInt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge parsed normal transactions with parsed ERC-20 token transfers into
|
||||||
|
// one row per distinct value movement. Pure: it reads only its arguments
|
||||||
|
// and returns a new list sorted newest block first.
|
||||||
|
//
|
||||||
|
// The merge key is the transaction hash for the native entry and
|
||||||
|
// hash + token contract for each token transfer, so:
|
||||||
|
//
|
||||||
|
// - A display-level contract call (a swap and friends, direction
|
||||||
|
// "contract") absorbs every token leg of its hash into the single
|
||||||
|
// native entry, because the legs are hops of one operation rather
|
||||||
|
// than separate movements the user made.
|
||||||
|
// - Otherwise each distinct token contract in the transaction keeps its
|
||||||
|
// own row, so a hash carrying several genuine transfers stays several
|
||||||
|
// rows.
|
||||||
|
// - The native entry of such a transaction is dropped when it moved no
|
||||||
|
// ETH and at least one token transfer shares its hash: that entry is
|
||||||
|
// the ERC-20 call itself, already represented by the token row. A
|
||||||
|
// native entry that moved ETH survives alongside the token rows, since
|
||||||
|
// the ETH and the tokens are two real movements, and a zero-value
|
||||||
|
// native transaction with no token transfer on its hash survives too.
|
||||||
|
function mergeTransactions(txs, tokenTransfers) {
|
||||||
|
const byKey = new Map();
|
||||||
|
|
||||||
|
// Entries are copied so consolidation never writes through to the
|
||||||
|
// caller's objects.
|
||||||
|
for (const tx of txs) {
|
||||||
|
byKey.set(tx.hash, { ...tx });
|
||||||
|
}
|
||||||
|
|
||||||
|
const absorbedHashes = new Set();
|
||||||
|
|
||||||
|
for (const parsed of tokenTransfers) {
|
||||||
|
const existing = byKey.get(parsed.hash);
|
||||||
|
if (existing && existing.direction === "contract") {
|
||||||
|
// For contract calls (swaps), consolidate into the original
|
||||||
|
// tx entry. Prefer the "received" transfer (swap output)
|
||||||
|
// for the display amount. If no received transfer exists,
|
||||||
|
// fall back to the first "sent" transfer (swap input).
|
||||||
|
const isReceived = parsed.direction === "received";
|
||||||
|
const needsAmount = !existing.exactValue;
|
||||||
|
if (isReceived || needsAmount) {
|
||||||
|
existing.value = parsed.value;
|
||||||
|
existing.exactValue = parsed.exactValue;
|
||||||
|
existing.rawAmount = parsed.rawAmount;
|
||||||
|
existing.rawUnit = parsed.rawUnit;
|
||||||
|
existing.symbol = parsed.symbol;
|
||||||
|
existing.contractAddress = parsed.contractAddress;
|
||||||
|
existing.holders = parsed.holders;
|
||||||
|
}
|
||||||
|
// Keep the original tx's from/to (the user's address and the
|
||||||
|
// contract they called), not the token transfer's from/to
|
||||||
|
// which may be a router or Permit2 contract.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existing && movedNoEther(existing)) {
|
||||||
|
absorbedHashes.add(parsed.hash);
|
||||||
|
}
|
||||||
|
// Every other token transfer gets its own entry.
|
||||||
|
byKey.set(parsed.hash + ":" + (parsed.contractAddress || ""), {
|
||||||
|
...parsed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hash of absorbedHashes) {
|
||||||
|
byKey.delete(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = [...byKey.values()];
|
||||||
|
merged.sort((a, b) => b.blockNumber - a.blockNumber);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||||
log.debugf("fetchRecentTransactions", address);
|
log.debugf("fetchRecentTransactions", address);
|
||||||
const addrLower = address.toLowerCase();
|
const addrLower = address.toLowerCase();
|
||||||
@@ -145,53 +224,11 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
|||||||
const txJson = txResp.ok ? await txResp.json() : {};
|
const txJson = txResp.ok ? await txResp.json() : {};
|
||||||
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
||||||
|
|
||||||
const txsByHash = new Map();
|
const txs = mergeTransactions(
|
||||||
|
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
|
||||||
|
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
|
||||||
|
);
|
||||||
|
|
||||||
for (const tx of txJson.items || []) {
|
|
||||||
txsByHash.set(tx.hash, parseTx(tx, addrLower));
|
|
||||||
}
|
|
||||||
|
|
||||||
// When a token transfer shares a hash with a normal tx, the normal tx
|
|
||||||
// is the contract call (0 ETH) and the token transfer has the real
|
|
||||||
// amount and symbol. For contract calls (swaps), a single transaction
|
|
||||||
// can produce multiple token transfers (input, intermediates, output).
|
|
||||||
// We consolidate these into the original tx entry using the token
|
|
||||||
// transfer where the user *receives* tokens (the swap output), so
|
|
||||||
// the transaction list shows the final result rather than confusing
|
|
||||||
// intermediate hops. We preserve the original tx's from/to so the
|
|
||||||
// user sees their own address, not a router or Permit2 contract.
|
|
||||||
for (const tt of ttJson.items || []) {
|
|
||||||
const parsed = parseTokenTransfer(tt, addrLower);
|
|
||||||
const existing = txsByHash.get(parsed.hash);
|
|
||||||
if (existing && existing.direction === "contract") {
|
|
||||||
// For contract calls (swaps), consolidate into the original
|
|
||||||
// tx entry. Prefer the "received" transfer (swap output)
|
|
||||||
// for the display amount. If no received transfer exists,
|
|
||||||
// fall back to the first "sent" transfer (swap input).
|
|
||||||
const isReceived = parsed.direction === "received";
|
|
||||||
const needsAmount = !existing.exactValue;
|
|
||||||
if (isReceived || needsAmount) {
|
|
||||||
existing.value = parsed.value;
|
|
||||||
existing.exactValue = parsed.exactValue;
|
|
||||||
existing.rawAmount = parsed.rawAmount;
|
|
||||||
existing.rawUnit = parsed.rawUnit;
|
|
||||||
existing.symbol = parsed.symbol;
|
|
||||||
existing.contractAddress = parsed.contractAddress;
|
|
||||||
existing.holders = parsed.holders;
|
|
||||||
}
|
|
||||||
// Keep the original tx's from/to (the user's address and the
|
|
||||||
// contract they called), not the token transfer's from/to
|
|
||||||
// which may be a router or Permit2 contract.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Non-contract token transfers get their own entries.
|
|
||||||
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
|
|
||||||
txsByHash.set(ttKey, parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
const txs = [...txsByHash.values()];
|
|
||||||
|
|
||||||
txs.sort((a, b) => b.blockNumber - a.blockNumber);
|
|
||||||
const result = txs.slice(0, count);
|
const result = txs.slice(0, count);
|
||||||
log.debugf("fetchRecentTransactions done, count:", result.length);
|
log.debugf("fetchRecentTransactions done, count:", result.length);
|
||||||
return result;
|
return result;
|
||||||
@@ -265,4 +302,8 @@ function filterTransactions(txs, filters = {}) {
|
|||||||
return { transactions: filtered, newFraudContracts: newFraud };
|
return { transactions: filtered, newFraudContracts: newFraud };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { fetchRecentTransactions, filterTransactions };
|
module.exports = {
|
||||||
|
fetchRecentTransactions,
|
||||||
|
filterTransactions,
|
||||||
|
mergeTransactions,
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,7 +2,20 @@ const { Network, Transaction, Wallet } = require("ethers");
|
|||||||
const {
|
const {
|
||||||
verifySignedTx,
|
verifySignedTx,
|
||||||
verifySignature,
|
verifySignature,
|
||||||
|
assertNoForbiddenFields,
|
||||||
|
assertNothingUnchecked,
|
||||||
sameAddress,
|
sameAddress,
|
||||||
|
failureIsRetryable,
|
||||||
|
describeTxFailure,
|
||||||
|
describeSigningFailure,
|
||||||
|
ALLOWED_TX_TYPES,
|
||||||
|
SERIALIZED_FIELDS,
|
||||||
|
FORBIDDEN_FIELDS,
|
||||||
|
TX_STAGE_SIGN,
|
||||||
|
TX_STAGE_VERIFY,
|
||||||
|
TX_STAGE_BROADCAST,
|
||||||
|
MAX_GAS_LIMIT,
|
||||||
|
MAX_FEE_PER_GAS,
|
||||||
} = require("../src/shared/approvalVerify");
|
} = require("../src/shared/approvalVerify");
|
||||||
const { getSignerForAddress } = require("../src/shared/wallet");
|
const { getSignerForAddress } = require("../src/shared/wallet");
|
||||||
|
|
||||||
@@ -18,6 +31,10 @@ const other = new Wallet(OTHER_KEY);
|
|||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||||
|
|
||||||
|
// The chain id of the selected network, as networks.js carries it.
|
||||||
|
const SELECTED = "0x1";
|
||||||
|
const SEPOLIA = "0xaa36a7";
|
||||||
|
|
||||||
// Approved parameters as a dApp would supply them over eth_sendTransaction.
|
// Approved parameters as a dApp would supply them over eth_sendTransaction.
|
||||||
const TX_PARAMS = {
|
const TX_PARAMS = {
|
||||||
from: signer.address,
|
from: signer.address,
|
||||||
@@ -27,25 +44,38 @@ const TX_PARAMS = {
|
|||||||
gas: "0x5208",
|
gas: "0x5208",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The values populateTransaction() fills in when the dApp fixed none of them.
|
||||||
|
const POPULATED = {
|
||||||
|
chainId: 1,
|
||||||
|
nonce: 7,
|
||||||
|
gasLimit: 100000n,
|
||||||
|
maxFeePerGas: 2000000000n,
|
||||||
|
maxPriorityFeePerGas: 1000000000n,
|
||||||
|
type: 2,
|
||||||
|
};
|
||||||
|
|
||||||
// Build a signable transaction from approved params. The popup does the same
|
// Build a signable transaction from approved params. The popup does the same
|
||||||
// thing through populateTransaction(); here the fields are fixed so the test
|
// thing through populateTransaction(); here the fields are fixed so the test
|
||||||
// needs no provider.
|
// needs no provider. `overrides` stands in for what a tampered or misbuilt
|
||||||
function txFor(params) {
|
// popup would put on the wire.
|
||||||
|
function txFor(params, overrides) {
|
||||||
return {
|
return {
|
||||||
chainId: 1,
|
...POPULATED,
|
||||||
nonce: 7,
|
|
||||||
gasLimit: 100000n,
|
|
||||||
maxFeePerGas: 2000000000n,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
type: 2,
|
|
||||||
to: params.to,
|
to: params.to,
|
||||||
value: params.value === undefined ? 0n : BigInt(params.value),
|
value: params.value === undefined ? 0n : BigInt(params.value),
|
||||||
data: params.data || "0x",
|
data: params.data || "0x",
|
||||||
|
...(overrides || {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signedFor(params, withWallet) {
|
async function signedFor(params, withWallet, overrides) {
|
||||||
return (withWallet || signer).signTransaction(txFor(params));
|
return (withWallet || signer).signTransaction(txFor(params, overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the approved transaction with one field changed from what was
|
||||||
|
// populated, which is the shape of every tamper case below.
|
||||||
|
async function signedWith(overrides) {
|
||||||
|
return signedFor(TX_PARAMS, signer, overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("sameAddress", () => {
|
describe("sameAddress", () => {
|
||||||
@@ -71,7 +101,7 @@ describe("sameAddress", () => {
|
|||||||
describe("verifySignedTx", () => {
|
describe("verifySignedTx", () => {
|
||||||
test("accepts the approved transaction signed by the approved address", async () => {
|
test("accepts the approved transaction signed by the approved address", async () => {
|
||||||
const raw = await signedFor(TX_PARAMS);
|
const raw = await signedFor(TX_PARAMS);
|
||||||
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address);
|
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||||
expect(parsed.from).toBe(signer.address);
|
expect(parsed.from).toBe(signer.address);
|
||||||
expect(parsed.hash).toBe(Transaction.from(raw).hash);
|
expect(parsed.hash).toBe(Transaction.from(raw).hash);
|
||||||
});
|
});
|
||||||
@@ -79,14 +109,16 @@ describe("verifySignedTx", () => {
|
|||||||
test("accepts a contract creation with no recipient", async () => {
|
test("accepts a contract creation with no recipient", async () => {
|
||||||
const params = { to: undefined, value: "0x0", data: "0x600160005500" };
|
const params = { to: undefined, value: "0x0", data: "0x600160005500" };
|
||||||
const raw = await signedFor(params);
|
const raw = await signedFor(params);
|
||||||
expect(() => verifySignedTx(raw, params, signer.address)).not.toThrow();
|
expect(() =>
|
||||||
|
verifySignedTx(raw, params, signer.address, SELECTED),
|
||||||
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts an absent value as zero", async () => {
|
test("accepts an absent value as zero", async () => {
|
||||||
const approved = { to: RECIPIENT, data: "0x" };
|
const approved = { to: RECIPIENT, data: "0x" };
|
||||||
const raw = await signedFor(approved);
|
const raw = await signedFor(approved);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
verifySignedTx(raw, approved, signer.address),
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,7 +126,7 @@ describe("verifySignedTx", () => {
|
|||||||
const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
|
const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
|
||||||
const raw = await signedFor(approved);
|
const raw = await signedFor(approved);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
verifySignedTx(raw, approved, signer.address),
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,9 +135,9 @@ describe("verifySignedTx", () => {
|
|||||||
...TX_PARAMS,
|
...TX_PARAMS,
|
||||||
to: OTHER_RECIPIENT,
|
to: OTHER_RECIPIENT,
|
||||||
});
|
});
|
||||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
expect(() =>
|
||||||
/approved recipient/,
|
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||||
);
|
).toThrow(/approved recipient/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects an inflated value", async () => {
|
test("rejects an inflated value", async () => {
|
||||||
@@ -113,48 +145,48 @@ describe("verifySignedTx", () => {
|
|||||||
...TX_PARAMS,
|
...TX_PARAMS,
|
||||||
value: "0x4563918244f40000",
|
value: "0x4563918244f40000",
|
||||||
});
|
});
|
||||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
expect(() =>
|
||||||
/approved value/,
|
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||||
);
|
).toThrow(/approved value/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects substituted call data", async () => {
|
test("rejects substituted call data", async () => {
|
||||||
const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
|
const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
|
||||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
expect(() =>
|
||||||
/approved call data/,
|
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||||
);
|
).toThrow(/approved call data/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects a transaction signed by a different address", async () => {
|
test("rejects a transaction signed by a different address", async () => {
|
||||||
const raw = await signedFor(TX_PARAMS, other);
|
const raw = await signedFor(TX_PARAMS, other);
|
||||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
expect(() =>
|
||||||
/different address/,
|
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||||
);
|
).toThrow(/different address/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects an unsigned transaction", () => {
|
test("rejects an unsigned transaction", () => {
|
||||||
const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
|
const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
|
||||||
expect(() =>
|
expect(() =>
|
||||||
verifySignedTx(unsigned, TX_PARAMS, signer.address),
|
verifySignedTx(unsigned, TX_PARAMS, signer.address, SELECTED),
|
||||||
).toThrow(/no valid signature/);
|
).toThrow(/no valid signature/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects a missing or malformed payload", () => {
|
test("rejects a missing or malformed payload", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
verifySignedTx(undefined, TX_PARAMS, signer.address),
|
verifySignedTx(undefined, TX_PARAMS, signer.address, SELECTED),
|
||||||
).toThrow(/missing or malformed/);
|
).toThrow(/missing or malformed/);
|
||||||
expect(() => verifySignedTx("nope", TX_PARAMS, signer.address)).toThrow(
|
|
||||||
/missing or malformed/,
|
|
||||||
);
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address),
|
verifySignedTx("nope", TX_PARAMS, signer.address, SELECTED),
|
||||||
|
).toThrow(/missing or malformed/);
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address, SELECTED),
|
||||||
).toThrow(/could not be decoded/);
|
).toThrow(/could not be decoded/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("every rejection message is a full sentence", async () => {
|
test("every rejection message is a full sentence", async () => {
|
||||||
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||||
try {
|
try {
|
||||||
verifySignedTx(raw, TX_PARAMS, signer.address);
|
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||||
throw new Error("expected a rejection");
|
throw new Error("expected a rejection");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
||||||
@@ -162,6 +194,552 @@ describe("verifySignedTx", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// One case per consequential field: the field alone differs from what was
|
||||||
|
// approved, and that alone must refuse the signature.
|
||||||
|
describe("verifySignedTx field comparison", () => {
|
||||||
|
test("rejects a chain id that is not the selected network", async () => {
|
||||||
|
const raw = await signedWith({ chainId: 11155111 });
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||||
|
).toThrow(/different network than the one that is selected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a chain id that is not the approved one", async () => {
|
||||||
|
// Selected network and signed chain id agree; the dApp asked for a
|
||||||
|
// different chain, so the artifact is not what was approved.
|
||||||
|
const approved = { ...TX_PARAMS, chainId: SEPOLIA };
|
||||||
|
const raw = await signedWith({});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).toThrow(/different network than the one that was approved/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses when the selected network is unknown", async () => {
|
||||||
|
const raw = await signedWith({});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, TX_PARAMS, signer.address, undefined),
|
||||||
|
).toThrow(/selected network is unknown/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a substituted nonce", async () => {
|
||||||
|
const approved = { ...TX_PARAMS, nonce: 7 };
|
||||||
|
const raw = await signedWith({ nonce: 8 });
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).toThrow(/approved nonce/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a substituted gas limit", async () => {
|
||||||
|
const approved = { ...TX_PARAMS, gasLimit: "0x186a0" };
|
||||||
|
const raw = await signedWith({ gasLimit: 250000n });
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).toThrow(/approved gas limit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a substituted maximum fee per gas", async () => {
|
||||||
|
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
|
||||||
|
const raw = await signedWith({ maxFeePerGas: 900000000000n });
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).toThrow(/approved maximum fee per gas/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a substituted maximum priority fee per gas", async () => {
|
||||||
|
const approved = { ...TX_PARAMS, maxPriorityFeePerGas: "0x3b9aca00" };
|
||||||
|
const raw = await signedWith({ maxPriorityFeePerGas: 1500000000n });
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).toThrow(/approved maximum priority fee per gas/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a substituted legacy gas price", async () => {
|
||||||
|
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
|
||||||
|
const legacy = {
|
||||||
|
type: 0,
|
||||||
|
gasPrice: 9000000000n,
|
||||||
|
maxFeePerGas: null,
|
||||||
|
maxPriorityFeePerGas: null,
|
||||||
|
};
|
||||||
|
const raw = await signedWith(legacy);
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).toThrow(/approved gas price/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an approved legacy fee signed as an EIP-1559 fee", async () => {
|
||||||
|
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
|
||||||
|
const raw = await signedWith({});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).toThrow(/approved fee mechanism/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an approved EIP-1559 fee signed as a legacy fee", async () => {
|
||||||
|
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
|
||||||
|
const raw = await signedWith({
|
||||||
|
type: 0,
|
||||||
|
gasPrice: 2000000000n,
|
||||||
|
maxFeePerGas: null,
|
||||||
|
maxPriorityFeePerGas: null,
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).toThrow(/approved fee mechanism/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a gas limit above anything a supported network accepts", async () => {
|
||||||
|
const raw = await signedWith({ gasLimit: MAX_GAS_LIMIT + 1n });
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||||
|
).toThrow(/gas limit no network this wallet supports/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an absurd fee per gas the approval never fixed", async () => {
|
||||||
|
const raw = await signedWith({
|
||||||
|
maxFeePerGas: MAX_FEE_PER_GAS + 1n,
|
||||||
|
maxPriorityFeePerGas: MAX_FEE_PER_GAS + 1n,
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||||
|
).toThrow(/fee per gas far above any plausible value/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every field mismatch is a refusal, not a warning", async () => {
|
||||||
|
const raw = await signedWith({ nonce: 8 });
|
||||||
|
try {
|
||||||
|
verifySignedTx(
|
||||||
|
raw,
|
||||||
|
{ ...TX_PARAMS, nonce: 7 },
|
||||||
|
signer.address,
|
||||||
|
SELECTED,
|
||||||
|
);
|
||||||
|
throw new Error("expected a rejection");
|
||||||
|
} catch (e) {
|
||||||
|
expect(e.approvalMismatch).toBe(true);
|
||||||
|
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The transaction type decides which fields exist, so an artifact of a type
|
||||||
|
// this wallet does not sign carries consequences the approval cannot describe
|
||||||
|
// and none of the field comparisons can see. The approval used here is the
|
||||||
|
// ordinary dApp shape with no fee fields — the common case, since
|
||||||
|
// populateTransaction() fills them — which is exactly the case the
|
||||||
|
// fee-mechanism check cannot catch by accident.
|
||||||
|
describe("verifySignedTx transaction type", () => {
|
||||||
|
const BARE_APPROVAL = {
|
||||||
|
from: signer.address,
|
||||||
|
to: RECIPIENT,
|
||||||
|
value: "0x2386f26fc10000",
|
||||||
|
data: "0x",
|
||||||
|
};
|
||||||
|
|
||||||
|
// An EIP-7702 artifact that pays the approved amount to the approved
|
||||||
|
// recipient and, in the same transaction, installs the attacker's code at
|
||||||
|
// the signer's own account for good. Every field the approval screen shows
|
||||||
|
// matches; only the type and the authorization list do not.
|
||||||
|
test("refuses a type 4 artifact that delegates the signer's own account", async () => {
|
||||||
|
const authorization = await signer.authorize({
|
||||||
|
address: OTHER_RECIPIENT,
|
||||||
|
chainId: 1,
|
||||||
|
nonce: 8,
|
||||||
|
});
|
||||||
|
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||||
|
type: 4,
|
||||||
|
authorizationList: [authorization],
|
||||||
|
});
|
||||||
|
const parsed = Transaction.from(raw);
|
||||||
|
expect(parsed.type).toBe(4);
|
||||||
|
expect(parsed.authorizationList[0].address).toBe(OTHER_RECIPIENT);
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
|
||||||
|
).toThrow(/type this wallet does not sign/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a type 3 blob artifact", async () => {
|
||||||
|
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||||
|
type: 3,
|
||||||
|
maxFeePerBlobGas: 1000000000n,
|
||||||
|
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
|
||||||
|
});
|
||||||
|
expect(Transaction.from(raw).type).toBe(3);
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
|
||||||
|
).toThrow(/type this wallet does not sign/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses every type outside the allowlist, not just the known ones", async () => {
|
||||||
|
for (const type of [3, 4]) {
|
||||||
|
expect(ALLOWED_TX_TYPES).not.toContain(type);
|
||||||
|
}
|
||||||
|
expect(ALLOWED_TX_TYPES).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a type refusal is a refusal, not a warning", async () => {
|
||||||
|
const authorization = await signer.authorize({
|
||||||
|
address: OTHER_RECIPIENT,
|
||||||
|
chainId: 1,
|
||||||
|
nonce: 8,
|
||||||
|
});
|
||||||
|
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||||
|
type: 4,
|
||||||
|
authorizationList: [authorization],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED);
|
||||||
|
throw new Error("expected a rejection");
|
||||||
|
} catch (e) {
|
||||||
|
expect(e.approvalMismatch).toBe(true);
|
||||||
|
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a legacy type 0 transaction", async () => {
|
||||||
|
const approved = { ...BARE_APPROVAL, gasPrice: "0x77359400" };
|
||||||
|
const raw = await signedFor(approved, signer, {
|
||||||
|
type: 0,
|
||||||
|
gasPrice: 2000000000n,
|
||||||
|
maxFeePerGas: null,
|
||||||
|
maxPriorityFeePerGas: null,
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a type 1 transaction whose access list is the approved one", async () => {
|
||||||
|
const accessList = [{ address: OTHER_RECIPIENT, storageKeys: [] }];
|
||||||
|
const approved = {
|
||||||
|
...BARE_APPROVAL,
|
||||||
|
gasPrice: "0x77359400",
|
||||||
|
accessList,
|
||||||
|
};
|
||||||
|
const raw = await signedFor(approved, signer, {
|
||||||
|
type: 1,
|
||||||
|
gasPrice: 2000000000n,
|
||||||
|
maxFeePerGas: null,
|
||||||
|
maxPriorityFeePerGas: null,
|
||||||
|
accessList,
|
||||||
|
});
|
||||||
|
expect(Transaction.from(raw).type).toBe(1);
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses an access list the approval never carried", async () => {
|
||||||
|
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||||
|
accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }],
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
|
||||||
|
).toThrow(/approved access list/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats an absent access list and an empty one as the same thing", async () => {
|
||||||
|
const approved = { ...BARE_APPROVAL, accessList: [] };
|
||||||
|
const raw = await signedFor(BARE_APPROVAL, signer, {});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The allowlist is only exhaustive while it accounts for every field an
|
||||||
|
// artifact can carry. These tests are what makes that claim checkable rather
|
||||||
|
// than asserted.
|
||||||
|
describe("verifySignedTx exhaustiveness", () => {
|
||||||
|
// Every accessor ethers exposes on a parsed transaction, and where this
|
||||||
|
// module deals with it. If an ethers upgrade adds a transaction field,
|
||||||
|
// this fails and forces a decision about it instead of letting it default
|
||||||
|
// to unchecked.
|
||||||
|
test("every field ethers can parse is accounted for", () => {
|
||||||
|
const derived = [
|
||||||
|
// Recovered from the signature or computed from the payload, not
|
||||||
|
// independent content: covered by the signer check and by the
|
||||||
|
// fields below.
|
||||||
|
"from",
|
||||||
|
"fromPublicKey",
|
||||||
|
"hash",
|
||||||
|
"serialized",
|
||||||
|
"signature",
|
||||||
|
"type",
|
||||||
|
"typeName",
|
||||||
|
"unsignedHash",
|
||||||
|
"unsignedSerialized",
|
||||||
|
// Blob sidecar machinery, meaningful only alongside `blobs`,
|
||||||
|
// which is refused outright.
|
||||||
|
"kzg",
|
||||||
|
"blobWrapperVersion",
|
||||||
|
];
|
||||||
|
const accounted = new Set([
|
||||||
|
...derived,
|
||||||
|
...FORBIDDEN_FIELDS.map((f) => f.key),
|
||||||
|
...Object.values(SERIALIZED_FIELDS).flat(),
|
||||||
|
]);
|
||||||
|
const exposed = Object.getOwnPropertyNames(Transaction.prototype)
|
||||||
|
.filter((name) => {
|
||||||
|
const d = Object.getOwnPropertyDescriptor(
|
||||||
|
Transaction.prototype,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
return d && typeof d.get === "function";
|
||||||
|
})
|
||||||
|
.sort();
|
||||||
|
expect(exposed.filter((name) => !accounted.has(name))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The two layers behind the type allowlist. Nothing reachable through
|
||||||
|
// verifySignedTx can trip either of them while the allowlist holds — that
|
||||||
|
// is what they are for — so they are exercised directly rather than taken
|
||||||
|
// on trust.
|
||||||
|
test("a forbidden field is refused even on an allowed type", async () => {
|
||||||
|
const authorization = await signer.authorize({
|
||||||
|
address: OTHER_RECIPIENT,
|
||||||
|
chainId: 1,
|
||||||
|
nonce: 8,
|
||||||
|
});
|
||||||
|
const carriers = {
|
||||||
|
authorizationList: [authorization],
|
||||||
|
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
|
||||||
|
blobs: ["0x00"],
|
||||||
|
maxFeePerBlobGas: 1n,
|
||||||
|
};
|
||||||
|
for (const key of Object.keys(carriers)) {
|
||||||
|
expect(FORBIDDEN_FIELDS.map((f) => f.key)).toContain(key);
|
||||||
|
let thrown;
|
||||||
|
try {
|
||||||
|
assertNoForbiddenFields({ type: 2, [key]: carriers[key] });
|
||||||
|
throw new Error("expected a rejection");
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e;
|
||||||
|
}
|
||||||
|
expect(thrown.approvalMismatch).toBe(true);
|
||||||
|
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
|
||||||
|
}
|
||||||
|
expect(() => assertNoForbiddenFields({ type: 2 })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stands in for a future ethers that parses a field this module does not
|
||||||
|
// know about onto an allowed type: every field the module checks is
|
||||||
|
// identical, and the bytes are not.
|
||||||
|
test("an artifact carrying more than the checked fields is refused", async () => {
|
||||||
|
const parsed = Transaction.from(await signedWith({}));
|
||||||
|
const smuggled = { type: parsed.type };
|
||||||
|
for (const key of SERIALIZED_FIELDS[parsed.type]) {
|
||||||
|
smuggled[key] = parsed[key];
|
||||||
|
}
|
||||||
|
smuggled.unsignedSerialized = parsed.unsignedSerialized + "ff";
|
||||||
|
expect(() => assertNothingUnchecked(smuggled)).toThrow(
|
||||||
|
/beyond the fields that were checked/,
|
||||||
|
);
|
||||||
|
expect(() => assertNothingUnchecked(parsed)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The closing check rebuilds the artifact from the fields the module
|
||||||
|
// compared and compares the bytes, so an artifact carrying anything else
|
||||||
|
// is refused without the module having to name it. Assert the rebuild is
|
||||||
|
// faithful for every accepted shape, since a rebuild that dropped a
|
||||||
|
// legitimate field would refuse honest transactions.
|
||||||
|
test("an accepted artifact of each allowed type rebuilds byte for byte", async () => {
|
||||||
|
const shapes = [
|
||||||
|
{
|
||||||
|
approved: { ...TX_PARAMS, gasPrice: "0x77359400" },
|
||||||
|
overrides: {
|
||||||
|
type: 0,
|
||||||
|
gasPrice: 2000000000n,
|
||||||
|
maxFeePerGas: null,
|
||||||
|
maxPriorityFeePerGas: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
approved: {
|
||||||
|
...TX_PARAMS,
|
||||||
|
gasPrice: "0x77359400",
|
||||||
|
accessList: [
|
||||||
|
{
|
||||||
|
address: RECIPIENT,
|
||||||
|
storageKeys: ["0x" + "11".repeat(32)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
overrides: {
|
||||||
|
type: 1,
|
||||||
|
gasPrice: 2000000000n,
|
||||||
|
maxFeePerGas: null,
|
||||||
|
maxPriorityFeePerGas: null,
|
||||||
|
accessList: [
|
||||||
|
{
|
||||||
|
address: RECIPIENT,
|
||||||
|
storageKeys: ["0x" + "11".repeat(32)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ approved: TX_PARAMS, overrides: {} },
|
||||||
|
];
|
||||||
|
for (const shape of shapes) {
|
||||||
|
const raw = await signedFor(
|
||||||
|
shape.approved,
|
||||||
|
signer,
|
||||||
|
shape.overrides,
|
||||||
|
);
|
||||||
|
const parsed = verifySignedTx(
|
||||||
|
raw,
|
||||||
|
shape.approved,
|
||||||
|
signer.address,
|
||||||
|
SELECTED,
|
||||||
|
);
|
||||||
|
const fields = { type: parsed.type };
|
||||||
|
for (const key of SERIALIZED_FIELDS[parsed.type]) {
|
||||||
|
fields[key] = parsed[key];
|
||||||
|
}
|
||||||
|
expect(Transaction.from(fields).unsignedSerialized).toBe(
|
||||||
|
parsed.unsignedSerialized,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The approval and the artifact spell the same values differently. None of
|
||||||
|
// these differences is tampering, so none may refuse the signature.
|
||||||
|
describe("verifySignedTx normalization", () => {
|
||||||
|
test("accepts a decimal chain id against a hex selected network", async () => {
|
||||||
|
const raw = await signedWith({});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, TX_PARAMS, signer.address, 1),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, TX_PARAMS, signer.address, "1"),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts an approved chain id written in hex", async () => {
|
||||||
|
const raw = await signedWith({});
|
||||||
|
const approved = { ...TX_PARAMS, chainId: "0x1" };
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a hex nonce against a numeric one", async () => {
|
||||||
|
const raw = await signedWith({ nonce: 7 });
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(
|
||||||
|
raw,
|
||||||
|
{ ...TX_PARAMS, nonce: "0x7" },
|
||||||
|
signer.address,
|
||||||
|
SELECTED,
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a decimal gas limit against a hex one", async () => {
|
||||||
|
const raw = await signedWith({ gasLimit: 100000n });
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(
|
||||||
|
raw,
|
||||||
|
{ ...TX_PARAMS, gasLimit: "100000" },
|
||||||
|
signer.address,
|
||||||
|
SELECTED,
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts fee fields spelled as hex, decimal, number and bigint", async () => {
|
||||||
|
const raw = await signedWith({});
|
||||||
|
for (const maxFee of [
|
||||||
|
"0x77359400",
|
||||||
|
"2000000000",
|
||||||
|
2000000000,
|
||||||
|
2000000000n,
|
||||||
|
]) {
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(
|
||||||
|
raw,
|
||||||
|
{ ...TX_PARAMS, maxFeePerGas: maxFee },
|
||||||
|
signer.address,
|
||||||
|
SELECTED,
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts an approval that fixes no nonce, gas or fee at all", async () => {
|
||||||
|
const raw = await signedWith({});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts an approval whose recipient case differs", async () => {
|
||||||
|
const raw = await signedWith({});
|
||||||
|
const approved = { ...TX_PARAMS, to: RECIPIENT.toLowerCase() };
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts absent call data against 0x", async () => {
|
||||||
|
const approved = { to: RECIPIENT, value: "0x0" };
|
||||||
|
const raw = await signedFor({ ...approved, data: "0x" });
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses an approved quantity that is not a number", async () => {
|
||||||
|
const raw = await signedWith({});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(
|
||||||
|
raw,
|
||||||
|
{ ...TX_PARAMS, maxFeePerGas: "cheap" },
|
||||||
|
signer.address,
|
||||||
|
SELECTED,
|
||||||
|
),
|
||||||
|
).toThrow(/is not a number/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The value is page-controlled. A refusal is correct; a raw BigInt
|
||||||
|
// conversion error is not, because it is not a mismatch, so it would be
|
||||||
|
// reported retryable and leave the approval unspent behind a live button
|
||||||
|
// that can never succeed.
|
||||||
|
test("refuses an approved value that is not a number, as a mismatch", async () => {
|
||||||
|
const raw = await signedWith({});
|
||||||
|
for (const value of ["cheap", 1.5, "1e18", {}]) {
|
||||||
|
let thrown;
|
||||||
|
try {
|
||||||
|
verifySignedTx(
|
||||||
|
raw,
|
||||||
|
{ ...TX_PARAMS, value },
|
||||||
|
signer.address,
|
||||||
|
SELECTED,
|
||||||
|
);
|
||||||
|
throw new Error("expected a rejection");
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e;
|
||||||
|
}
|
||||||
|
expect(thrown.approvalMismatch).toBe(true);
|
||||||
|
expect(thrown.message).toMatch(/approved value is not a number/);
|
||||||
|
expect(failureIsRetryable(thrown)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses an approved access list that is not an access list", async () => {
|
||||||
|
const raw = await signedWith({});
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(
|
||||||
|
raw,
|
||||||
|
{ ...TX_PARAMS, accessList: ["nope"] },
|
||||||
|
signer.address,
|
||||||
|
SELECTED,
|
||||||
|
),
|
||||||
|
).toThrow(/not a valid access list/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const TYPED_DATA = JSON.stringify({
|
const TYPED_DATA = JSON.stringify({
|
||||||
domain: {
|
domain: {
|
||||||
name: "AutistMask Test",
|
name: "AutistMask Test",
|
||||||
@@ -281,6 +859,133 @@ describe("verifySignature", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// What happens after a signing attempt fails: the background keeps the
|
||||||
|
// approval for anything the user can correct, and the popup only offers the
|
||||||
|
// button again when it did.
|
||||||
|
describe("signing failure and retry", () => {
|
||||||
|
test("a failure that is not a mismatch leaves the approval retryable", () => {
|
||||||
|
expect(failureIsRetryable(new Error("The node is unreachable."))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(failureIsRetryable(undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a mismatch spends the approval", async () => {
|
||||||
|
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||||
|
try {
|
||||||
|
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||||
|
throw new Error("expected a rejection");
|
||||||
|
} catch (e) {
|
||||||
|
expect(failureIsRetryable(e)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a retryable failure keeps the button usable and says only what failed", () => {
|
||||||
|
const outcome = describeSigningFailure(
|
||||||
|
{ error: "The node rejected the transaction.", retryable: true },
|
||||||
|
"The transaction could not be sent.",
|
||||||
|
);
|
||||||
|
expect(outcome.retryable).toBe(true);
|
||||||
|
expect(outcome.message).toBe("The node rejected the transaction.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a refusal tells the user to start again from the site", () => {
|
||||||
|
const outcome = describeSigningFailure(
|
||||||
|
{
|
||||||
|
error: "The signed transaction does not go to the approved recipient.",
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
"The transaction could not be sent.",
|
||||||
|
);
|
||||||
|
expect(outcome.retryable).toBe(false);
|
||||||
|
expect(outcome.message).toMatch(/start it again from the site\.$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a response the background never sent is treated as a spent approval", () => {
|
||||||
|
const outcome = describeSigningFailure(
|
||||||
|
undefined,
|
||||||
|
"The transaction could not be sent.",
|
||||||
|
);
|
||||||
|
expect(outcome.retryable).toBe(false);
|
||||||
|
expect(outcome.message).toMatch(/^The transaction could not be sent\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every failure message is a full sentence", () => {
|
||||||
|
const outcome = describeSigningFailure(
|
||||||
|
{ error: "The node is on fire", retryable: true },
|
||||||
|
"The transaction could not be sent.",
|
||||||
|
);
|
||||||
|
expect(outcome.message).toMatch(/^[A-Z].*\.$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a popup that could not sign leaves the approval standing", () => {
|
||||||
|
const outcome = describeTxFailure(
|
||||||
|
TX_STAGE_SIGN,
|
||||||
|
"That password is incorrect. Please try again.",
|
||||||
|
);
|
||||||
|
expect(outcome.retryable).toBe(true);
|
||||||
|
expect(outcome.spendApproval).toBe(false);
|
||||||
|
expect(outcome.error).toMatch(/password is incorrect/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a mismatch found at verification spends the approval", async () => {
|
||||||
|
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||||
|
let outcome;
|
||||||
|
try {
|
||||||
|
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||||
|
} catch (e) {
|
||||||
|
outcome = describeTxFailure(TX_STAGE_VERIFY, e);
|
||||||
|
}
|
||||||
|
expect(outcome.retryable).toBe(false);
|
||||||
|
expect(outcome.spendApproval).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failure before the check ran is still retryable", () => {
|
||||||
|
const outcome = describeTxFailure(
|
||||||
|
TX_STAGE_VERIFY,
|
||||||
|
new Error("The wallet state could not be read."),
|
||||||
|
);
|
||||||
|
expect(outcome.retryable).toBe(true);
|
||||||
|
expect(outcome.spendApproval).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A broadcast that throws after the node took the transaction is routine:
|
||||||
|
// a timeout, a dropped response, a node answering "already known". The
|
||||||
|
// popup's retry does not re-broadcast the same bytes — it re-populates and
|
||||||
|
// re-signs at a freshly fetched nonce — so a retryable broadcast failure
|
||||||
|
// would put the approved transfer on the chain twice.
|
||||||
|
test("a failed broadcast is terminal, whatever the node said", () => {
|
||||||
|
for (const message of [
|
||||||
|
"already known",
|
||||||
|
"timeout of 30000ms exceeded",
|
||||||
|
"could not coalesce error",
|
||||||
|
"replacement transaction underpriced",
|
||||||
|
]) {
|
||||||
|
const outcome = describeTxFailure(
|
||||||
|
TX_STAGE_BROADCAST,
|
||||||
|
new Error(message),
|
||||||
|
);
|
||||||
|
expect(outcome.retryable).toBe(false);
|
||||||
|
expect(outcome.spendApproval).toBe(true);
|
||||||
|
expect(outcome.error).toBe(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed broadcast does not tell the user to send it again", () => {
|
||||||
|
const outcome = describeSigningFailure(
|
||||||
|
{
|
||||||
|
error: "The node did not answer.",
|
||||||
|
retryable: false,
|
||||||
|
stage: TX_STAGE_BROADCAST,
|
||||||
|
},
|
||||||
|
"The transaction could not be sent.",
|
||||||
|
);
|
||||||
|
expect(outcome.retryable).toBe(false);
|
||||||
|
expect(outcome.message).toMatch(/may still have reached the network/);
|
||||||
|
expect(outcome.message).not.toMatch(/start it again from the site/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// End-to-end over the messaging boundary, without a browser: run the exact
|
// End-to-end over the messaging boundary, without a browser: run the exact
|
||||||
// sequence the approval popup runs, then hand the artifact to the exact check
|
// sequence the approval popup runs, then hand the artifact to the exact check
|
||||||
// the background runs before it broadcasts or resolves. Only what the popup
|
// the background runs before it broadcasts or resolves. Only what the popup
|
||||||
@@ -314,7 +1019,12 @@ describe("popup signing sequence to background verification", () => {
|
|||||||
|
|
||||||
test("a populated, signed transaction is accepted and broadcastable", async () => {
|
test("a populated, signed transaction is accepted and broadcastable", async () => {
|
||||||
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
||||||
const parsed = verifySignedTx(rawSignedTx, TX_PARAMS, signer.address);
|
const parsed = verifySignedTx(
|
||||||
|
rawSignedTx,
|
||||||
|
TX_PARAMS,
|
||||||
|
signer.address,
|
||||||
|
SELECTED,
|
||||||
|
);
|
||||||
expect(parsed.nonce).toBe(7);
|
expect(parsed.nonce).toBe(7);
|
||||||
expect(parsed.chainId).toBe(1n);
|
expect(parsed.chainId).toBe(1n);
|
||||||
expect(parsed.gasLimit).toBe(21000n);
|
expect(parsed.gasLimit).toBe(21000n);
|
||||||
@@ -349,7 +1059,14 @@ describe("popup signing sequence to background verification", () => {
|
|||||||
to: OTHER_RECIPIENT,
|
to: OTHER_RECIPIENT,
|
||||||
});
|
});
|
||||||
expect(() =>
|
expect(() =>
|
||||||
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address),
|
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SELECTED),
|
||||||
).toThrow(/approved recipient/);
|
).toThrow(/approved recipient/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the background rejects a transaction populated on another network", async () => {
|
||||||
|
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
||||||
|
expect(() =>
|
||||||
|
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SEPOLIA),
|
||||||
|
).toThrow(/different network than the one that is selected/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
104
tests/state.test.js
Normal file
104
tests/state.test.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
|
function oneWallet() {
|
||||||
|
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// state.js resolves the storage API at require time, so the stub has to exist
|
||||||
|
// before the module is loaded, and the module registry has to be reset between
|
||||||
|
// cases because `state` is a module-level singleton.
|
||||||
|
function loadModuleWith(persisted) {
|
||||||
|
jest.resetModules();
|
||||||
|
const set = jest.fn(async () => {});
|
||||||
|
global.chrome = {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: jest.fn(async () =>
|
||||||
|
persisted ? { autistmask: persisted } : {},
|
||||||
|
),
|
||||||
|
set,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { mod: require("../src/shared/state"), set };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.chrome;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadState hasWallet reconciliation", () => {
|
||||||
|
// A profile that deleted its last wallet on a build predating the write
|
||||||
|
// path fix keeps hasWallet: true forever. It must load as no wallet, which
|
||||||
|
// is what sends the popup to the welcome view.
|
||||||
|
test("stored hasWallet true with zero wallets loads as no wallet", async () => {
|
||||||
|
const { mod } = loadModuleWith({ hasWallet: true, wallets: [] });
|
||||||
|
await mod.loadState();
|
||||||
|
expect(mod.state.hasWallet).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stored hasWallet true with a missing wallets key loads as no wallet", async () => {
|
||||||
|
const { mod } = loadModuleWith({ hasWallet: true });
|
||||||
|
await mod.loadState();
|
||||||
|
expect(mod.state.wallets).toEqual([]);
|
||||||
|
expect(mod.state.hasWallet).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stored hasWallet false with one wallet loads as having a wallet", async () => {
|
||||||
|
const { mod } = loadModuleWith({
|
||||||
|
hasWallet: false,
|
||||||
|
wallets: oneWallet(),
|
||||||
|
});
|
||||||
|
await mod.loadState();
|
||||||
|
expect(mod.state.hasWallet).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("absent hasWallet with wallets present loads as having a wallet", async () => {
|
||||||
|
const { mod } = loadModuleWith({ wallets: oneWallet() });
|
||||||
|
await mod.loadState();
|
||||||
|
expect(mod.state.hasWallet).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("consistent stored states are preserved", async () => {
|
||||||
|
const withWallet = loadModuleWith({
|
||||||
|
hasWallet: true,
|
||||||
|
wallets: oneWallet(),
|
||||||
|
});
|
||||||
|
await withWallet.mod.loadState();
|
||||||
|
expect(withWallet.mod.state.hasWallet).toBe(true);
|
||||||
|
|
||||||
|
const without = loadModuleWith({ hasWallet: false, wallets: [] });
|
||||||
|
await without.mod.loadState();
|
||||||
|
expect(without.mod.state.hasWallet).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty storage leaves the default no-wallet state", async () => {
|
||||||
|
const { mod } = loadModuleWith(null);
|
||||||
|
await mod.loadState();
|
||||||
|
expect(mod.state.hasWallet).toBe(false);
|
||||||
|
expect(mod.state.wallets).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The correction is derived on every load rather than written back, so a
|
||||||
|
// load never has a storage side effect.
|
||||||
|
test("loadState does not write to storage", async () => {
|
||||||
|
const { mod, set } = loadModuleWith({ hasWallet: true, wallets: [] });
|
||||||
|
await mod.loadState();
|
||||||
|
expect(set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deriving must not disturb the rest of the load.
|
||||||
|
test("other persisted fields still load", async () => {
|
||||||
|
const { mod } = loadModuleWith({
|
||||||
|
hasWallet: false,
|
||||||
|
wallets: oneWallet(),
|
||||||
|
networkId: "sepolia",
|
||||||
|
theme: "dark",
|
||||||
|
activeAddress: ADDRESS,
|
||||||
|
});
|
||||||
|
await mod.loadState();
|
||||||
|
expect(mod.state.networkId).toBe("sepolia");
|
||||||
|
expect(mod.state.theme).toBe("dark");
|
||||||
|
expect(mod.state.activeAddress).toBe(ADDRESS);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@ global.chrome = { storage: { local: {} } };
|
|||||||
const {
|
const {
|
||||||
fetchRecentTransactions,
|
fetchRecentTransactions,
|
||||||
filterTransactions,
|
filterTransactions,
|
||||||
|
mergeTransactions,
|
||||||
} = require("../src/shared/transactions");
|
} = require("../src/shared/transactions");
|
||||||
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
|
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
|
||||||
const { debugFetch } = require("../src/shared/log");
|
const { debugFetch } = require("../src/shared/log");
|
||||||
@@ -685,6 +686,339 @@ describe("legitimate transactions are never filtered", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// mergeTransactions is the pure core of the merge: it takes parsed native
|
||||||
|
// entries and parsed token transfers and decides how many rows one on-chain
|
||||||
|
// transaction becomes. One transaction is one row per distinct value
|
||||||
|
// movement, so the native side of a plain ERC-20 transfer must not survive
|
||||||
|
// next to its token row (the duplicate-row bug), while a hash that really
|
||||||
|
// did move several things must keep a row for each.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// A native entry as parseTx produces it for a decoded contract call: the
|
||||||
|
// amount fields are blanked and direction is "contract".
|
||||||
|
function contractCallTx(overrides = {}) {
|
||||||
|
return nativeTx({
|
||||||
|
from: VICTIM,
|
||||||
|
to: USDC_CONTRACT,
|
||||||
|
value: "",
|
||||||
|
exactValue: "",
|
||||||
|
rawAmount: "",
|
||||||
|
rawUnit: "",
|
||||||
|
valueGwei: 0,
|
||||||
|
direction: "contract",
|
||||||
|
directionLabel: "Approve",
|
||||||
|
isContractCall: true,
|
||||||
|
method: "approve",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The native entry parseTx produces for a plain ERC-20 transfer: sent to the
|
||||||
|
// token contract, no ETH, and method "transfer", which is exactly why it is
|
||||||
|
// not marked as a display-level contract call.
|
||||||
|
function erc20CallTx(overrides = {}) {
|
||||||
|
return nativeTx({
|
||||||
|
from: VICTIM,
|
||||||
|
to: USDC_CONTRACT,
|
||||||
|
value: "0.0000",
|
||||||
|
exactValue: "0.0",
|
||||||
|
rawAmount: "0",
|
||||||
|
valueGwei: 0,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
isContractCall: true,
|
||||||
|
method: "transfer",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mergeTransactions: one row per value movement", () => {
|
||||||
|
const HASH = "0x" + "d".repeat(64);
|
||||||
|
const OTHER_HASH = "0x" + "e".repeat(64);
|
||||||
|
const ROUTER = "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad";
|
||||||
|
|
||||||
|
test("a plain ERC-20 transfer yields one row, the token row", () => {
|
||||||
|
const native = erc20CallTx({ hash: HASH });
|
||||||
|
const token = tokenTx({
|
||||||
|
hash: HASH,
|
||||||
|
from: VICTIM,
|
||||||
|
to: ORDINARY_PEER,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeTransactions([native], [token]);
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(merged[0].symbol).toBe("USDC");
|
||||||
|
expect(merged[0].exactValue).toBe("1500.5");
|
||||||
|
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ETH-only transfer keeps its row unchanged", () => {
|
||||||
|
const merged = mergeTransactions([legitimateEthSend()], []);
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(merged[0]).toEqual(legitimateEthSend());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a genuine zero-value native transaction is still displayed", () => {
|
||||||
|
const zero = nativeTx({
|
||||||
|
hash: HASH,
|
||||||
|
from: VICTIM,
|
||||||
|
to: ORDINARY_PEER,
|
||||||
|
value: "0.0000",
|
||||||
|
exactValue: "0.0",
|
||||||
|
rawAmount: "0",
|
||||||
|
valueGwei: 0,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeTransactions([zero], []);
|
||||||
|
expect(merged).toEqual([zero]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a zero-value native row is only absorbed by a transfer sharing its hash", () => {
|
||||||
|
const zero = erc20CallTx({ hash: HASH });
|
||||||
|
const unrelated = tokenTx({ hash: OTHER_HASH });
|
||||||
|
|
||||||
|
const merged = mergeTransactions([zero], [unrelated]);
|
||||||
|
expect(merged).toHaveLength(2);
|
||||||
|
expect(merged.map((t) => t.hash).sort()).toEqual(
|
||||||
|
[HASH, OTHER_HASH].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a native transaction that moved ETH keeps its row beside the token row", () => {
|
||||||
|
// An undecoded call (no method name) carrying ETH that also emitted
|
||||||
|
// a token transfer: two real movements, so two rows.
|
||||||
|
const native = nativeTx({
|
||||||
|
hash: HASH,
|
||||||
|
from: VICTIM,
|
||||||
|
to: ROUTER,
|
||||||
|
value: "0.2500",
|
||||||
|
exactValue: "0.25",
|
||||||
|
rawAmount: "250000000000000000",
|
||||||
|
valueGwei: 250000000,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
isContractCall: true,
|
||||||
|
});
|
||||||
|
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
|
||||||
|
|
||||||
|
const merged = mergeTransactions([native], [token]);
|
||||||
|
expect(merged).toHaveLength(2);
|
||||||
|
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a sub-gwei ETH movement keeps its row beside the token row", () => {
|
||||||
|
// 500000000 wei is 0.5 gwei, so parseTx's valueGwei floors to 0 while
|
||||||
|
// rawAmount stays nonzero. Deciding "moved no ETH" on valueGwei would
|
||||||
|
// delete this row and lose a real ETH movement, so the decision is made
|
||||||
|
// on rawAmount as a BigInt.
|
||||||
|
const native = nativeTx({
|
||||||
|
hash: HASH,
|
||||||
|
from: VICTIM,
|
||||||
|
to: ROUTER,
|
||||||
|
value: "0.0000",
|
||||||
|
exactValue: "0.0000000005",
|
||||||
|
rawAmount: "500000000",
|
||||||
|
valueGwei: 0,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
isContractCall: true,
|
||||||
|
});
|
||||||
|
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
|
||||||
|
|
||||||
|
const merged = mergeTransactions([native], [token]);
|
||||||
|
expect(merged).toHaveLength(2);
|
||||||
|
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
|
||||||
|
expect(merged.find((t) => t.symbol === "ETH").rawAmount).toBe(
|
||||||
|
"500000000",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a swap consolidates every token leg into one row, preferring the received leg", () => {
|
||||||
|
const native = contractCallTx({
|
||||||
|
hash: HASH,
|
||||||
|
to: ROUTER,
|
||||||
|
directionLabel: "Swap",
|
||||||
|
method: "execute",
|
||||||
|
});
|
||||||
|
const sentLeg = tokenTx({
|
||||||
|
hash: HASH,
|
||||||
|
from: VICTIM,
|
||||||
|
to: ROUTER,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
});
|
||||||
|
const receivedLeg = tokenTx({
|
||||||
|
hash: HASH,
|
||||||
|
from: ROUTER,
|
||||||
|
to: VICTIM,
|
||||||
|
value: "0.2500",
|
||||||
|
exactValue: "0.25",
|
||||||
|
rawAmount: "250000000000000000",
|
||||||
|
rawUnit: "WETH base units (10^-18)",
|
||||||
|
symbol: "WETH",
|
||||||
|
contractAddress: WETH_CONTRACT,
|
||||||
|
holders: 850000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeTransactions([native], [sentLeg, receivedLeg]);
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(merged[0].symbol).toBe("WETH");
|
||||||
|
expect(merged[0].exactValue).toBe("0.25");
|
||||||
|
// The user's own address and the contract called are preserved.
|
||||||
|
expect(merged[0].from).toBe(VICTIM);
|
||||||
|
expect(merged[0].to).toBe(ROUTER);
|
||||||
|
expect(merged[0].directionLabel).toBe("Swap");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a swap whose legs are all sent takes its amount from the first sent leg", () => {
|
||||||
|
const native = contractCallTx({
|
||||||
|
hash: HASH,
|
||||||
|
to: ROUTER,
|
||||||
|
directionLabel: "Swap",
|
||||||
|
method: "execute",
|
||||||
|
});
|
||||||
|
const firstSent = tokenTx({
|
||||||
|
hash: HASH,
|
||||||
|
from: VICTIM,
|
||||||
|
to: ROUTER,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
});
|
||||||
|
const secondSent = tokenTx({
|
||||||
|
hash: HASH,
|
||||||
|
from: VICTIM,
|
||||||
|
to: ROUTER,
|
||||||
|
value: "0.2500",
|
||||||
|
exactValue: "0.25",
|
||||||
|
rawAmount: "250000000000000000",
|
||||||
|
rawUnit: "WETH base units (10^-18)",
|
||||||
|
symbol: "WETH",
|
||||||
|
contractAddress: WETH_CONTRACT,
|
||||||
|
holders: 850000,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeTransactions([native], [firstSent, secondSent]);
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
// With no received leg the display amount comes from the first sent
|
||||||
|
// leg, and a later sent leg does not overwrite it.
|
||||||
|
expect(merged[0].symbol).toBe("USDC");
|
||||||
|
expect(merged[0].exactValue).toBe("1500.5");
|
||||||
|
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
|
||||||
|
expect(merged[0].holders).toBe(3500000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a contract call carrying ETH plus a token transfer stays one row", () => {
|
||||||
|
const native = contractCallTx({
|
||||||
|
hash: HASH,
|
||||||
|
to: ROUTER,
|
||||||
|
directionLabel: "Swap",
|
||||||
|
method: "swapExactETHForTokens",
|
||||||
|
valueGwei: 250000000,
|
||||||
|
});
|
||||||
|
const received = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
|
||||||
|
|
||||||
|
const merged = mergeTransactions([native], [received]);
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(merged[0].symbol).toBe("USDC");
|
||||||
|
expect(merged[0].exactValue).toBe("1500.5");
|
||||||
|
// The ETH leg is still visible as the row's native quantity.
|
||||||
|
expect(merged[0].valueGwei).toBe(250000000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an approve keeps its row and survives the filters", () => {
|
||||||
|
const approve = contractCallTx({ hash: HASH });
|
||||||
|
|
||||||
|
const merged = mergeTransactions([approve], []);
|
||||||
|
expect(merged).toEqual([approve]);
|
||||||
|
expect(filterTransactions(merged, filters()).transactions).toEqual([
|
||||||
|
approve,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a contract creation keeps its row", () => {
|
||||||
|
const creation = nativeTx({
|
||||||
|
hash: HASH,
|
||||||
|
from: VICTIM,
|
||||||
|
to: "",
|
||||||
|
value: "0.0000",
|
||||||
|
exactValue: "0.0",
|
||||||
|
rawAmount: "0",
|
||||||
|
valueGwei: 0,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mergeTransactions([creation], [])).toEqual([creation]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a native self-send keeps its single row", () => {
|
||||||
|
const selfSend = nativeTx({
|
||||||
|
hash: HASH,
|
||||||
|
from: VICTIM,
|
||||||
|
to: VICTIM,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mergeTransactions([selfSend], [])).toEqual([selfSend]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a token self-send yields one row", () => {
|
||||||
|
const native = erc20CallTx({ hash: HASH });
|
||||||
|
const token = tokenTx({
|
||||||
|
hash: HASH,
|
||||||
|
from: VICTIM,
|
||||||
|
to: VICTIM,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeTransactions([native], [token]);
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(merged[0].symbol).toBe("USDC");
|
||||||
|
expect(merged[0].from).toBe(VICTIM);
|
||||||
|
expect(merged[0].to).toBe(VICTIM);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("several distinct tokens moved by one ERC-20 call keep a row each", () => {
|
||||||
|
const native = erc20CallTx({ hash: HASH });
|
||||||
|
const usdc = tokenTx({ hash: HASH });
|
||||||
|
const weth = tokenTx({
|
||||||
|
hash: HASH,
|
||||||
|
symbol: "WETH",
|
||||||
|
contractAddress: WETH_CONTRACT,
|
||||||
|
holders: 850000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeTransactions([native], [usdc, weth]);
|
||||||
|
expect(merged.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rows are sorted by block number, newest first", () => {
|
||||||
|
const older = nativeTx({ hash: HASH, blockNumber: 21000000 });
|
||||||
|
const newer = nativeTx({ hash: OTHER_HASH, blockNumber: 21000010 });
|
||||||
|
|
||||||
|
const merged = mergeTransactions([older, newer], []);
|
||||||
|
expect(merged.map((t) => t.blockNumber)).toEqual([21000010, 21000000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the entries handed in are never mutated", () => {
|
||||||
|
const native = contractCallTx({ hash: HASH, method: "execute" });
|
||||||
|
const token = tokenTx({ hash: HASH });
|
||||||
|
const before = JSON.stringify([native, token]);
|
||||||
|
|
||||||
|
mergeTransactions([native], [token]);
|
||||||
|
expect(JSON.stringify([native, token])).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// fetchRecentTransactions owns the per-address merge of normal transactions
|
// fetchRecentTransactions owns the per-address merge of normal transactions
|
||||||
// with ERC-20 transfers. (The cross-address merge Home performs lives in
|
// with ERC-20 transfers. (The cross-address merge Home performs lives in
|
||||||
@@ -886,13 +1220,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
|||||||
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
|
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Documents current behaviour: for a plain ERC-20 transfer the method is
|
// Regression guard for the duplicate-row bug: for a plain ERC-20
|
||||||
// "transfer", so parseTx does not mark the entry as a contract call in
|
// transfer the method is "transfer", so parseTx does not mark the entry
|
||||||
// the display sense and the merge loop does not consolidate the token
|
// as a contract call in the display sense. The native side of that
|
||||||
// transfer into it. The result is two entries for one transaction: a
|
// transaction moved no ETH and is represented by the token row, so it
|
||||||
// zero-value native row and the real token row. The zero-value row also
|
// must not survive the merge as a second, zero-value row.
|
||||||
// escapes dust filtering because isContractCall is true.
|
test("a plain ERC-20 transfer produces exactly one entry", async () => {
|
||||||
test("current behaviour: a plain ERC-20 transfer produces two entries", async () => {
|
|
||||||
const hash = "0x" + "5".repeat(64);
|
const hash = "0x" + "5".repeat(64);
|
||||||
respondWith(
|
respondWith(
|
||||||
[
|
[
|
||||||
@@ -925,14 +1258,15 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||||
expect(txs).toHaveLength(2);
|
expect(txs).toHaveLength(1);
|
||||||
expect(txs.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
|
expect(txs[0].symbol).toBe("USDC");
|
||||||
const nativeRow = txs.find((t) => t.symbol === "ETH");
|
expect(txs[0].exactValue).toBe("1.0");
|
||||||
expect(nativeRow.exactValue).toBe("0.0");
|
expect(txs[0].direction).toBe("sent");
|
||||||
expect(nativeRow.isContractCall).toBe(true);
|
expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
|
||||||
// And the zero-value row is not removed by the dust filter.
|
// The surviving row is the token row, and the filters keep it.
|
||||||
const kept = filterTransactions(txs, filters()).transactions;
|
const kept = filterTransactions(txs, filters()).transactions;
|
||||||
expect(kept).toHaveLength(2);
|
expect(kept).toHaveLength(1);
|
||||||
|
expect(kept[0].symbol).toBe("USDC");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("entries are sorted by block number descending and capped at count", async () => {
|
test("entries are sorted by block number descending and capped at count", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user