Compare commits
2 Commits
3f02a699d6
...
b9ac3cf928
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9ac3cf928 | ||
| b9bc226ae1 |
404
README.md
404
README.md
@@ -271,10 +271,10 @@ on a different table knows exactly tf I am talking about.
|
||||
|
||||
Every interactive element must visually indicate that it is clickable. Buttons
|
||||
use a visible border, padding, and a hover state (invert to white-on-black).
|
||||
Text that triggers an action (e.g. "Import private key") uses an underline. No
|
||||
invisible hit targets, no bare text that happens to have a click handler. If it
|
||||
does something when you click it, it must look like it does something when you
|
||||
click it.
|
||||
Text that triggers an action (e.g. "Add additional wallet...") uses an
|
||||
underline. No invisible hit targets, no bare text that happens to have a click
|
||||
handler. If it does something when you click it, it must look like it does
|
||||
something when you click it.
|
||||
|
||||
#### Display Consistency
|
||||
|
||||
@@ -334,12 +334,18 @@ attack.
|
||||
|
||||
The core hierarchy is **Wallets → Addresses**:
|
||||
|
||||
- A **wallet** is either:
|
||||
- An **HD wallet** (recovery phrase): generates multiple addresses from a
|
||||
single 12/24 word recovery phrase using BIP-39/BIP-44 derivation. The user
|
||||
can add more addresses with a "+" button.
|
||||
- A **key wallet** (private key): a single address imported directly from a
|
||||
private key. No "+" button since there is only one address.
|
||||
- A **wallet** is one of three types:
|
||||
- An **HD wallet** (`type: "hd"`, recovery phrase): generates multiple
|
||||
addresses from a single 12/24 word recovery phrase using BIP-39/BIP-44
|
||||
derivation. The user can add more addresses with a "+" button.
|
||||
- A **key wallet** (`type: "key"`, private key): a single address imported
|
||||
directly from a private key. No "+" button since there is only one
|
||||
address.
|
||||
- An **xprv wallet** (`type: "xprv"`, extended private key): the same
|
||||
multi-address behavior as an HD wallet, including the "+" button and the
|
||||
address scan on import, but imported from an extended private key rather
|
||||
than a recovery phrase. It therefore has no recovery phrase to display or
|
||||
back up.
|
||||
- An **address** holds ETH and any user-added ERC-20 tokens.
|
||||
- The user can have multiple wallets, each with multiple addresses (HD) or a
|
||||
single address (key).
|
||||
@@ -354,95 +360,140 @@ menus.
|
||||
|
||||
### Screen Map
|
||||
|
||||
Navigation uses a stack model (like iOS): each action pushes a screen onto the
|
||||
stack, and "Back" pops it. The root screen is either Welcome (no wallets) or
|
||||
Home (has wallets). Screens are listed below with their elements and
|
||||
transitions.
|
||||
Navigation uses a stack model (like iOS): each forward action pushes the current
|
||||
screen onto `state.viewStack`, and "Back" pops it (`pushCurrentView()` and
|
||||
`goBack()` in `src/popup/views/helpers.js`). The root screen is either Welcome
|
||||
(no wallets) or Home (has wallets). Each screen below gives its view id in
|
||||
parentheses; the registry of view ids is the `VIEWS` array in
|
||||
`src/popup/views/helpers.js`, and the markup for a screen is the element with id
|
||||
`view-` plus that view id in `src/popup/index.html`.
|
||||
|
||||
#### Welcome
|
||||
Three elements sit outside the screens and are present on all of them: the title
|
||||
bar ("AutistMask by @sneak" plus the Settings gear), the flash message line
|
||||
under it, and the red banner at the very top that appears on a debug build, when
|
||||
runtime debug mode is on, or when the active network is a testnet. They are not
|
||||
repeated in the element lists below.
|
||||
|
||||
- **When**: No wallets exist yet.
|
||||
- **Elements**: "AutistMask" heading, brief intro text, "Add wallet" button.
|
||||
Closing and reopening the popup returns to the screen the user was last on only
|
||||
for the views listed in `RESTORABLE_VIEWS` (`src/popup/index.js`). Every other
|
||||
screen, including ExportPrivKey, falls back to Home.
|
||||
|
||||
#### Welcome (`welcome`)
|
||||
|
||||
- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
|
||||
screen in that case.
|
||||
- **Elements**:
|
||||
- "Welcome! To get started, add a wallet." text
|
||||
- "Add wallet" button
|
||||
- **Transitions**:
|
||||
- "Add wallet" → **AddWallet**
|
||||
|
||||
#### Home
|
||||
#### Home (`main`)
|
||||
|
||||
- **When**: At least one wallet exists. This is the root screen.
|
||||
- **Elements**:
|
||||
- Header: "AutistMask", Settings gear button
|
||||
- Active address ETH balance (large) + USD value (inline parentheses)
|
||||
- Total USD value across all tokens (small text)
|
||||
- Active address ETH balance (large) + USD value in parentheses
|
||||
- "Total:" USD value across ETH and all tracked tokens of the active address
|
||||
- Active address (color dot, full address, etherscan link, tap to copy)
|
||||
- Send / Receive quick-action buttons
|
||||
- Send / Receive quick-action buttons, both acting on the active address
|
||||
- ETH/USD price display
|
||||
- Wallet list: each wallet shows name (tap to rename), "+" button (HD only),
|
||||
and its addresses with color dots, balances, and `[info]` buttons
|
||||
- Recent transactions across all addresses (merged, deduplicated, filtered)
|
||||
- Wallet list: each wallet shows its name (tap to rename inline) and a "+"
|
||||
button for HD and xprv wallets, then one block per address with "Address
|
||||
N" (bold when active), the ENS name if resolved, the full address, an
|
||||
`[info]` button, the address USD total, and a balance line for ETH and for
|
||||
each tracked token
|
||||
- "Recent Transactions": up to 25 transactions merged across every address
|
||||
of every wallet, deduplicated by hash and filtered
|
||||
- "Add additional wallet..." link at bottom
|
||||
- **Transitions**:
|
||||
- Tap address row → sets active address (no screen change)
|
||||
- Tap address row → sets the active address and broadcasts
|
||||
`AUTISTMASK_ACTIVE_CHANGED` (no screen change)
|
||||
- Tap wallet name → inline rename field (no screen change)
|
||||
- "+" on wallet → derives the next address inline (no screen change)
|
||||
- `[info]` on address → **AddressDetail**
|
||||
- "Send" → **Send** (selects active address)
|
||||
- "Send" → **Send** (refuses with a flash message on a zero balance)
|
||||
- "Receive" → **Receive** (shows active address QR)
|
||||
- "+" on wallet → derives next address inline
|
||||
- Tap home tx row → **TransactionDetail**
|
||||
- "Add additional wallet..." → **AddWallet**
|
||||
- Settings gear → **Settings** (toggles; tap again to return)
|
||||
- Tap home tx row → **AddressDetail** (for the address involved)
|
||||
|
||||
#### AddWallet
|
||||
#### AddWallet (`add-wallet`)
|
||||
|
||||
- **When**: User wants to add a new wallet (from Home, Welcome, or Settings).
|
||||
- **When**: User wants to add a new wallet (from Welcome, Home, or Settings).
|
||||
This one screen covers all three import modes; there is no separate import
|
||||
screen.
|
||||
- **Elements**:
|
||||
- "Add Wallet" heading, "Back" button
|
||||
- Instruction text
|
||||
- Die button `[die]` (generates random recovery phrase)
|
||||
- Recovery phrase textarea
|
||||
- Backup warning box (shown after die is clicked)
|
||||
- Password + confirm password inputs
|
||||
- "Add" button
|
||||
- "Have a private key instead?" link
|
||||
- **Transitions**:
|
||||
- "Add" (valid phrase + password) → **Home**
|
||||
- "Back" → previous screen (Home or Welcome)
|
||||
- "Have a private key instead?" → **ImportKey**
|
||||
|
||||
#### ImportKey
|
||||
|
||||
- **When**: User wants to import a single private key.
|
||||
- **Elements**:
|
||||
- "Import Private Key" heading, "Back" button
|
||||
- Instruction text
|
||||
- Private key input (password-masked)
|
||||
- Password + confirm password inputs
|
||||
- "Back" button, "Add Wallet" heading
|
||||
- Three tabs — "From Phrase" (`tab-mnemonic`), "From Key" (`tab-privkey`),
|
||||
"From xprv" (`tab-xprv`) — each showing its own form section:
|
||||
- **From Phrase**: instruction text, a die button that generates a
|
||||
random recovery phrase, a recovery phrase textarea, and a backup
|
||||
warning box that becomes visible once the die button has been used
|
||||
- **From Key**: instruction text and a masked private key input
|
||||
- **From xprv**: instruction text and a masked extended private key
|
||||
input
|
||||
- Password + confirm password inputs, with a hint line whose wording depends
|
||||
on the selected tab
|
||||
- "Import" button
|
||||
- **Transitions**:
|
||||
- "Import" (valid key + password) → **Home**
|
||||
- "Back" → **AddWallet**
|
||||
- "Import" with a valid entry and a matching password of at least 12
|
||||
characters → creates the wallet, clears the navigation stack, and →
|
||||
**Home**. The phrase and xprv modes then scan for further used addresses
|
||||
and report the count as a flash message.
|
||||
- "Import" with an invalid entry, a duplicate wallet or address, or a short
|
||||
or mismatched password → flash message, no screen change
|
||||
- "Back" → previous screen (Welcome, Home, or Settings)
|
||||
|
||||
#### AddressDetail
|
||||
#### AddressDetail (`address`)
|
||||
|
||||
- **When**: User tapped `[info]` on an address from Home.
|
||||
- **Elements**:
|
||||
- "Back" button
|
||||
- Blockie identicon (48px, centered)
|
||||
- Title: "Wallet Name — Address N"
|
||||
- ENS name (if resolved, bold with color dot)
|
||||
- ENS name (if resolved, bold above the address)
|
||||
- Full address (color dot, etherscan link, tap to copy)
|
||||
- USD total for address
|
||||
- Balance list: ETH + tracked ERC-20 tokens (4 decimal places, USD inline).
|
||||
Each balance row is clickable → **AddressToken**
|
||||
- Send / Receive / + Token buttons
|
||||
- Send / Receive / + Token buttons and a "···" menu button
|
||||
- "···" dropdown containing a single "Export Private Key" entry
|
||||
- Transaction list (with ENS resolution for counterparties)
|
||||
- **Transitions**:
|
||||
- Tap balance row → **AddressToken** (for that token)
|
||||
- "Send" → **Send**
|
||||
- "Send" → **Send** (refuses with a flash message on a zero balance)
|
||||
- "Receive" → **Receive**
|
||||
- "+ Token" → **AddToken**
|
||||
- "···" → "Export Private Key" → **ExportPrivKey**
|
||||
- Tap transaction row → **TransactionDetail**
|
||||
- "Back" → **Home**
|
||||
- "Back" → previous screen (Home)
|
||||
|
||||
#### AddressToken
|
||||
#### ExportPrivKey (`export-privkey`)
|
||||
|
||||
- **When**: User chose "Export Private Key" from the "···" menu on
|
||||
AddressDetail. This screen discloses secret material.
|
||||
- **Elements**:
|
||||
- "Back" button
|
||||
- Blockie identicon (48px, centered)
|
||||
- "Export Private Key" heading
|
||||
- "Wallet Name — Address N" and the full address (etherscan link, tap to
|
||||
copy)
|
||||
- Warning that anyone holding the private key can transfer all funds from
|
||||
the address
|
||||
- Error line
|
||||
- Password input and "Reveal" button, shown until the key is revealed
|
||||
- The private key on a highlighted background, tap to copy, shown only after
|
||||
the password has been accepted
|
||||
- **Transitions**:
|
||||
- "Reveal" (correct password) → decrypts the wallet secret, derives this
|
||||
address's key, hides the password input and shows the key (no screen
|
||||
change)
|
||||
- "Reveal" (wrong password) → "Wrong password." on the error line, nothing
|
||||
revealed
|
||||
- "Back" → clears the key and password from the DOM, then → previous screen
|
||||
(AddressDetail)
|
||||
|
||||
#### AddressToken (`address-token`)
|
||||
|
||||
- **When**: User clicked a specific token balance on AddressDetail.
|
||||
- **Elements**:
|
||||
@@ -453,49 +504,64 @@ transitions.
|
||||
- USD total for this token
|
||||
- Single token balance line (4 decimal places)
|
||||
- Send / Receive buttons
|
||||
- Token contract well (ERC-20 only): full contract address (tap to copy,
|
||||
etherscan link) plus name, symbol, decimals, holder count and project
|
||||
website where known
|
||||
- Token-filtered transaction list (only this token's transfers)
|
||||
- **Transitions**:
|
||||
- "Send" → **Send** (token pre-selected and locked in dropdown)
|
||||
- "Send" → **Send** (token locked: the dropdown is replaced by a static
|
||||
symbol and contract address)
|
||||
- "Receive" → **Receive** (ERC-20 warning shown for non-ETH tokens)
|
||||
- Tap transaction row → **TransactionDetail**
|
||||
- "Back" → **AddressDetail**
|
||||
- "Back" → previous screen (AddressDetail)
|
||||
|
||||
#### Send
|
||||
#### Send (`send`)
|
||||
|
||||
- **When**: User wants to send ETH or a token from this address.
|
||||
- **When**: User wants to send ETH or a token, from Home, AddressDetail, or
|
||||
AddressToken.
|
||||
- **Elements**:
|
||||
- "Send" heading, "Back" button
|
||||
- "Back" button, "Send" heading
|
||||
- From: address with color dot + etherscan link
|
||||
- What to send: token dropdown (or static display with contract address when
|
||||
locked from AddressToken)
|
||||
- To: address or ENS name input
|
||||
- To: address or ENS name input, with an inline validation message
|
||||
- Amount input with current balance display
|
||||
- "Review" button
|
||||
- "Review" button, disabled until the recipient validates
|
||||
- **Transitions**:
|
||||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||||
- "Back" → **AddressToken** (if came from token view) or **AddressDetail**
|
||||
- "Review" with an unresolvable ENS name or an invalid amount → flash
|
||||
message, no screen change
|
||||
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
|
||||
|
||||
#### ConfirmTx
|
||||
#### ConfirmTx (`confirm-tx`)
|
||||
|
||||
- **When**: User reviewed send details and is ready to authorize.
|
||||
- **Elements**:
|
||||
- "Confirm Transaction" heading, "Back" button
|
||||
- "Back" button, "Confirm Transaction" heading
|
||||
- Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)"
|
||||
- Token contract: full address + etherscan link (ERC-20 only)
|
||||
- From: blockie + color dot + full address + etherscan link + wallet title
|
||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||
- Amount: value + symbol (USD in parentheses)
|
||||
- Your balance: value + symbol (USD in parentheses)
|
||||
- Estimated network fee: ETH amount (USD in parentheses), fetched async
|
||||
- Warnings (scam address, self-send)
|
||||
- Estimated network fee: "Estimating..." then the ETH amount (USD in
|
||||
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)
|
||||
- "Send" button (disabled if errors)
|
||||
- Password: an inline field on this screen, not a modal, with its own error
|
||||
line
|
||||
- "Sign & Send" button (disabled if errors)
|
||||
- **Transitions**:
|
||||
- "Send" → password modal → broadcast tx → **WaitTx**
|
||||
- "Send" → password modal → broadcast fails → **ErrorTx**
|
||||
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
|
||||
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
|
||||
- "Sign & Send" (wrong password) → "Wrong password." on the password error
|
||||
line, no screen change
|
||||
- "Back" → **Send**
|
||||
|
||||
#### WaitTx
|
||||
#### WaitTx (`wait-tx`)
|
||||
|
||||
- **When**: Transaction has been broadcast, waiting for on-chain confirmation.
|
||||
- **Elements**:
|
||||
@@ -509,20 +575,24 @@ transitions.
|
||||
- Receipt found → **SuccessTx**
|
||||
- 60 seconds without confirmation → **ErrorTx** (timeout message)
|
||||
|
||||
#### SuccessTx
|
||||
#### SuccessTx (`success-tx`)
|
||||
|
||||
- **When**: Transaction confirmed on-chain.
|
||||
- **Elements**:
|
||||
- "Transaction Confirmed" heading
|
||||
- Decoded action well (shown when the transaction carried recognized
|
||||
calldata; the top-level Amount and To are hidden in that case)
|
||||
- Amount + symbol
|
||||
- To: color dot + full address + etherscan link
|
||||
- Block number
|
||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||
- "Done" button
|
||||
- **Transitions**:
|
||||
- "Done" → **AddressToken** (if `selectedToken` set) or **AddressDetail**
|
||||
- "Done" in the approval popup → closes the popup window
|
||||
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
|
||||
(if `selectedToken` set) or **AddressDetail**
|
||||
|
||||
#### ErrorTx
|
||||
#### ErrorTx (`error-tx`)
|
||||
|
||||
- **When**: Transaction broadcast failed, or timed out waiting for confirmation.
|
||||
- **Elements**:
|
||||
@@ -534,24 +604,28 @@ transitions.
|
||||
full hash (tap to copy) + etherscan link
|
||||
- "Done" button
|
||||
- **Transitions**:
|
||||
- "Done" → **AddressToken** (if `selectedToken` set) or **AddressDetail**
|
||||
- "Done" in the approval popup → closes the popup window
|
||||
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
|
||||
(if `selectedToken` set) or **AddressDetail**
|
||||
|
||||
#### Receive
|
||||
#### Receive (`receive`)
|
||||
|
||||
- **When**: User wants to receive funds at this address.
|
||||
- **When**: User wants to receive funds at this address, from Home,
|
||||
AddressDetail, or AddressToken.
|
||||
- **Elements**:
|
||||
- "Receive" heading, "Back" button
|
||||
- "Back" button, "Receive" heading
|
||||
- Instruction text
|
||||
- QR code encoding the address
|
||||
- Full address (color dot, selectable, etherscan link)
|
||||
- "Copy address" button
|
||||
- ERC-20 warning (shown when navigating from AddressToken for non-ETH token)
|
||||
- **Transitions**:
|
||||
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail**
|
||||
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
|
||||
|
||||
#### TransactionDetail
|
||||
#### TransactionDetail (`transaction`)
|
||||
|
||||
- **When**: User tapped a transaction row from AddressDetail or AddressToken.
|
||||
- **When**: User tapped a transaction row on Home, AddressDetail, or
|
||||
AddressToken.
|
||||
- **Elements** (grouped into logical blocks using light well containers; field
|
||||
labels are self-explanatory so groups have no headings):
|
||||
- "Transaction" heading, "Back" button
|
||||
@@ -576,91 +650,182 @@ transitions.
|
||||
- Raw data (shown when calldata is present): full calldata in monospace
|
||||
dashed border
|
||||
- **Transitions**:
|
||||
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail**
|
||||
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
|
||||
|
||||
#### AddToken
|
||||
#### AddToken (`add-token`)
|
||||
|
||||
- **When**: User wants to track an ERC-20 token on this address.
|
||||
- **When**: User wants to track an ERC-20 token, reached from "+ Token" on
|
||||
AddressDetail.
|
||||
- **Elements**:
|
||||
- "Add Token" heading, "Back" button
|
||||
- "Back" button, "Add Token" heading
|
||||
- Instruction text (find contract address on Etherscan)
|
||||
- Contract address input
|
||||
- Token info preview (name, symbol — fetched from contract)
|
||||
- Common token quick-pick buttons
|
||||
- Status line ("Looking up token...", cleared or replaced on failure)
|
||||
- Common token quick-pick buttons (top 25 by market cap), which fill the
|
||||
contract address input
|
||||
- "Add" button
|
||||
- **Transitions**:
|
||||
- "Add" (valid contract) → **AddressDetail**
|
||||
- "Back" → **AddressDetail**
|
||||
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
|
||||
**AddressDetail**
|
||||
- "Add" with a token already tracked, a scam-listed address, or a failed
|
||||
contract lookup → flash message, no screen change
|
||||
- "Back" → previous screen (AddressDetail)
|
||||
|
||||
#### Settings
|
||||
#### Settings (`settings`)
|
||||
|
||||
- **When**: User tapped Settings gear from Home.
|
||||
- **When**: User tapped the Settings gear.
|
||||
- **Elements**:
|
||||
- "Settings" heading, "Back" button
|
||||
- Wallets: "+ Add wallet" button
|
||||
- Display: "Show tracked tokens with zero balance" checkbox
|
||||
- Ethereum RPC: endpoint URL input + "Save" button
|
||||
- Blockscout API: endpoint URL input + "Save" button
|
||||
- "Back" button, "Settings" heading
|
||||
- Wallets: one row per wallet with its name (tap to rename inline) and an
|
||||
`[x]` delete button, plus a "+ Add wallet" button
|
||||
- Tracked Tokens: one row per tracked token with an `[x]` remove button,
|
||||
plus a "+ Add token" button
|
||||
- Display: "Show tracked tokens with zero balance" checkbox and a Theme
|
||||
selector (System / Light / Dark)
|
||||
- Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
|
||||
resets the RPC and Blockscout endpoints to that network's defaults
|
||||
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
||||
`eth_chainId` before being saved)
|
||||
- Blockscout API: endpoint URL input + "Save" button (validated against
|
||||
`/stats` before being saved)
|
||||
- Token Spam Protection:
|
||||
- "Hide tokens with fewer than 1,000 holders" checkbox
|
||||
- "Hide transactions from detected fraud contracts" checkbox
|
||||
- "Hide dust transactions below N gwei" checkbox + threshold input
|
||||
- "UTC Timestamps" checkbox
|
||||
- Allowed Sites: list with remove buttons
|
||||
- Denied Sites: list with remove buttons
|
||||
- About: project link, license, author, version, release date, and the
|
||||
commit, which links to the commit in the repository
|
||||
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
|
||||
turns on the red banner and verbose logging
|
||||
- **Transitions**:
|
||||
- "+ Add wallet" → **AddWallet**
|
||||
- "Back" (or Settings gear again) → **Home**
|
||||
- "+ Add token" → **SettingsAddToken**
|
||||
- `[x]` on a wallet → **DeleteWallet**
|
||||
- Tap wallet name → inline rename field (no screen change)
|
||||
- `[x]` on a tracked token or a site → removes it in place (no screen
|
||||
change)
|
||||
- Ten clicks on the version → reveals the Debug well (no screen change)
|
||||
- "Back" (or Settings gear again) → previous screen (Home)
|
||||
|
||||
#### SiteApproval
|
||||
#### DeleteWallet (`delete-wallet-confirm`)
|
||||
|
||||
- **When**: A website requests wallet access via `eth_requestAccounts`. Opened
|
||||
in a separate popup by the background script.
|
||||
- **When**: User tapped the `[x]` next to a wallet in Settings.
|
||||
- **Elements**:
|
||||
- "Back" button, "Delete Wallet" heading
|
||||
- Warning naming the wallet and stating that deletion is permanent and any
|
||||
funds are unrecoverable without the recovery phrase
|
||||
- Error line
|
||||
- Password input
|
||||
- "Confirm Delete" button
|
||||
- **Transitions**:
|
||||
- "Confirm Delete" (correct password, other wallets remain) → deletes the
|
||||
wallet and its site permissions, then → **Settings** with a "Wallet
|
||||
deleted." flash message
|
||||
- "Confirm Delete" (correct password, last wallet) → deletes the wallet,
|
||||
clears the selection and the navigation stack, then → **Welcome**
|
||||
- Either way, the active address moves only if it belonged to the deleted
|
||||
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
|
||||
(`src/shared/walletDelete.js`)
|
||||
- "Confirm Delete" (wrong password) → "Wrong password." on the error line,
|
||||
nothing deleted
|
||||
- "Back" → previous screen (Settings)
|
||||
|
||||
#### SettingsAddToken (`settings-addtoken`)
|
||||
|
||||
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
|
||||
across every address, unlike AddToken which is reached from one address.
|
||||
- **Elements**:
|
||||
- "Back" button, "Add Token" heading
|
||||
- Instruction text
|
||||
- "Top tokens:" quick-pick buttons (top 10 by market cap; already-tracked
|
||||
tokens are disabled)
|
||||
- "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
|
||||
"Add selected" button
|
||||
- "Or enter contract address:" input, a status line, and an "Add" button
|
||||
- **Transitions**:
|
||||
- Any of the three add paths, on success → adds the token and shows an
|
||||
"Added SYMBOL" flash message (no screen change)
|
||||
- A duplicate, a scam-listed address, or a failed contract lookup → flash
|
||||
message, no screen change
|
||||
- "Back" → previous screen (Settings)
|
||||
|
||||
#### SiteApproval (`approve-site`)
|
||||
|
||||
- **When**: A website requests wallet access via `eth_requestAccounts` or
|
||||
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
|
||||
The background script prefers the toolbar popup (`action.openPopup()`) and
|
||||
falls back to a separate popup window (`src/background/index.js`,
|
||||
`requestApproval()`).
|
||||
- **Elements**:
|
||||
- "Connection Request" heading
|
||||
- Site hostname (bold)
|
||||
- Phishing warning banner (shown when the hostname is on the phishing
|
||||
blocklist)
|
||||
- Site hostname (bold) + "wants to connect to your wallet"
|
||||
- Address that will be shared (color dot + full address + etherscan link)
|
||||
- "Remember my choice for this site" checkbox
|
||||
- "Allow" / "Deny" buttons
|
||||
- **Transitions**:
|
||||
- "Allow" / "Deny" → closes popup (returns result to background script)
|
||||
- "Allow" / "Deny" → closes popup (returns result to background script; the
|
||||
choice is persisted to the allowed or denied list when "Remember" is
|
||||
checked)
|
||||
- Popup closed without answering → treated as a denial
|
||||
|
||||
#### TxApproval
|
||||
#### TxApproval (`approve-tx`)
|
||||
|
||||
- **When**: A connected website requests a transaction via
|
||||
`eth_sendTransaction`. Opened via the toolbar popup by the background script.
|
||||
`eth_sendTransaction`. Always opened in a separate popup window by the
|
||||
background script (`windows.create()`), because the request is triggered
|
||||
programmatically rather than by a user gesture.
|
||||
- **Elements**:
|
||||
- "Transaction Request" heading
|
||||
- Phishing warning banner (shown when the hostname is on the phishing
|
||||
blocklist)
|
||||
- Site hostname (bold) + "wants to send a transaction"
|
||||
- Decoded action (if calldata is recognized): action name, token details,
|
||||
amounts, steps, deadline (see Transaction Decoding)
|
||||
- From: color dot + full address + etherscan link
|
||||
- To/Contract: color dot + full address + etherscan link (or "contract
|
||||
- Contract: color dot + full address + etherscan link (or "contract
|
||||
creation"), token symbol label if known
|
||||
- Value: amount in ETH (4 decimal places)
|
||||
- Value: amount in ETH (4 decimal places, USD in parentheses)
|
||||
- Raw data: full calldata displayed inline (shown if present)
|
||||
- Password input
|
||||
- Password input and an error line
|
||||
- "Confirm" / "Reject" buttons
|
||||
- **Transitions**:
|
||||
- "Confirm" (with password) → closes popup (returns result to background)
|
||||
- "Confirm" (correct password) → decrypts and signs in the popup, hands the
|
||||
signed transaction to the background to broadcast, then → **WaitTx** in
|
||||
the same popup window
|
||||
- "Confirm" (wrong password) → error line, no screen change
|
||||
- "Reject" → closes popup (returns rejection to background)
|
||||
- Popup window closed without answering → the request is rejected with
|
||||
EIP-1193 code 4001
|
||||
|
||||
#### SignApproval
|
||||
#### SignApproval (`approve-sign`)
|
||||
|
||||
- **When**: A connected website requests a message signature via
|
||||
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened via the toolbar
|
||||
popup by the background script.
|
||||
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
|
||||
TxApproval, in a separate popup window.
|
||||
- **Elements**:
|
||||
- "Signature Request" heading
|
||||
- Phishing warning banner (shown when the hostname is on the phishing
|
||||
blocklist)
|
||||
- Site hostname (bold) + "wants you to sign a message"
|
||||
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
||||
- Type: "Personal message" or "Typed data (EIP-712)"
|
||||
- From: color dot + full address + etherscan link
|
||||
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
|
||||
message fields (EIP-712 typed data)
|
||||
- Password input
|
||||
- Password input and an error line
|
||||
- "Sign" / "Reject" buttons
|
||||
- **Transitions**:
|
||||
- "Sign" (with password) → signs locally → closes popup (returns signature)
|
||||
- "Sign" (correct password) → signs locally → closes popup (returns
|
||||
signature)
|
||||
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
||||
change
|
||||
- "Reject" → closes popup (returns rejection to background)
|
||||
- Popup window closed without answering → the request is rejected with
|
||||
EIP-1193 code 4001
|
||||
|
||||
### External Services
|
||||
|
||||
@@ -815,6 +980,7 @@ hardcoded test phrase.
|
||||
- Create new HD wallet (generates 12-word recovery phrase)
|
||||
- Import HD wallet from existing 12 or 24 word recovery phrase
|
||||
- Import single-address wallet from private key
|
||||
- Import multi-address wallet from an extended private key (`xprv`)
|
||||
- Add multiple addresses within an HD wallet
|
||||
- Manage multiple wallets simultaneously
|
||||
- View ETH balance per address
|
||||
@@ -986,7 +1152,7 @@ Currently supported:
|
||||
|
||||
### Transactions
|
||||
|
||||
- [ ] Gas estimation and fee display before confirming
|
||||
- [x] Gas estimation and fee display before confirming
|
||||
|
||||
### Testing
|
||||
|
||||
|
||||
7
TODO.md
7
TODO.md
@@ -44,6 +44,9 @@ undefined identifiers, which is how
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-11: README Screen Map rebuilt from the code — every screen, element
|
||||
and transition re-verified against `src/popup/`
|
||||
([#164](https://git.eeqj.de/sneak/AutistMask/issues/164)).
|
||||
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
|
||||
all five network destinations documented, password/Settings/Add Wallet
|
||||
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
|
||||
@@ -51,6 +54,10 @@ undefined identifiers, which is how
|
||||
remaining wallets, the selection only moves when it was deleted, and the
|
||||
active-address change is broadcast to connected sites
|
||||
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
|
||||
- 2026-08-11: 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
|
||||
on `next`, with Status and Next Step refreshed
|
||||
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).
|
||||
|
||||
@@ -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) {
|
||||
log.debugf("fetchRecentTransactions", address);
|
||||
const addrLower = address.toLowerCase();
|
||||
@@ -145,53 +224,11 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||
const txJson = txResp.ok ? await txResp.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);
|
||||
log.debugf("fetchRecentTransactions done, count:", result.length);
|
||||
return result;
|
||||
@@ -265,4 +302,8 @@ function filterTransactions(txs, filters = {}) {
|
||||
return { transactions: filtered, newFraudContracts: newFraud };
|
||||
}
|
||||
|
||||
module.exports = { fetchRecentTransactions, filterTransactions };
|
||||
module.exports = {
|
||||
fetchRecentTransactions,
|
||||
filterTransactions,
|
||||
mergeTransactions,
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ global.chrome = { storage: { local: {} } };
|
||||
const {
|
||||
fetchRecentTransactions,
|
||||
filterTransactions,
|
||||
mergeTransactions,
|
||||
} = require("../src/shared/transactions");
|
||||
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
|
||||
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
|
||||
// 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"]);
|
||||
});
|
||||
|
||||
// Documents current behaviour: for a plain ERC-20 transfer the method is
|
||||
// "transfer", so parseTx does not mark the entry as a contract call in
|
||||
// the display sense and the merge loop does not consolidate the token
|
||||
// transfer into it. The result is two entries for one transaction: a
|
||||
// zero-value native row and the real token row. The zero-value row also
|
||||
// escapes dust filtering because isContractCall is true.
|
||||
test("current behaviour: a plain ERC-20 transfer produces two entries", async () => {
|
||||
// Regression guard for the duplicate-row bug: for a plain ERC-20
|
||||
// transfer the method is "transfer", so parseTx does not mark the entry
|
||||
// as a contract call in the display sense. The native side of that
|
||||
// transaction moved no ETH and is represented by the token row, so it
|
||||
// must not survive the merge as a second, zero-value row.
|
||||
test("a plain ERC-20 transfer produces exactly one entry", async () => {
|
||||
const hash = "0x" + "5".repeat(64);
|
||||
respondWith(
|
||||
[
|
||||
@@ -925,14 +1258,15 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
||||
);
|
||||
|
||||
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||
expect(txs).toHaveLength(2);
|
||||
expect(txs.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
|
||||
const nativeRow = txs.find((t) => t.symbol === "ETH");
|
||||
expect(nativeRow.exactValue).toBe("0.0");
|
||||
expect(nativeRow.isContractCall).toBe(true);
|
||||
// And the zero-value row is not removed by the dust filter.
|
||||
expect(txs).toHaveLength(1);
|
||||
expect(txs[0].symbol).toBe("USDC");
|
||||
expect(txs[0].exactValue).toBe("1.0");
|
||||
expect(txs[0].direction).toBe("sent");
|
||||
expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
|
||||
// The surviving row is the token row, and the filters keep it.
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user