Compare commits

..

1 Commits

Author SHA1 Message Date
71f1d11a33 Implement personal_sign and eth_signTypedData_v4 message signing
All checks were successful
check / check (push) Successful in 5s
Replace stub error handlers with full approval flow for personal_sign,
eth_sign, eth_signTypedData_v4, and eth_signTypedData. Uses toolbar
popup only (no fallback window) and keeps sign approvals pending across
popup close/reopen cycles so the user can respond via the toolbar icon.
2026-02-27 14:55:11 +07:00
31 changed files with 781 additions and 4074 deletions

1
.gitignore vendored
View File

@@ -25,4 +25,3 @@ dist/
# Yarn
.yarn-integrity
package-lock.json

372
README.md
View File

@@ -6,24 +6,6 @@ Chrome and Firefox. It manages HD wallets derived from BIP-39 seed phrases and
supports sending and receiving ETH and ERC-20 tokens, as well as web3 site
connection and authentication via the EIP-1193 provider API.
The most popular browser-based EVM wallet has a cute mascot, but sucks now. It
has tracking, ads, preferred placement for swaps, tx broadcast fuckery,
intercepts tx status links to their own site instead of going to Etherscan, etc.
None of the common alternatives work on Firefox.
Hence, a minimally viable ERC20 browser wallet/signer that works cross-platform.
Everything you need, nothing you don't. We import as few libraries as possible,
don't implement any crypto, and don't send user-specific data anywhere but a
(user-configurable) Ethereum RPC endpoint (which defaults to a public node). The
extension contacts exactly three external services: the configured RPC node for
blockchain interactions, a public CoinDesk API (no API key) for realtime price
information, and a Blockscout block-explorer API for transaction history and
token balances. All three endpoints are user-configurable.
In the extension is a hardcoded list of the top ERC20 contract addresses. You
can add any ERC20 contract by contract address if you wish, but the hardcoded
list exists to detect symbol spoofing attacks and improve UX.
## Getting Started
```bash
@@ -42,9 +24,9 @@ Load the extension:
## Rationale
Common popular EVM wallets have become bloated with swap UIs, portfolio
dashboards, analytics, tracking, and advertisements. It is no longer a simple
wallet. Most alternatives only support Chromium browsers, leaving Firefox users
MetaMask has become bloated with swap UIs, portfolio dashboards, analytics,
tracking, and advertisements. It is no longer a simple wallet. Most alternatives
(Rabby, Rainbow, etc.) only support Chromium browsers, leaving Firefox users
without a usable option.
AutistMask exists to provide the absolute minimum viable Ethereum wallet
@@ -64,70 +46,47 @@ separate output directories.
```
src/
background/ — service worker / background script
index.js — RPC routing, approval flows, message signing
content/ — content script injected into web pages
index.js — relay between inpage provider and background
inpage.js — the window.ethereum provider object (EIP-1193)
popup/ — popup UI (the main wallet interface)
background/ — service worker / background script
index.js — extension lifecycle, message routing
wallet.js — wallet management (create, import, derive via ethers.js)
provider.js — EIP-1193 JSON-RPC provider implementation
popup/ — popup UI (the main wallet interface)
index.html
index.js — entry point, view routing, state restore
styles/main.css — Tailwind source
views/ — one JS module per screen (home, send, approval, etc.)
shared/ — modules used by both popup and background
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
constants.jschain IDs, default RPC endpoint, ERC-20 ABI
ens.js — ENS forward/reverse resolution
prices.js — ETH/USD and token/USD via CoinDesk API
scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage)
tokenList.js — top ERC-20 tokens by market cap (hardcoded)
transactions.js — tx history fetching + anti-poisoning filters
uniswap.js — Uniswap Universal Router calldata decoder
vault.js — password-based encryption via libsodium
wallet.js — mnemonic generation, HD derivation, signing
index.js
styles/ — CSS (Tailwind)
content/content script injected into web pages
index.js — injects the provider into page context
inpage.js — the window.ethereum provider object
shared/ shared utilities
vault.js — encrypted storage via libsodium
constants.js — chain IDs, default RPC endpoints, ERC-20 ABI
manifest/
chrome.json — Manifest V3 for Chrome
firefox.json — Manifest V2 for Firefox
chrome.json — Manifest V3 for Chrome
firefox.json — Manifest V2/V3 for Firefox
```
### UI Design Philosophy
The UI is inspired by _Universal Paperclips_. It's deliberately minimal,
monochrome, fast, and includes once-popular usability affordances that seem to
have fallen out of fashion in modern UI design. Clickable things look clickable.
Things don't flash or spin or move around unnecessarily. This is a tool for
getting work done, not a toy.
This is designed for a normal audience. Basic familiarity with cryptocurrency
terms is required, but you need not be a programmer or software engineer to use
this wallet.
If you _are_ basically familiar with cryptocurrency terms, you should be able to
use all of the main features of this wallet without having to read the
documentation; i.e. we wish for the primary functionality to remain easily
discoverable.
The UI follows a "Universal Paperclips" aesthetic — a deliberately spartan,
almost brutalist approach. The guiding principle is that an unskilled,
non-technical person should be able to figure out how to use it without any
crypto knowledge.
#### Visual Style
- **Monochrome**: Black text on white background. Color is only used when and
where it is semantically meaningful and explicitly useful, such as error
messages, critical warnings, or address disambiguation. (Notable exception: we
use color dots, and identicons, to help a user easily distinguish addresses.)
- **Monochrome**: Black text on white background. No brand colors, no gradients,
no color-coding. Color may be introduced later for specific semantic purposes
(e.g. error states) but the baseline is monochrome.
- **Text-first**: Every piece of information is presented as text. Balances are
numbers. Addresses are hex strings. Flash messages are sentences. All fiddly
bits can be clicked to copy to the clipboard, and external links to Etherscan
are provided everywhere they might be useful.
numbers. Addresses are hex strings. Status is a sentence. No progress spinners
with animations — a text status line is sufficient.
- **Monospace font**: All text is rendered in the system monospace font.
Ethereum addresses, transaction hashes, and balances are inherently
fixed-width data. Rather than mixing proportional and monospace fonts, we use
monospace everywhere for visual consistency and alignment.
- **No images**: Zero image assets in the entire extension. No logos, no
illustrations, no token icons. Token identity is conveyed by symbol text (ETH,
USDC, etc.). We display
[Blockie identicons](https://github.com/MyCryptoHQ/ethereum-blockies-base64)
on critical screens and when space is available to allow users to disambiguate
addresses visually, as a security feature.
USDC, etc.).
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
styling. Tailwind is configured with a minimal monochrome palette. This keeps
the styling co-located with the markup and eliminates CSS file management.
@@ -136,19 +95,16 @@ discoverable.
would add bundle size, build complexity, and attack surface for no benefit at
this scale.
- **360x600 popup**: Standard browser extension popup dimensions. The UI is
designed for this fixed viewport.
designed for this fixed viewport — no responsive breakpoints needed.
#### No Layout Shift
Asynchronous state changes (clipboard confirmation, transaction status, error
messages, flash notifications, API results returning) must _never_ move around
the existing UI elements. All dynamic content areas must reserve their space up
front using `min-height` or always-present wrapper elements.
`visibility: hidden` is preferred over `display: none` when the element's space
must be preserved. This prevents jarring content jumps that disorient users and
can cause dangerous mis-clicks. Anyone who has multi-tabled on ClubGG and
smashed the big red "all-in blind preflop" button when trying to simply "call"
on a different table knows exactly tf I am talking about.
messages, flash notifications) must never move existing UI elements. All dynamic
content areas reserve their space up front using `min-height` or always-present
wrapper elements. `visibility: hidden` is preferred over `display: none` when
the element's space must be preserved. This prevents jarring content jumps that
disorient users and avoids mis-clicks caused by shifting buttons.
#### Clickable Affordance
@@ -162,40 +118,24 @@ click it.
#### Display Consistency
The same data must be formatted identically everywhere it appears. Token and ETH
amounts are displayed with exactly 4 decimal places (e.g. "1.0500 ETH", "17.1900
USDT") in balance lists, transaction lists, send confirmations, and approval
screens. Timestamps include both an ISO datetime and a humanized relative age
wherever shown. If a formatting rule applies in one place, it applies in every
place. Users should never see the same value rendered differently on two
screens.
**Specific Exception — Truncation:** On some non-critical display locations, we
may truncate _a small number_ of characters from the middle of an address solely
due to display size constraints. Wherever possible, and, notably, **in all
critical contexts (transaction confirmation view before signing, transaction
history detail view) addresses will _NEVER_ be truncated**. Even in places we
truncate addresses, we truncate only a maximum of 10 characters, which means
that the portions still displayed will be more than adequate for the user to
verify addresses even in the case of address spoofing attacks. Clicking an
address will always copy the full, untruncated value.
**Specific Exception — Transaction Detail view:** The transaction detail screen
is the authoritative record of a specific transaction and shows the exact,
untruncated amount with all meaningful decimal places (e.g. "0.00498824598498216
ETH"). It also shows the native quantity (e.g. "4988245984982160 wei") below it.
Both are click-copyable. Truncating to 4 decimals in summary views is acceptable
for scannability, but the detail view must never discard precision — it is the
one place the user can always use to verify exact details.
amounts are always displayed with exactly 4 decimal places (e.g. "1.0500 ETH",
"17.1900 USDT") in balance lists, transaction lists, transaction details, send
confirmations, and any other context. Timestamps include both an ISO datetime
and a humanized relative age wherever shown. If a formatting rule applies in one
place, it applies in every place. Users should never see the same value rendered
differently on two screens.
#### Language & Labeling
All user-facing text avoids unnecessary jargon wherever possible:
All user-facing text avoids crypto jargon wherever possible:
- "Recovery phrase" instead of "seed phrase", "mnemonic", or "BIP-39 mnemonic"
- "Address" instead of "account", "derived key", or "HD child"
- "Password" instead of "encryption key" or "vault passphrase"
- "Private key" instead of "secret key" or "signing key"
- Buttons use plain verbs: "Send", "Receive", "Copy address", "Add", "Back",
"Cancel", "Lock", "Unlock", "Allow", "Deny"
- No bracket notation like `[locked]` or `[setup]` — just plain titles
- Helpful inline descriptions where needed (e.g. "This password locks the wallet
on this device. It is not the same as your recovery phrase.")
- Error messages are full sentences ("Please enter your password." not "password
@@ -204,14 +144,14 @@ All user-facing text avoids unnecessary jargon wherever possible:
#### Full Identifiers Policy
Addresses, transaction hashes, contract addresses, and all other cryptographic
identifiers are displayed in full whenever possible. We truncate only in
specific, limited, non-critical places and even then only a small amount that
still prevents spoofing attacks. Address poisoning attacks exploit truncated
displays by generating fraud addresses that share the same prefix and suffix as
a legitimate address. If a user only sees `0xAbCd...1234`, an attacker can
create an address with the same visible characters and trick the user into
sending funds to it. Showing the complete identifier defeats this class of
attack.
identifiers are displayed in full whenever possible — never truncated. Address
poisoning attacks exploit truncated displays by generating fraud addresses that
share the same prefix and suffix as a legitimate address. If a user only sees
`0xAbCd...1234`, an attacker can create an address with the same visible
characters and trick the user into sending funds to it. Showing the complete
identifier defeats this class of attack. Truncation is only acceptable in
space-constrained contexts where the full identifier is accessible one tap away
(e.g. a tooltip or copy action).
#### Data Model
@@ -497,71 +437,37 @@ transitions.
#### TxApproval
- **When**: A connected website requests a transaction via
`eth_sendTransaction`. Opened via the toolbar popup by the background script.
`eth_sendTransaction`. Opened in a separate popup by the background script.
- **Elements**:
- "Transaction Request" heading
- 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
creation"), token symbol label if known
- To: color dot + full address + etherscan link (or "contract creation")
- Value: amount in ETH (4 decimal places)
- Raw data: full calldata displayed inline (shown if present)
- Data: raw transaction data (shown if present)
- Password input
- "Confirm" / "Reject" buttons
- **Transitions**:
- "Confirm" (with password) → closes popup (returns result to background)
- "Reject" → closes popup (returns rejection to background)
#### SignApproval
- **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.
- **Elements**:
- "Signature Request" heading
- Site hostname (bold) + "wants you to sign a message"
- 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
- "Sign" / "Reject" buttons
- **Transitions**:
- "Sign" (with password) → signs locally → closes popup (returns signature)
- "Reject" → closes popup (returns rejection to background)
### External Services
AutistMask is not a fully self-contained offline tool. It necessarily
communicates with three external services to function as a wallet:
communicates with external services to function as a wallet:
- **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query
balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`),
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
broadcast transactions (`eth_sendRawTransaction`), and check transaction
receipts. The default endpoint is a public RPC (configurable by the user to
any endpoint they prefer, including a local node). By default the extension
talks to `https://ethereum-rpc.publicnode.com`.
- **Data sent**: Ethereum addresses, transaction data, contract call
parameters. The RPC endpoint can see all on-chain queries and submitted
transactions.
any endpoint they prefer, including a local node). This is the only external
service the extension talks to.
- **CoinDesk CADLI price API**: Used to fetch ETH/USD and token/USD prices for
displaying fiat values. The price is cached for 5 minutes to avoid excessive
requests. No API key required. No user data is sent — only a list of token
symbols. Note that CoinDesk will receive your client IP.
- **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No
addresses or user-specific data.
- **Blockscout block-explorer API**: Used to fetch transaction history (normal
transactions and ERC-20 token transfers), ERC-20 token balances, and token
holder counts (for spam filtering). The default endpoint is
`https://eth.blockscout.com/api/v2` (configurable by the user in Settings).
- **Data sent**: Ethereum addresses. Blockscout receives the user's
addresses to query their transaction history and token balances. No
private keys, passwords, or signing operations are sent.
symbols.
What the extension does NOT do:
@@ -571,15 +477,14 @@ What the extension does NOT do:
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer
These three services (RPC endpoint, CoinDesk price API, and Blockscout API) are
the only external services. All three endpoints are user-configurable. Users who
want maximum privacy can point the RPC and Blockscout URLs at their own
self-hosted instances (price fetching can be disabled in a future version).
The user's RPC endpoint and the CoinDesk price API are the only external
services. Users who want maximum privacy can point the RPC at their own node
(price fetching can be disabled in a future version).
### Dependencies
AutistMask uses four runtime libraries. All cryptographic operations are
delegated to ethers and libsodium — see the Crypto Policy section below.
AutistMask uses two runtime libraries. All cryptographic operations are
delegated to these libraries — see the Crypto Policy section below.
| Package | Version | License | Purpose |
| -------------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -604,11 +509,17 @@ Dev dependencies (not shipped in extension):
`pbkdf`, `hmac`, `encrypt`, `decrypt`, `hash`, `cipher`, `digest`, `sign`
(case-insensitive) appear in our own source code (outside of `node_modules/`),
it is almost certainly a bug. All cryptographic operations must go through
`ethers` or `libsodium-wrappers-sumo`. Both are widely audited and
battle-tested.
`ethers` or `libsodium-wrappers-sumo`. This policy exists because:
- Rolling your own crypto is the single most common source of security
vulnerabilities in wallet software.
- Both libraries are widely audited and battle-tested.
- Keeping crypto out of application code makes security review tractable:
reviewers only need to verify that we call the libraries correctly, not that
we implemented crypto correctly.
Exceptions require explicit authorization in a code comment referencing this
policy, but as of now there are none.
policy.
### DEBUG Mode Policy
@@ -640,12 +551,12 @@ project owner.
- **The password is NOT used in address derivation.** It exists solely to
protect the recovery phrase / private key on disk. Anyone with the
recovery phrase can restore the wallet on any device without this
password. This matches standard EVM wallet behavior.
password. This matches MetaMask's behavior.
- **BIP-39 / BIP-44 via ethers.js**: Mnemonic generation, validation, and HD key
derivation (`m/44'/60'/0'/0/n`) are handled entirely by ethers.js. The BIP-39
passphrase is always empty (matching most wallet software). The user's
password is completely separate and has no effect on which addresses are
generated.
passphrase is always empty (matching MetaMask and most wallet software). The
user's password is completely separate and has no effect on which addresses
are generated.
- **ethers.js for everything Ethereum**: Transaction construction, signing, gas
estimation, RPC communication, ERC-20 contract calls, and address derivation
are all handled by ethers.js. This means zero hand-rolled Ethereum logic.
@@ -667,16 +578,13 @@ project owner.
- View ERC-20 token balances (user adds token by contract address)
- Send ETH 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)
- Connect to web3 sites (EIP-1193 `eth_requestAccounts`)
- Sign transactions requested by connected sites (`eth_sendTransaction`)
- Sign transactions requested by connected sites
- Sign messages (`personal_sign`, `eth_sign`)
- Sign typed data (`eth_signTypedData_v4`, `eth_signTypedData`)
- Human-readable transaction decoding (ERC-20, Uniswap Universal Router)
- ETH/USD and token/USD price display
- Configurable RPC endpoint and Blockscout API
- Address poisoning protection (spam token filtering, dust filtering, fraud
contract blocklist)
- Lock/unlock with password
- Configurable RPC endpoint
- Future: USD value display (and other fiat currencies)
### Address Poisoning and Fake Token Transfer Attacks
@@ -764,84 +672,104 @@ indexes it as a real token transfer.
designed as a sharp tool — users who understand the risks can configure the
wallet to show everything unfiltered, unix-style.
#### Transaction Decoding
### Non-Goals
When a dApp asks the user to approve a transaction, AutistMask attempts to
decode the calldata into a human-readable summary. This is purely a display
convenience to help the user understand what they are signing — it is not
endorsement, special treatment, or partnership with any protocol.
AutistMask is a generic web3 wallet. It treats all dApps, protocols, and
contracts equally. No contract gets special handling, priority, or integration
beyond what is needed to show the user a legible confirmation screen. Our
commitment is to the user, not to any service, site, or contract.
Decoded transaction summaries are best-effort. If decoding fails, the raw
calldata is displayed in full. The decoders live in self-contained modules under
`src/shared/` (e.g. `uniswap.js`) so they can be added for common contracts
without polluting wallet-specific code. Contributions of decoders for other
widely-used contracts are welcome.
Currently supported:
- **ERC-20**: `approve()` and `transfer()` calls — shows token symbol, spender
or recipient, and amount.
- **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g.
"Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks.
### Non-Goals Forever
- Built in token swaps (use a DEX in the browser)
- Token swaps (use a DEX in the browser)
- NFT display or management
- Multi-chain support (Ethereum mainnet only, for now)
- Analytics, telemetry, or tracking of any kind
- Advertisements or promotions
- Obscure token list auto-discovery (user adds tokens manually)
- We detect common/popular ERC20s in the basic case
- Phishing detection
- Hardware wallet support (maybe later)
- Token list auto-discovery (user adds tokens manually)
- Fiat on/off ramps
- Extensive transaction decoding/parsing
- For common ones we will do best-effort, but you should just use a block
explorer.
- Browser notifications
- Transaction history (use Etherscan)
### Non-Goals for 1.0
## TODO — 0.1.0 MVP
- Multi-chain support (Ethereum mainnet only)
- Hardware wallet support
Everything needed for a minimal working wallet that can send and receive ETH.
## TODO
### Done
- [x] Project scaffolding (Makefile, Dockerfile, CI, manifests, esbuild)
- [x] Tailwind CSS build pipeline
- [x] Popup UI shell with screen stacking (Welcome, AddWallet, Home,
AddressDetail, Send, Receive, Settings)
- [x] BIP-39 mnemonic generation via ethers.js (die button)
- [x] BIP-39 mnemonic validation on import
- [x] BIP-32/BIP-44 HD key derivation (real addresses from xpub)
- [x] Private key import (real address via ethers.Wallet)
- [x] Xpub stored unencrypted for read-only address derivation
- [x] State persistence to extension storage (survives popup close)
- [x] Live ETH balance fetching via JSON-RPC (`eth_getBalance`)
- [x] ENS reverse lookup (address → name) and forward resolution (name → address
in Send field)
- [x] ETH/USD price fetching via CoinDesk API
- [x] USD value display next to ETH balances
- [x] Full address display everywhere (no truncation)
- [x] Token list module with ~150 ERC-20 tokens ordered by market cap
### Wallet Management
- [ ] Rename wallets (tap wallet name on Home to edit)
- [ ] Rename addresses (tap address name on AddressDetail to edit)
- [ ] Delete wallet (with confirmation)
- [ ] Delete address from HD wallet (with confirmation)
- [ ] Show wallet's recovery phrase (requires password)
- [ ] Show wallet's recovery phrase (requires password, from Settings or wallet
context menu)
### Transactions
### Sending
- [x] Encrypt recovery phrase / private key with password via libsodium
(Argon2id + XSalsa20-Poly1305)
- [x] Password prompt on Send (decrypt private key to construct transaction)
- [x] Transaction construction via ethers.js (to, value, gasLimit, gasPrice)
- [ ] Gas estimation and fee display before confirming
- [x] Broadcast transaction via `eth_sendRawTransaction`
- [x] Transaction status feedback (pending → confirmed / failed)
### Receiving
- [x] QR code generation for address (qrcode library, renders to canvas)
### Display
- [ ] Home screen: total ETH balance summed across all addresses
- [ ] Home screen: total USD value (small text under total ETH)
- [ ] Cache ETH/USD price for 5 minutes (don't re-fetch on every popup open)
- [ ] Per-address USD value in small text under ETH balance everywhere
### Tokens (ERC-20)
- [ ] Add token by contract address (fetch name/symbol/decimals from contract)
- [ ] Display ERC-20 token balances per address
- [ ] Send ERC-20 tokens
### Testing
- [ ] Tests for mnemonic generation and address derivation
- [ ] Tests for xpub derivation and child address generation
- [ ] Tests for token list module (getTopTokenPrices, getTopTokenSymbols)
- [ ] Test on Chrome (Manifest V3)
- [ ] Test on Firefox (Manifest V2)
### Scam List
- [ ] Research and document each address in scamlist.js
- [ ] Add more known fraud addresses from Etherscan labels
- [ ] Research and document each address in scamlist.js (what it is, why it's on
the list, source)
- [ ] Add more known fraud addresses from Etherscan labels (drainers, phishing,
address poisoning deployers)
### Future
### Post-MVP
- [ ] EIP-1193 provider injection (window.ethereum) for web3 site connectivity
- [ ] Site connection approval flow
- [ ] Transaction signing approval flow (requests from connected sites)
- [ ] Message signing (`personal_sign`, `eth_sign`)
- [ ] Multi-currency fiat display (EUR, GBP, etc.)
- [ ] Security audit of key management
## Policies
- We don't mention "the other wallet" by name in code or documentation. We're
our own thing.
- The README is the complete authoritative technical documentation. It's ok if
it gets big.
## License
GPL-3.0. See [LICENSE](LICENSE).

121
RULES.md
View File

@@ -1,121 +0,0 @@
> **⚠️ THIS FILE MUST NEVER BE MODIFIED BY AGENTS.** RULES.md is maintained
> exclusively by the project owner. AI agents, bots, and automated tools must
> treat this file as read-only. If an audit finds a divergence between the code
> and this file, the code must be changed to match — never the other way around.
# AutistMask Rules Checklist
This file is derived from README.md and REPO_POLICIES.md for use as an audit
checklist. The authoritative policies are in those two files. If this file
contradicts either, the originals govern.
---
## Cryptography
- [ ] No raw crypto primitives in application code (`aes`, `sha`, `pbkdf`,
`hmac`, `encrypt`, `decrypt`, `hash`, `cipher`, `digest`, `sign`)
- [ ] All crypto goes through `ethers` or `libsodium-wrappers-sumo`
- [ ] No exceptions without an explicit code comment citing the Crypto Policy
- [ ] Secrets encrypted at rest with Argon2id + XSalsa20-Poly1305
- [ ] Password never used in address derivation (encryption only)
## External Communication
- [ ] Extension contacts exactly three external services: configured RPC
endpoint, CoinDesk price API, and Blockscout block-explorer API
- [ ] No analytics, telemetry, or tracking
- [ ] No user-specific data sent except to the configured RPC endpoint
- [ ] No Infura/Alchemy hard dependency
- [ ] No backend servers operated by the developer
- [ ] RPC endpoint is user-configurable (defaults to publicnode.com)
## Dependencies
- [ ] Four runtime libraries only: `ethers`, `libsodium-wrappers-sumo`,
`qrcode`, `ethereum-blockies-base64`
- [ ] No JS framework (React, Vue, Svelte, etc.)
- [ ] All external references pinned by cryptographic hash (per REPO_POLICIES)
## Address Display & Anti-Spoofing
- [ ] Addresses displayed in full in all critical contexts (tx confirmation,
send confirmation, transaction detail)
- [ ] `truncateMiddle()` removes at most 10 characters — enforced in code
- [ ] Caller floor for address truncation is 32 characters minimum
- [ ] Clicking any address copies the full untruncated value
- [ ] Known token symbol verification: transfers claiming a known symbol from an
unrecognized contract are filtered
- [ ] Tokens with < 1,000 holders hidden from tx history and send selector
- [ ] Dust transactions below configurable threshold hidden
- [ ] Fraud contract blocklist applied to tx history
## Display Consistency
- [ ] Token/ETH amounts: exactly 4 decimal places in all summary views
- [ ] Transaction detail view: exact untruncated amount (full precision)
- [ ] Transaction detail view: native quantity shown (wei / base units)
- [ ] Both amount and native quantity are click-copyable
- [ ] Timestamps: ISO datetime + relative age, everywhere they appear
- [ ] Same data formatted identically across all screens
## No Layout Shift
- [ ] All async-populated elements have `min-height` or placeholder content
- [ ] `formatUsd(null)` returns `""` — callers must use `&nbsp;` fallback
- [ ] `visibility: hidden` preferred over `display: none` when space must be
preserved
- [ ] No UI element moves when async data (prices, balances, tx lists) arrives
## Clickable Affordance
- [ ] Every button has visible border, padding, and hover state
- [ ] Every clickable text element has underline or dashed underline
- [ ] No invisible hit targets
## DEBUG Mode
- [ ] DEBUG mode only enables: red banner + hardcoded test mnemonic
- [ ] No `if (DEBUG)` branches that skip functionality or bypass security
- [ ] New DEBUG conditionals require explicit project owner approval
## Transaction Decoding
- [ ] Decoding is best-effort display convenience only
- [ ] No protocol gets special handling beyond the confirmation screen
- [ ] If decoding fails, raw calldata displayed in full (not truncated)
- [ ] Decoders are self-contained modules in `src/shared/`
## Approval Flow
- [ ] Site connection: explicit user approval via popup
- [ ] Transaction signing: password required, decoded details shown
- [ ] Message signing: password required, message content shown
- [ ] Typed data signing: password required, domain/type/message fields shown
- [ ] Rejected approvals return EIP-1193 error code 4001
- [ ] TX and sign approvals persist across popup close/reopen (toolbar popup)
## Secrets & Storage
- [ ] Public data (xpubs, addresses, balances) stored unencrypted
- [ ] Private data (recovery phrases, private keys) encrypted at rest
- [ ] Password only required for signing operations
- [ ] No secrets in `.env`, credentials, or API keys committed to repo
- [ ] `git add -A` / `git add .` never used
## Build & Workflow
- [ ] All tool invocations via `make` targets, never directly
- [ ] `make check` = `make test` + `make lint` + `make fmt-check`
- [ ] `main` always passes `make check`
- [ ] Feature branches for all changes, merge to main when done
- [ ] No force-push to `main`
- [ ] Pre-commit hook runs `make check`
## Language & Labeling
- [ ] "Recovery phrase" not "seed phrase" or "mnemonic"
- [ ] "Address" not "account" or "derived key"
- [ ] "Password" not "encryption key" or "vault passphrase"
- [ ] Error messages are full sentences
- [ ] No competitor mentioned by name in code or documentation

View File

@@ -30,6 +30,7 @@ const connectedSites = {};
// Pending approval requests: { id: { origin, hostname, resolve } }
const pendingApprovals = {};
let nextApprovalId = 1;
async function getState() {
const result = await storageApi.get("autistmask");
@@ -93,13 +94,11 @@ function resetPopupUrl() {
}
}
// Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals.
// Fallback: open approval in a separate window (used when openPopup is unavailable)
function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 360;
const popupHeight = 600;
const popupWidth = 400;
const popupHeight = 500;
windowsApi.getLastFocused((currentWin) => {
const opts = {
@@ -128,7 +127,7 @@ function openApprovalWindow(id) {
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
function requestApproval(origin, hostname) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
const id = nextApprovalId++;
pendingApprovals[id] = { origin, hostname, resolve };
if (actionApi && typeof actionApi.openPopup === "function") {
@@ -150,12 +149,9 @@ function requestApproval(origin, hostname) {
}
// Open a tx-approval popup and return a promise that resolves with txHash or error.
// Uses windows.create() directly because tx approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
function requestTxApproval(origin, hostname, txParams) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
const id = nextApprovalId++;
pendingApprovals[id] = {
origin,
hostname,
@@ -164,17 +160,30 @@ function requestTxApproval(origin, hostname, txParams) {
type: "tx",
};
openApprovalWindow(id);
if (actionApi && typeof actionApi.openPopup === "function") {
actionApi.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
try {
const result = actionApi.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => openApprovalWindow(id));
}
} catch {
openApprovalWindow(id);
}
} else {
openApprovalWindow(id);
}
});
}
// Open a sign-approval popup and return a promise that resolves with { signature } or { error }.
// Uses windows.create() directly because sign approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
// Uses the toolbar popup only — no fallback window. If openPopup() fails the
// popup URL is still set, so the user can click the toolbar icon to respond.
function requestSignApproval(origin, hostname, signParams) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
const id = nextApprovalId++;
pendingApprovals[id] = {
origin,
hostname,
@@ -183,25 +192,47 @@ function requestSignApproval(origin, hostname, signParams) {
type: "sign",
};
openApprovalWindow(id);
if (actionApi && typeof actionApi.setPopup === "function") {
actionApi.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
}
if (actionApi && typeof actionApi.openPopup === "function") {
try {
const result = actionApi.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => {});
}
} catch {
// openPopup unsupported — user clicks toolbar icon
}
}
});
}
// Detect when an approval popup (browser-action) closes without a response.
// TX and sign approvals now use windows.create() and are handled by the
// windowsApi.onRemoved listener below, but we still handle site-connection
// approval disconnects here.
// Sign approvals are NOT auto-rejected on disconnect because toolbar popups
// naturally close on focus loss and the user can reopen them.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1];
const id = parseInt(port.name.split(":")[1], 10);
port.onDisconnect.addListener(() => {
const approval = pendingApprovals[id];
if (approval) {
if (approval.type === "tx" || approval.type === "sign") {
if (approval.type === "sign") {
// Keep pending — user can reopen the toolbar popup
return;
}
approval.resolve({ approved: false, remember: false });
if (approval.type === "tx") {
approval.resolve({
error: {
code: 4001,
message: "User rejected the request.",
},
});
} else {
approval.resolve({ approved: false, remember: false });
}
delete pendingApprovals[id];
}
resetPopupUrl();
@@ -419,13 +450,6 @@ async function handleRpc(method, params, origin) {
? { method, message: params[0], from: params[1] }
: { method, message: params[1], from: params[0] };
if (method === "eth_sign") {
signParams.dangerWarning =
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
"This can be used to sign transactions that drain your funds. " +
"Only proceed if you fully understand what you are signing.";
}
const decision = await requestSignApproval(
origin,
hostname,
@@ -502,13 +526,7 @@ async function broadcastAccountsChanged() {
}
// Reject and close any pending approval popups so they don't hang
for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.type === "tx" || approval.type === "sign") {
approval.resolve({
error: { code: 4001, message: "User rejected the request." },
});
} else {
approval.resolve({ approved: false, remember: false });
}
approval.resolve({ approved: false, remember: false });
if (approval.windowId) {
windowsApi.remove(approval.windowId, () => {
if (runtime.lastError) {
@@ -595,39 +613,12 @@ if (windowsApi && windowsApi.onRemoved) {
// Listen for messages from content scripts and popup
runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_RPC") {
// Derive origin from trusted sender info to prevent origin spoofing.
// Chrome MV3 provides sender.origin; Firefox MV2 fallback uses sender.tab.url.
let trustedOrigin = msg.origin; // fallback only if sender info unavailable
if (sender.origin) {
trustedOrigin = sender.origin;
} else if (sender.tab && sender.tab.url) {
try {
trustedOrigin = new URL(sender.tab.url).origin;
} catch {
// keep fallback
}
}
handleRpc(msg.method, msg.params, trustedOrigin).then((response) => {
handleRpc(msg.method, msg.params, msg.origin).then((response) => {
sendResponse(response);
});
return true;
}
// Validate that popup-only messages originate from the extension itself.
const POPUP_ONLY_TYPES = [
"AUTISTMASK_GET_APPROVAL",
"AUTISTMASK_APPROVAL_RESPONSE",
"AUTISTMASK_TX_RESPONSE",
"AUTISTMASK_SIGN_RESPONSE",
];
if (POPUP_ONLY_TYPES.includes(msg.type)) {
const extUrl = runtime.getURL("");
if (!sender.url || !sender.url.startsWith(extUrl)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
}
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
const approval = pendingApprovals[msg.id];
if (approval) {
@@ -692,8 +683,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (wallet) break;
}
if (!wallet) throw new Error("Wallet not found");
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
let decrypted = await decryptWithPassword(
const decrypted = await decryptWithPassword(
wallet.encryptedSecret,
msg.password,
);
@@ -702,10 +692,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
addrIndex,
decrypted,
);
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decrypted = null;
const provider = getProvider(state.rpcUrl);
const connected = signer.connect(provider);
const tx = await connected.sendTransaction(approval.txParams);
@@ -751,8 +737,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (wallet) break;
}
if (!wallet) throw new Error("Wallet not found");
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
let decrypted = await decryptWithPassword(
const decrypted = await decryptWithPassword(
wallet.encryptedSecret,
msg.password,
);
@@ -761,10 +746,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
addrIndex,
decrypted,
);
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decrypted = null;
const sp = approval.signParams;
let signature;

View File

@@ -13,26 +13,6 @@ if (typeof browser !== "undefined") {
(document.head || document.documentElement).appendChild(script);
}
// Send the persisted EIP-6963 provider UUID to the inpage script.
// Generated once at install time and stored in chrome.storage.local.
(function sendProviderUuid() {
const storage =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
storage.get("eip6963Uuid", (items) => {
let uuid = items?.eip6963Uuid;
if (!uuid) {
uuid = crypto.randomUUID();
storage.set({ eip6963Uuid: uuid });
}
window.postMessage(
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
location.origin,
);
});
})();
// Relay requests from the page to the background script
window.addEventListener("message", (event) => {
if (event.source !== window) return;

View File

@@ -9,7 +9,7 @@
const pending = {};
// Listen for responses from the content script
window.addEventListener("message", function onUuid(event) {
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
const { id, result, error } = event.data;
@@ -24,7 +24,7 @@
});
// Listen for events pushed from the extension
window.addEventListener("message", function onUuid(event) {
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_EVENT") return;
const { eventName, data } = event.data;
@@ -134,7 +134,7 @@
// Some dApps (wagmi) check this to confirm MetaMask-like behavior
_metamask: {
isUnlocked() {
return Promise.resolve(provider.selectedAddress !== null);
return Promise.resolve(true);
},
},
};
@@ -155,38 +155,21 @@
"</svg>",
);
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
function buildProviderInfo() {
return {
uuid: providerUuid,
name: "AutistMask",
icon: ICON_SVG,
rdns: "berlin.sneak.autistmask",
};
}
const providerInfo = {
uuid: "f3c5b2a1-8d4e-4f6a-9c7b-1e2d3a4b5c6d",
name: "AutistMask",
icon: ICON_SVG,
rdns: "berlin.sneak.autistmask",
};
function announceProvider() {
window.dispatchEvent(
new CustomEvent("eip6963:announceProvider", {
detail: Object.freeze({
info: buildProviderInfo(),
provider,
}),
detail: Object.freeze({ info: providerInfo, provider }),
}),
);
}
// Listen for the persisted UUID from the content script
function onProviderUuid(event) {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_PROVIDER_UUID") return;
window.removeEventListener("message", onProviderUuid);
providerUuid = event.data.uuid;
announceProvider();
}
window.addEventListener("message", onProviderUuid);
window.addEventListener("eip6963:requestProvider", announceProvider);
announceProvider();
})();

View File

@@ -23,7 +23,7 @@
</h1>
<button
id="btn-settings"
class="border border-border text-fg cursor-pointer text-xl px-1 leading-none hover:bg-fg hover:text-bg"
class="bg-transparent border-none text-fg cursor-pointer text-2xl p-0 leading-none"
title="Settings"
>
&#9881;
@@ -274,15 +274,11 @@
</div>
<div
id="address-usd-total"
class="text-xs text-muted mb-3 text-right min-h-[1rem]"
>
&nbsp;
</div>
class="text-xs text-muted mb-3 text-right"
></div>
<!-- balances -->
<div class="border-b border-border-light pb-2 mb-2">
<div id="address-balances" class="min-h-[1.25rem]">
&nbsp;
</div>
<div id="address-balances"></div>
</div>
<!-- actions -->
@@ -305,26 +301,6 @@
>
+ Token
</button>
<div class="relative">
<button
id="btn-more-menu"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
aria-label="More actions"
>
&middot;&middot;&middot;
</button>
<div
id="more-menu-dropdown"
class="hidden absolute right-0 top-full mt-1 border border-border bg-bg z-50 whitespace-nowrap py-1"
>
<button
id="btn-export-privkey"
class="block w-full text-left px-4 py-1.5 text-xs font-light text-muted hover:bg-hover hover:text-fg cursor-pointer"
>
Export Private Key
</button>
</div>
</div>
</div>
<!-- transactions -->
@@ -338,60 +314,6 @@
</div>
</div>
<!-- ============ EXPORT PRIVATE KEY VIEW ============ -->
<div id="view-export-privkey" class="view hidden">
<button
id="btn-export-privkey-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<div
id="export-privkey-jazzicon"
class="flex justify-center mt-1 mb-3"
></div>
<h2 class="font-bold mb-1">Export Private Key</h2>
<p class="text-xs mb-1" id="export-privkey-title"></p>
<p class="text-xs mb-3">
<span id="export-privkey-dot"></span>
<span
id="export-privkey-address"
class="cursor-pointer"
title="Click to copy"
></span>
</p>
<p class="text-xs mb-3 text-muted">
Warning: anyone with this private key can access and
transfer all funds from this address. Never share it.
</p>
<div
id="export-privkey-flash"
class="text-xs mb-2 hidden"
></div>
<div id="export-privkey-password-section" class="mb-2">
<label class="block mb-1">Password</label>
<input
type="password"
id="export-privkey-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue"
/>
<button
id="btn-export-privkey-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
>
Reveal
</button>
</div>
<div id="export-privkey-result" class="hidden">
<div
id="export-privkey-value"
class="bg-danger-well rounded p-2 font-mono text-xs break-all cursor-pointer mb-1"
title="Click to copy"
></div>
</div>
</div>
<!-- ============ ADDRESS-TOKEN DETAIL VIEW ============ -->
<div id="view-address-token" class="view hidden">
<button
@@ -421,15 +343,11 @@
</div>
<div
id="address-token-usd-total"
class="text-xs text-muted mb-3 text-right min-h-[1rem]"
>
&nbsp;
</div>
class="text-xs text-muted mb-3 text-right"
></div>
<!-- single token balance -->
<div class="border-b border-border-light pb-2 mb-2">
<div id="address-token-balance" class="min-h-[1.25rem]">
&nbsp;
</div>
<div id="address-token-balance"></div>
</div>
<!-- actions -->
@@ -448,12 +366,6 @@
</button>
</div>
<!-- token contract details (ERC-20 only) -->
<div
id="address-token-contract-info"
class="bg-hover rounded-md mx-1 p-3 mb-3 text-xs hidden"
></div>
<!-- token-filtered transactions -->
<div class="mt-3">
<div class="border-b border-border pb-1 mb-1">
@@ -605,7 +517,6 @@
<!-- ============ TX SUCCESS ============ -->
<div id="view-success-tx" class="view hidden">
<h2 class="font-bold mb-2">Transaction Confirmed</h2>
<div id="success-tx-decoded" class="mb-3 hidden text-xs"></div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="success-tx-summary" class="font-bold"></div>
@@ -711,10 +622,9 @@
<div class="flex justify-center mb-3">
<canvas id="receive-qr"></canvas>
</div>
<div
class="border border-border p-2 break-all mb-3 text-xs cursor-pointer"
>
<span id="receive-address-block" class="select-all"></span>
<div class="border border-border p-2 break-all mb-3 text-xs">
<span id="receive-dot"></span>
<span id="receive-address" class="select-all"></span>
<span id="receive-etherscan-link"></span>
</div>
<button
@@ -784,7 +694,9 @@
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Wallets</h3>
<div id="settings-wallet-list" class="mb-2"></div>
<p class="text-xs text-muted mb-2">
Add a new wallet from a recovery phrase or private key.
</p>
<button
id="btn-main-add-wallet"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
@@ -793,21 +705,6 @@
</button>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Tracked Tokens</h3>
<p class="text-xs text-muted mb-2">
ERC-20 tokens whose balances are tracked across all
addresses.
</p>
<div id="settings-tracked-tokens"></div>
<button
id="btn-settings-add-token"
class="border border-border px-2 py-1 mt-2 hover:bg-fg hover:text-bg cursor-pointer"
>
+ Add token
</button>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Display</h3>
<label
@@ -919,108 +816,6 @@
</div>
</div>
<!-- ============ DELETE WALLET CONFIRM ============ -->
<div id="view-delete-wallet-confirm" class="view hidden">
<button
id="btn-delete-wallet-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-3">Delete Wallet</h2>
<p class="text-xs mb-3">
Deleting
<strong id="delete-wallet-name"></strong> is permanent. Any
funds will be unrecoverable without your recovery phrase.
</p>
<div
id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 hidden"
></div>
<div class="mb-2">
<label class="block mb-1">Password</label>
<input
type="password"
id="delete-wallet-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to confirm"
/>
</div>
<button
id="btn-delete-wallet-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Confirm Delete
</button>
</div>
<!-- ============ SETTINGS: ADD TOKEN ============ -->
<div id="view-settings-addtoken" class="view hidden">
<button
id="btn-settings-addtoken-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Add Token</h2>
<p class="text-xs text-muted mb-3">
Pick a common token or enter a contract address manually.
</p>
<!-- top 10 quick-pick buttons -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Top tokens:</label
>
<div
id="settings-addtoken-top10"
class="flex flex-wrap gap-1"
></div>
</div>
<!-- top 100 dropdown -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Or pick from top 100:</label
>
<select
id="settings-addtoken-select"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
>
<option value="">-- select --</option>
</select>
<button
id="btn-settings-addtoken-select"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add selected
</button>
</div>
<!-- manual contract address -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Or enter contract address:</label
>
<input
type="text"
id="settings-addtoken-address"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
<div
id="settings-addtoken-info"
class="text-xs text-muted mt-1 hidden"
></div>
<button
id="btn-settings-addtoken-manual"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
</div>
</div>
<!-- ============ TRANSACTION DETAIL ============ -->
<div id="view-transaction" class="view hidden">
<button
@@ -1029,13 +824,7 @@
>
&lt; Back
</button>
<h2 id="tx-detail-heading" class="font-bold mb-2">
Transaction
</h2>
<div id="tx-detail-type-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Type</div>
<div id="tx-detail-type" class="text-xs font-bold"></div>
</div>
<h2 class="font-bold mb-2">Transaction</h2>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Status</div>
<div id="tx-detail-status" class="text-xs"></div>
@@ -1046,11 +835,7 @@
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="tx-detail-value" class="text-xs"></div>
</div>
<div class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Native quantity</div>
<div id="tx-detail-native" class="text-xs"></div>
<div id="tx-detail-value" class="text-xs font-bold"></div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">From</div>
@@ -1060,33 +845,10 @@
<div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div>
</div>
<div id="tx-detail-calldata-section" class="mb-4 hidden">
<div
id="tx-detail-calldata-well"
class="mb-3 border border-border border-dashed p-2"
>
<div class="text-xs text-muted mb-1">Action</div>
<div
id="tx-detail-calldata-action"
class="text-xs font-bold mb-2"
></div>
<div
id="tx-detail-calldata-details"
class="text-xs"
></div>
</div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="tx-detail-hash" class="text-xs break-all"></div>
</div>
<div id="tx-detail-rawdata-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="tx-detail-rawdata"
class="text-xs break-all font-mono border border-border border-dashed p-2"
></div>
</div>
</div>
<!-- ============ TRANSACTION APPROVAL ============ -->
@@ -1124,7 +886,11 @@
</div>
<div id="approve-tx-data-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Raw data</div>
<div id="approve-tx-data" class="text-xs break-all"></div>
<div
id="approve-tx-data"
class="text-xs break-all"
style="max-height: 4rem; overflow-y: auto"
></div>
</div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
@@ -1159,17 +925,6 @@
wants you to sign a message.
</p>
<div
id="approve-sign-danger-warning"
class="hidden mb-3 p-2 text-xs font-bold"
style="
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
border-radius: 6px;
"
></div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Type</div>
<div id="approve-sign-type" class="text-xs font-bold"></div>

View File

@@ -20,7 +20,6 @@ const transactionDetail = require("./views/transactionDetail");
const receive = require("./views/receive");
const addToken = require("./views/addToken");
const settings = require("./views/settings");
const settingsAddToken = require("./views/settingsAddToken");
const approval = require("./views/approval");
function renderWalletList() {
@@ -61,8 +60,6 @@ const ctx = {
showConfirmTx: (txInfo) => confirmTx.show(txInfo),
showReceive: () => receive.show(),
showTransactionDetail: (tx) => transactionDetail.show(tx),
showSettingsView: () => settings.show(),
showSettingsAddTokenView: () => settingsAddToken.show(),
};
// Views that can be fully re-rendered from persisted state.
@@ -73,9 +70,7 @@ const RESTORABLE_VIEWS = new Set([
"address-token",
"receive",
"settings",
"settings-addtoken",
"transaction",
"confirm-tx",
"success-tx",
"error-tx",
]);
@@ -125,9 +120,6 @@ function restoreView() {
case "settings":
settings.show();
break;
case "settings-addtoken":
settingsAddToken.show();
break;
case "transaction":
if (state.viewData && state.viewData.tx) {
transactionDetail.render();
@@ -135,13 +127,6 @@ function restoreView() {
fallbackView();
}
break;
case "confirm-tx":
if (state.viewData && state.viewData.pendingTx) {
confirmTx.show(state.viewData.pendingTx);
} else {
fallbackView();
}
break;
case "success-tx":
if (state.viewData && state.viewData.hash) {
txStatus.renderSuccess();
@@ -197,7 +182,7 @@ async function init() {
const params = new URLSearchParams(window.location.search);
const approvalId = params.get("approval");
if (approvalId) {
approval.show(approvalId);
approval.show(parseInt(approvalId, 10));
showView("approve-site");
return;
}
@@ -227,7 +212,6 @@ async function init() {
receive.init(ctx);
addToken.init(ctx);
settings.init(ctx);
settingsAddToken.init(ctx);
if (!state.hasWallet) {
showView("welcome");

View File

@@ -11,7 +11,6 @@
--color-border-light: #cccccc;
--color-hover: #eeeeee;
--color-well: #f5f5f5;
--color-danger-well: #fef2f2;
--color-section: #dddddd;
}

View File

@@ -49,8 +49,8 @@ function init(ctx) {
showFlash("Please choose a password.");
return;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
if (pw.length < 8) {
showFlash("Password must be at least 8 characters.");
return;
}
if (pw !== pw2) {

View File

@@ -4,7 +4,6 @@ const {
showFlash,
balanceLinesForAddress,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
} = require("./helpers");
@@ -18,8 +17,6 @@ const { resolveEnsNames } = require("../../shared/ens");
const { updateSendBalance, renderSendTokenSelect } = require("./send");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
let ctx;
@@ -57,8 +54,7 @@ function show() {
const addrLink = etherscanAddressLink(addr.address);
$("address-etherscan-link").innerHTML =
`<a href="${addrLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const usdTotal = formatUsd(getAddressValueUsd(addr));
$("address-usd-total").innerHTML = usdTotal || "&nbsp;";
$("address-usd-total").textContent = formatUsd(getAddressValueUsd(addr));
const ensEl = $("address-ens");
if (addr.ensName) {
ensEl.innerHTML =
@@ -153,11 +149,11 @@ async function loadTransactions(address) {
loadedTxs = txs;
// Collect ALL unique addresses (from + to) for ENS resolution so
// that reverse lookups work for every displayed address, not just
// the ones that were originally entered as ENS names.
// Collect unique counterparty addresses for ENS resolution.
const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
...new Set(
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
),
];
if (counterparties.length > 0) {
try {
@@ -188,23 +184,14 @@ function renderTransactions(txs) {
let html = "";
let i = 0;
for (const tx of txs) {
// For swap transactions, show the user's own labelled wallet
// address instead of the contract address (see issue #55).
const counterparty =
tx.direction === "contract" && tx.directionLabel === "Swap"
? tx.from
: tx.direction === "sent" || tx.direction === "contract"
? tx.to
: tx.from;
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets);
const dirLabel = tx.directionLabel;
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const maxAddr = Math.max(10, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
@@ -267,102 +254,6 @@ function init(_ctx) {
});
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
// More menu dropdown
const moreBtn = $("btn-more-menu");
const moreDropdown = $("more-menu-dropdown");
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = !moreDropdown.classList.toggle("hidden");
moreBtn.classList.toggle("bg-fg", isOpen);
moreBtn.classList.toggle("text-bg", isOpen);
});
document.addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
});
moreDropdown.addEventListener("click", (e) => {
e.stopPropagation();
});
$("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];
const blockieEl = $("export-privkey-jazzicon");
blockieEl.innerHTML = "";
const bImg = document.createElement("img");
bImg.src = makeBlockie(addr.address);
bImg.width = 48;
bImg.height = 48;
bImg.style.imageRendering = "pixelated";
bImg.style.borderRadius = "50%";
blockieEl.appendChild(bImg);
$("export-privkey-title").textContent =
wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
$("export-privkey-dot").innerHTML = addressDotHtml(addr.address);
$("export-privkey-address").textContent = addr.address;
$("export-privkey-address").dataset.full = addr.address;
$("export-privkey-password").value = "";
$("export-privkey-flash").classList.add("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-result").classList.add("hidden");
$("export-privkey-value").textContent = "";
showView("export-privkey");
});
$("btn-export-privkey-confirm").addEventListener("click", async () => {
const password = $("export-privkey-password").value;
if (!password) {
$("export-privkey-flash").textContent = "Password is required.";
$("export-privkey-flash").classList.remove("hidden");
return;
}
const wallet = state.wallets[state.selectedWallet];
try {
const secret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
const signer = getSignerForAddress(
wallet,
state.selectedAddress,
secret,
);
const privateKey = signer.privateKey;
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = privateKey;
$("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").classList.add("hidden");
} catch {
$("export-privkey-flash").textContent = "Wrong password.";
$("export-privkey-flash").classList.remove("hidden");
}
});
$("export-privkey-value").addEventListener("click", () => {
const key = $("export-privkey-value").textContent;
if (key) {
navigator.clipboard.writeText(key);
showFlash("Copied!");
}
});
$("export-privkey-address").addEventListener("click", () => {
const full = $("export-privkey-address").dataset.full;
if (full) {
navigator.clipboard.writeText(full);
showFlash("Copied!");
}
});
$("btn-export-privkey-back").addEventListener("click", () => {
$("export-privkey-value").textContent = "";
$("export-privkey-password").value = "";
show();
});
}
module.exports = { init, show };

View File

@@ -6,13 +6,11 @@ const {
showView,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
balanceLine,
} = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const {
formatUsd,
getPrice,
@@ -87,7 +85,6 @@ function show() {
// Determine token symbol and balance
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") {
symbol = "ETH";
amount = parseFloat(addr.balance || "0");
@@ -96,11 +93,7 @@ function show() {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
symbol = resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
);
symbol = tb ? tb.symbol : "?";
amount = tb ? parseFloat(tb.balance || "0") : 0;
price = getPrice(symbol);
}
@@ -131,68 +124,11 @@ function show() {
// USD total for this token only
const usdVal = price ? amount * price : 0;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = usdStr || "&nbsp;";
$("address-token-usd-total").textContent = formatUsd(usdVal);
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
// Token contract details (ERC-20 only)
const contractInfo = $("address-token-contract-info");
if (tokenId !== "ETH") {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
const tracked = (state.trackedTokens || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
const rawName =
(tb && tb.name) ||
(tracked && tracked.name) ||
(knownToken && knownToken.name) ||
null;
const rawSymbol =
(tb && tb.symbol) ||
(tracked && tracked.symbol) ||
(knownToken && knownToken.symbol) ||
null;
const tokenName = rawName ? escapeHtml(rawName) : null;
const tokenSymbol = rawSymbol ? escapeHtml(rawSymbol) : null;
const tokenDecimals =
tb && tb.decimals != null
? tb.decimals
: tracked && tracked.decimals != null
? tracked.decimals
: knownToken && knownToken.decimals != null
? knownToken.decimals
: null;
const tokenHolders = tb && tb.holders != null ? tb.holders : null;
const dot = addressDotHtml(tokenId);
const tokenLink = `https://etherscan.io/token/${escapeHtml(tokenId)}`;
const projectUrl = knownToken && knownToken.url ? knownToken.url : null;
let infoHtml = `<div class="font-bold mb-2">Contract Address</div>`;
infoHtml +=
`<div class="flex items-center mb-2">${dot}` +
`<span class="break-all underline decoration-dashed cursor-pointer" id="address-token-contract-copy" data-copy="${escapeHtml(tokenId)}">${escapeHtml(tokenId)}</span>` +
`<a href="${tokenLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
`</div>`;
if (tokenName)
infoHtml += `<div class="mb-1"><span class="text-muted">Name:</span> ${tokenName}</div>`;
if (tokenSymbol)
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
if (tokenDecimals != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
if (tokenHolders != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`;
if (projectUrl)
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
contractInfo.innerHTML = infoHtml;
contractInfo.classList.remove("hidden");
} else {
contractInfo.innerHTML = "";
contractInfo.classList.add("hidden");
}
// Transactions
$("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
@@ -238,10 +174,11 @@ async function loadTransactions(address, tokenId) {
loadedTxs = txs;
// Collect ALL unique addresses for ENS resolution so reverse
// lookups work for every displayed address.
// Collect unique counterparty addresses for ENS resolution
const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
...new Set(
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
),
];
if (counterparties.length > 0) {
try {
@@ -274,14 +211,12 @@ function renderTransactions(txs) {
for (const tx of txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets);
const dirLabel = tx.directionLabel;
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const maxAddr = Math.max(10, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
@@ -316,14 +251,6 @@ function init(_ctx) {
}
});
$("address-token-contract-info").addEventListener("click", (e) => {
const copyEl = e.target.closest("[data-copy]");
if (copyEl) {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
}
});
$("btn-address-token-back").addEventListener("click", () => {
ctx.showAddressDetail();
});

View File

@@ -1,16 +1,9 @@
const {
$,
addressDotHtml,
addressTitle,
escapeHtml,
showView,
} = require("./helpers");
const { $, addressDotHtml, escapeHtml, showView } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
@@ -28,15 +21,7 @@ function approvalAddressHtml(address) {
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(address, state.wallets);
let html = "";
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
html += `<div class="break-all">${escapeHtml(address)}${extLink}</div>`;
} else {
html += `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
}
return html;
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
}
function formatTxValue(val) {
@@ -55,102 +40,81 @@ function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
// Try to decode calldata using known ABIs.
// Try to decode calldata using the ERC-20 ABI.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) {
if (!data || data === "0x" || data.length < 10) return null;
// Try ERC-20 (approve / transfer)
try {
const parsed = erc20Iface.parseTransaction({ data });
if (parsed) {
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null;
const tokenDecimals = token ? token.decimals : 18;
const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
if (!parsed) return null;
if (parsed.name === "approve") {
const spender = parsed.args[0];
const rawAmount = parsed.args[1];
const maxUint = BigInt(
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = rawAmount === maxUint;
const amountRaw = isUnlimited
? "Unlimited"
: formatTxValue(formatUnits(rawAmount, tokenDecimals));
const amountStr = isUnlimited
? "Unlimited"
: amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null;
const tokenDecimals = token ? token.decimals : 18;
const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
return {
name: "Token Approval",
description: tokenSymbol
? "Approve spending of your " + tokenSymbol
: "Approve spending of an ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{
label: "Spender",
value: spender,
address: spender,
},
{
label: "Amount",
value: amountStr,
rawValue: amountRaw,
},
],
};
}
if (parsed.name === "approve") {
const spender = parsed.args[0];
const rawAmount = parsed.args[1];
const maxUint = BigInt(
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = rawAmount === maxUint;
const amountStr = isUnlimited
? "Unlimited"
: formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
(tokenSymbol ? " " + tokenSymbol : "");
if (parsed.name === "transfer") {
const to = parsed.args[0];
const rawAmount = parsed.args[1];
const amountRaw = formatTxValue(
formatUnits(rawAmount, tokenDecimals),
);
const amountStr =
amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Transfer",
description: tokenSymbol
? "Transfer " + tokenSymbol
: "Transfer ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{ label: "Recipient", value: to, address: to },
{
label: "Amount",
value: amountStr,
rawValue: amountRaw,
},
],
};
}
return {
name: "Token Approval",
description: tokenSymbol
? "Approve spending of your " + tokenSymbol
: "Approve spending of an ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{ label: "Spender", value: spender, address: spender },
{ label: "Amount", value: amountStr },
],
};
}
if (parsed.name === "transfer") {
const to = parsed.args[0];
const rawAmount = parsed.args[1];
const amountStr =
formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
(tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Transfer",
description: tokenSymbol
? "Transfer " + tokenSymbol
: "Transfer ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{ label: "Recipient", value: to, address: to },
{ label: "Amount", value: amountStr },
],
};
}
return null;
} catch {
// Not ERC-20 — fall through
return null;
}
// Try Uniswap Universal Router
const routerResult = uniswap.decode(data, toAddress);
if (routerResult) return routerResult;
return null;
}
function showTxApproval(details) {
@@ -175,7 +139,7 @@ function showTxApproval(details) {
pendingTxDetails.to = d.address;
}
if (d.label === "Amount") {
pendingTxDetails.amount = d.rawValue || d.value;
pendingTxDetails.amount = d.value;
}
}
if (token) {
@@ -184,15 +148,6 @@ function showTxApproval(details) {
}
}
// Carry decoded calldata info through to success/error views
if (decoded) {
pendingTxDetails.decoded = {
name: decoded.name,
description: decoded.description,
details: decoded.details,
};
}
$("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-from").innerHTML = approvalAddressHtml(state.activeAddress);
@@ -329,18 +284,6 @@ function showSignApproval(details) {
}
}
// Display danger warning for eth_sign (raw hash signing)
const warningEl = $("approve-sign-danger-warning");
if (warningEl) {
if (sp.dangerWarning) {
warningEl.textContent = sp.dangerWarning;
warningEl.classList.remove("hidden");
} else {
warningEl.textContent = "";
warningEl.classList.add("hidden");
}
}
$("approve-sign-password").value = "";
$("approve-sign-error").classList.add("hidden");
$("btn-approve-sign").disabled = false;
@@ -420,7 +363,6 @@ function init(ctx) {
type: "AUTISTMASK_TX_RESPONSE",
id: approvalId,
approved: true,
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
password: password,
},
(response) => {
@@ -460,7 +402,6 @@ function init(ctx) {
type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId,
approved: true,
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
password: password,
},
(response) => {
@@ -488,4 +429,4 @@ function init(ctx) {
});
}
module.exports = { init, show, decodeCalldata };
module.exports = { init, show };

View File

@@ -18,7 +18,7 @@ const {
addressDotHtml,
escapeHtml,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
@@ -219,10 +219,6 @@ function show(txInfo) {
$("confirm-fee-amount").textContent = "Estimating...";
showView("confirm-tx");
// Persist txInfo so the view survives popup close/reopen
state.viewData = { pendingTx: txInfo };
saveState();
estimateGas(txInfo);
}
@@ -338,13 +334,8 @@ function init(ctx) {
tx = await contract.transfer(pendingTx.to, amount);
}
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decryptedSecret = null;
txStatus.showWait(pendingTx, tx.hash);
} catch (e) {
decryptedSecret = null;
const hash = tx ? tx.hash : null;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
}

View File

@@ -1,90 +0,0 @@
const { $, showView, showFlash } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
let deleteWalletIndex = null;
let ctx = null;
function show(walletIdx) {
deleteWalletIndex = walletIdx;
const wallet = state.wallets[walletIdx];
$("delete-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
$("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").classList.add("hidden");
showView("delete-wallet-confirm");
}
function init(_ctx) {
ctx = _ctx;
$("btn-delete-wallet-back").addEventListener("click", () => {
deleteWalletIndex = null;
ctx.showSettingsView();
});
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value;
if (!pw) {
$("delete-wallet-flash").textContent =
"Please enter your password.";
$("delete-wallet-flash").classList.remove("hidden");
return;
}
if (deleteWalletIndex === null) {
$("delete-wallet-flash").textContent =
"No wallet selected for deletion.";
$("delete-wallet-flash").classList.remove("hidden");
return;
}
const walletIdx = deleteWalletIndex;
const wallet = state.wallets[walletIdx];
// Verify password against the wallet's encrypted data
try {
await decryptWithPassword(wallet.encryptedSecret, pw);
} catch (_e) {
$("delete-wallet-flash").textContent = "Wrong password.";
$("delete-wallet-flash").classList.remove("hidden");
return;
}
// Collect addresses to clean up from allowedSites/deniedSites
const addresses = (wallet.addresses || []).map((a) => a.address);
// Remove wallet
state.wallets.splice(walletIdx, 1);
// Clean up site permissions for deleted addresses
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
deleteWalletIndex = null;
if (state.wallets.length === 0) {
// No wallets left — reset selection and show welcome
state.selectedWallet = null;
state.selectedAddress = null;
state.activeAddress = null;
await saveState();
showView("welcome");
} else {
// Switch to first wallet if deleted wallet was active
state.selectedWallet = 0;
state.selectedAddress = 0;
state.activeAddress =
state.wallets[0].addresses[0]?.address || null;
await saveState();
ctx.renderWalletList();
ctx.showSettingsView();
showFlash("Wallet deleted.");
}
});
}
module.exports = { init, show };

View File

@@ -8,8 +8,6 @@ const {
} = require("../../shared/prices");
const { state, saveState } = require("../../shared/state");
// When views are added, removed, or transitions between them change,
// update the view-navigation documentation in README.md to match.
const VIEWS = [
"welcome",
"add-wallet",
@@ -25,13 +23,10 @@ const VIEWS = [
"receive",
"add-token",
"settings",
"delete-wallet-confirm",
"settings-addtoken",
"transaction",
"approve-site",
"approve-tx",
"approve-sign",
"export-privkey",
];
function $(id) {
@@ -87,7 +82,7 @@ function showFlash(msg, duration = 2000) {
function balanceLine(symbol, amount, price, tokenId) {
const qty = amount.toFixed(4);
const usd = price ? formatUsd(amount * price) || "&nbsp;" : "&nbsp;";
const usd = price ? formatUsd(amount * price) : "";
const tokenAttr = tokenId ? ` data-token="${tokenId}"` : "";
const clickClass = tokenId
? " cursor-pointer hover:bg-hover balance-row"
@@ -136,18 +131,9 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
return html;
}
// Truncate the middle of a string, replacing removed characters with "…".
// Safety: refuses to truncate more than 10 characters, which is the maximum
// that still prevents address spoofing attacks (see Display Consistency in
// README). Callers that need to display less should use a different UI
// approach rather than silently making addresses insecure.
function truncateMiddle(str, maxLen) {
if (str.length <= maxLen) return str;
const removed = str.length - maxLen + 1; // +1 for the ellipsis char
if (removed > 10) {
maxLen = str.length - 10 + 1;
}
if (maxLen >= str.length) return str;
if (maxLen < 5) return str.slice(0, maxLen);
const half = Math.floor((maxLen - 1) / 2);
return str.slice(0, half) + "\u2026" + str.slice(-(maxLen - 1 - half));
}
@@ -224,41 +210,6 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(displayAddr)}</span></div>`;
}
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
module.exports = {
$,
showError,
@@ -273,6 +224,4 @@ module.exports = {
addressTitle,
formatAddressHtml,
truncateMiddle,
isoDate,
timeAgo,
};

View File

@@ -3,10 +3,7 @@ const {
showView,
showFlash,
balanceLinesForAddress,
isoDate,
timeAgo,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
} = require("./helpers");
@@ -41,15 +38,13 @@ function renderTotalValue() {
const ethPrice = getPrice("ETH");
if (priceEl) {
priceEl.innerHTML = ethPrice
? formatUsd(ethPrice) + " USD/ETH"
: "&nbsp;";
priceEl.textContent = ethPrice ? formatUsd(ethPrice) + " USD/ETH" : "";
}
const addr = findActiveAddr();
if (!addr) {
el.innerHTML = "&nbsp;";
if (subEl) subEl.innerHTML = "&nbsp;";
el.textContent = "";
if (subEl) subEl.textContent = "";
return;
}
const ethBal = parseFloat(addr.balance || "0");
@@ -59,8 +54,8 @@ function renderTotalValue() {
if (subEl) {
const totalUsd = getAddressValueUsd(addr);
subEl.innerHTML =
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : "&nbsp;";
subEl.textContent =
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : "";
}
}
@@ -90,6 +85,41 @@ function renderActiveAddress() {
}
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
);
}
let homeTxs = [];
function renderHomeTxList(ctx) {
@@ -103,22 +133,13 @@ function renderHomeTxList(ctx) {
let html = "";
let i = 0;
for (const tx of homeTxs) {
// For swap transactions, show the user's own labelled wallet
// address (the one that initiated the swap) instead of the
// contract address which is not useful in the list view.
const counterparty =
tx.direction === "contract" && tx.directionLabel === "Swap"
? tx.from
: tx.direction === "sent" || tx.direction === "contract"
? tx.to
: tx.from;
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const dirLabel = tx.directionLabel;
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
const title = addressTitle(counterparty, state.wallets);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
const maxAddr = Math.max(10, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
@@ -255,7 +276,7 @@ function render(ctx) {
html += `<span class="flex-shrink-0 ml-1">${infoBtn}</span>`;
html += `</div>`;
const addrUsd = formatUsd(getAddressValueUsd(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || "&nbsp;"}</div>`;
html += `<div class="text-xs text-muted text-right">${addrUsd}</div>`;
html += balanceLinesForAddress(
addr,
state.trackedTokens,

View File

@@ -30,8 +30,8 @@ function init(ctx) {
showFlash("Please choose a password.");
return;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
if (pw.length < 8) {
showFlash("Password must be at least 8 characters.");
return;
}
if (pw !== pw2) {

View File

@@ -1,10 +1,4 @@
const {
$,
showView,
showFlash,
formatAddressHtml,
addressTitle,
} = require("./helpers");
const { $, showView, showFlash, addressDotHtml } = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
const QRCode = require("qrcode");
@@ -18,12 +12,8 @@ const EXT_ICON =
function show() {
const addr = currentAddress();
const address = addr ? addr.address : "";
const title = address ? addressTitle(address, state.wallets) : null;
const ensName = addr ? addr.ensName || null : null;
$("receive-address-block").innerHTML = address
? formatAddressHtml(address, ensName, null, title)
: "";
$("receive-address-block").dataset.full = address;
$("receive-dot").innerHTML = address ? addressDotHtml(address) : "";
$("receive-address").textContent = address;
const link = address ? `https://etherscan.io/address/${address}` : "";
$("receive-etherscan-link").innerHTML = link
? `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`
@@ -60,16 +50,8 @@ function show() {
}
function init(ctx) {
$("receive-address-block").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
}
});
$("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full;
const addr = $("receive-address").textContent;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");

View File

@@ -1,16 +1,10 @@
// Send view: collect To, Amount, Token. Then go to confirmation.
const {
$,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
} = require("./helpers");
const { $, showFlash, addressDotHtml, escapeHtml } = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
let ctx;
const { getProvider } = require("../../shared/balances");
const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
const { KNOWN_SYMBOLS } = require("../../shared/tokenList");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
@@ -50,15 +44,8 @@ function updateSendBalance() {
const dot = addressDotHtml(addr.address);
const link = `https://etherscan.io/address/${addr.address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(addr.address, state.wallets);
let fromHtml = "";
if (title) {
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
if (addr.ensName) {
fromHtml += `<div>${escapeHtml(addr.ensName)}</div>`;
}
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else if (addr.ensName) {
if (addr.ensName) {
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(addr.ensName)}</div>`;
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else {
@@ -73,11 +60,7 @@ function updateSendBalance() {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const symbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
const symbol = tb ? tb.symbol : "?";
const bal = tb ? tb.balance || "0" : "0";
$("send-balance").textContent =
"Current balance: " + bal + " " + symbol;
@@ -128,11 +111,7 @@ function init(_ctx) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
tokenSymbol = tb ? tb.symbol : "?";
tokenBalance = tb ? tb.balance || "0" : "0";
}

View File

@@ -1,8 +1,7 @@
const { $, showView, showFlash, escapeHtml } = require("./helpers");
const { $, showView, showFlash } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
const { log, debugFetch } = require("../../shared/log");
const deleteWallet = require("./deleteWallet");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
@@ -39,95 +38,10 @@ function renderSiteList(containerId, siteMap, stateKey) {
});
}
function renderTrackedTokens() {
const container = $("settings-tracked-tokens");
if (state.trackedTokens.length === 0) {
container.innerHTML = '<p class="text-xs text-muted">None</p>';
return;
}
let html = "";
state.trackedTokens.forEach((token, idx) => {
const label = token.name
? escapeHtml(token.name) + " (" + escapeHtml(token.symbol) + ")"
: escapeHtml(token.symbol);
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span>${label}</span>`;
html += `<button class="btn-remove-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
html += `</div>`;
});
container.innerHTML = html;
container.querySelectorAll(".btn-remove-token").forEach((btn) => {
btn.addEventListener("click", async () => {
const idx = parseInt(btn.dataset.idx, 10);
state.trackedTokens.splice(idx, 1);
await saveState();
renderTrackedTokens();
});
});
}
function renderWalletListSettings() {
const container = $("settings-wallet-list");
if (state.wallets.length === 0) {
container.innerHTML = '<p class="text-xs text-muted">No wallets.</p>';
return;
}
let html = "";
state.wallets.forEach((wallet, idx) => {
const name = escapeHtml(wallet.name || "Wallet " + (idx + 1));
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span class="settings-wallet-name cursor-pointer underline decoration-dashed" data-idx="${idx}">${name}</span>`;
html += `<button class="btn-delete-wallet border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
html += `</div>`;
});
container.innerHTML = html;
container.querySelectorAll(".btn-delete-wallet").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.idx, 10);
deleteWallet.show(idx);
});
});
// Inline rename on click
container.querySelectorAll(".settings-wallet-name").forEach((span) => {
span.addEventListener("click", () => {
const idx = parseInt(span.dataset.idx, 10);
const wallet = state.wallets[idx];
const input = document.createElement("input");
input.type = "text";
input.className =
"border border-border p-0 text-xs bg-bg text-fg w-full";
input.value = wallet.name || "Wallet " + (idx + 1);
span.replaceWith(input);
input.focus();
input.select();
const finish = async () => {
const val = input.value.trim();
if (val && val !== wallet.name) {
wallet.name = val;
await saveState();
}
renderWalletListSettings();
};
input.addEventListener("blur", finish);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") input.blur();
if (e.key === "Escape") {
input.value = wallet.name || "Wallet " + (idx + 1);
input.blur();
}
});
});
});
}
function show() {
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
renderSiteLists();
renderWalletListSettings();
showView("settings");
}
@@ -141,8 +55,6 @@ function renderSiteLists() {
}
function init(ctx) {
deleteWallet.init(ctx);
$("btn-save-rpc").addEventListener("click", async () => {
const url = $("settings-rpc").value.trim();
if (!url) {
@@ -243,11 +155,6 @@ function init(ctx) {
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
$("btn-settings-add-token").addEventListener(
"click",
ctx.showSettingsAddTokenView,
);
$("btn-settings-back").addEventListener("click", () => {
ctx.renderWalletList();
showView("main");

View File

@@ -1,159 +0,0 @@
const { $, showView, showFlash } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log");
let ctx;
function isTracked(address) {
const lower = address.toLowerCase();
return state.trackedTokens.some((t) => t.address.toLowerCase() === lower);
}
function tokenLabel(t) {
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
}
function renderTop10() {
const el = $("settings-addtoken-top10");
el.innerHTML = getTopTokens(10)
.map((t) => {
const tracked = isTracked(t.address);
const cls = tracked
? "border border-border px-1 text-xs opacity-40 cursor-default"
: "border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs";
return (
`<button class="settings-addtoken-quick ${cls}"` +
` data-address="${t.address}"` +
` data-symbol="${t.symbol}"` +
` data-decimals="${t.decimals}"` +
` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` +
`${tracked ? " disabled" : ""}>${t.symbol}</button>`
);
})
.join("");
el.querySelectorAll(".settings-addtoken-quick:not([disabled])").forEach(
(btn) => {
btn.addEventListener("click", async () => {
const token = {
address: btn.dataset.address,
symbol: btn.dataset.symbol,
decimals: parseInt(btn.dataset.decimals, 10),
name: btn.dataset.name || btn.dataset.symbol,
};
state.trackedTokens.push(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
renderDropdown();
ctx.doRefreshAndRender();
});
},
);
}
function renderDropdown() {
const sel = $("settings-addtoken-select");
const tokens = getTopTokens(100);
let html = '<option value="">-- select --</option>';
for (const t of tokens) {
const tracked = isTracked(t.address);
const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
html +=
`<option value="${t.address}"` +
` data-symbol="${t.symbol}"` +
` data-decimals="${t.decimals}"` +
` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` +
`${tracked ? " disabled" : ""}>${label}</option>`;
}
sel.innerHTML = html;
}
function show() {
$("settings-addtoken-address").value = "";
$("settings-addtoken-info").classList.add("hidden");
renderTop10();
renderDropdown();
showView("settings-addtoken");
}
function init(_ctx) {
ctx = _ctx;
$("btn-settings-addtoken-back").addEventListener("click", () => {
ctx.showSettingsView();
});
$("btn-settings-addtoken-select").addEventListener("click", async () => {
const sel = $("settings-addtoken-select");
const opt = sel.options[sel.selectedIndex];
if (!opt || !opt.value) {
showFlash("Please select a token.");
return;
}
if (isTracked(opt.value)) {
showFlash("Already tracked.");
return;
}
const token = {
address: opt.value,
symbol: opt.dataset.symbol,
decimals: parseInt(opt.dataset.decimals, 10),
name: opt.dataset.name || opt.dataset.symbol,
};
state.trackedTokens.push(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
renderDropdown();
ctx.doRefreshAndRender();
});
$("btn-settings-addtoken-manual").addEventListener("click", async () => {
const addr = $("settings-addtoken-address").value.trim();
if (!addr || !addr.startsWith("0x")) {
showFlash(
"Please enter a valid contract address starting with 0x.",
);
return;
}
if (isTracked(addr)) {
showFlash("Already tracked.");
return;
}
if (isScamAddress(addr)) {
showFlash("This address is on a known scam/fraud list.");
return;
}
const infoEl = $("settings-addtoken-info");
infoEl.textContent = "Looking up token...";
infoEl.classList.remove("hidden");
log.debugf("Looking up token contract", addr);
try {
const info = await lookupTokenInfo(addr, state.rpcUrl);
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
address: addr,
symbol: info.symbol,
decimals: info.decimals,
name: info.name,
});
await saveState();
showFlash("Added " + info.symbol);
$("settings-addtoken-address").value = "";
infoEl.classList.add("hidden");
renderTop10();
renderDropdown();
ctx.doRefreshAndRender();
} catch (e) {
const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", addr, detail);
showFlash(detail);
infoEl.classList.add("hidden");
}
});
}
module.exports = { init, show };

View File

@@ -6,15 +6,10 @@ const {
showView,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
isoDate,
timeAgo,
} = require("./helpers");
const { state } = require("../../shared/state");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
@@ -25,6 +20,41 @@ const EXT_ICON =
let ctx;
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
function copyableHtml(text, extraClass) {
const cls =
"underline decoration-dashed cursor-pointer" +
@@ -37,31 +67,20 @@ function blockieHtml(address) {
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function etherscanLinkHtml(url) {
return (
`<a href="${url}" target="_blank" rel="noopener" ` +
`class="inline-flex items-center"` +
`>${EXT_ICON}</a>`
);
}
function txAddressHtml(address, ensName, title) {
function txAddressHtml(address, ensName) {
const blockie = blockieHtml(address);
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
const extLink = etherscanLinkHtml(link);
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
let html = `<div class="mb-1">${blockie}</div>`;
if (title) {
html += `<div class="font-bold">${escapeHtml(title)}</div>`;
}
if (ensName) {
html +=
`<div class="flex items-center">${dot}` +
copyableHtml(ensName, "") +
`</div>` +
`<div class="flex items-center">${dot}` +
copyableHtml(address, "break-all") +
extLink +
`</div>` +
`<div class="break-all">` +
copyableHtml(address, "break-all") +
`</div>`;
} else {
html +=
@@ -75,7 +94,7 @@ function txAddressHtml(address, ensName, title) {
function txHashHtml(hash) {
const link = `https://etherscan.io/tx/${hash}`;
const extLink = etherscanLinkHtml(link);
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
return copyableHtml(hash, "break-all") + extLink;
}
@@ -86,18 +105,12 @@ function show(tx) {
from: tx.from,
to: tx.to,
value: tx.value,
exactValue: tx.exactValue || tx.value,
rawAmount: tx.rawAmount || "",
rawUnit: tx.rawUnit || "",
symbol: tx.symbol,
timestamp: tx.timestamp,
isError: tx.isError,
fromEns: tx.fromEns || null,
toEns: tx.toEns || null,
directionLabel: tx.directionLabel || null,
direction: tx.direction || null,
isContractCall: tx.isContractCall || false,
method: tx.method || null,
},
};
render();
@@ -107,57 +120,11 @@ function render() {
const tx = state.viewData.tx;
if (!tx) return;
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
const fromTitle = addressTitle(tx.from, state.wallets);
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-from").innerHTML = txAddressHtml(
tx.from,
tx.fromEns,
fromTitle,
);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
// Exact amount (full precision, copyable)
const exactStr = tx.exactValue
? tx.exactValue + " " + tx.symbol
$("tx-detail-from").innerHTML = txAddressHtml(tx.from, tx.fromEns);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns);
$("tx-detail-value").textContent = tx.value
? tx.value + " " + tx.symbol
: tx.directionLabel + " " + tx.symbol;
$("tx-detail-value").innerHTML = copyableHtml(exactStr, "font-bold");
// Native quantity (raw integer, copyable)
const nativeEl = $("tx-detail-native");
if (tx.rawAmount && tx.rawUnit) {
const nativeStr = tx.rawAmount + " " + tx.rawUnit;
nativeEl.innerHTML = copyableHtml(nativeStr, "");
nativeEl.parentElement.classList.remove("hidden");
} else {
nativeEl.innerHTML = "";
nativeEl.parentElement.classList.add("hidden");
}
// Show type label for contract interactions (Swap, Execute, etc.)
const typeSection = $("tx-detail-type-section");
const typeEl = $("tx-detail-type");
const headingEl = $("tx-detail-heading");
if (tx.direction === "contract" && tx.directionLabel) {
if (typeSection) {
typeEl.textContent = tx.directionLabel;
typeSection.classList.remove("hidden");
}
} else {
if (typeSection) typeSection.classList.add("hidden");
}
if (headingEl) headingEl.textContent = "Transaction";
// Hide calldata and raw data sections; re-fetch if this is a contract call
const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section");
if (rawDataSection) rawDataSection.classList.add("hidden");
if (tx.isContractCall || tx.direction === "contract") {
loadCalldata(tx.hash, tx.to);
}
$("tx-detail-time").textContent =
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
@@ -174,87 +141,6 @@ function render() {
});
}
async function loadCalldata(txHash, toAddress) {
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
const wellEl = $("tx-detail-calldata-well");
const rawSection = $("tx-detail-rawdata-section");
const rawEl = $("tx-detail-rawdata");
if (!section || !actionEl || !detailsEl) return;
try {
const resp = await debugFetch(
state.blockscoutUrl + "/transactions/" + txHash,
);
if (!resp.ok) return;
const txData = await resp.json();
const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return;
const decoded = decodeCalldata(inputData, toAddress || "");
if (decoded) {
// Render decoded calldata matching approval view style
actionEl.textContent = decoded.name;
let detailsHtml = "";
if (decoded.description) {
detailsHtml += `<div class="mb-2">${escapeHtml(decoded.description)}</div>`;
}
for (const d of decoded.details || []) {
detailsHtml += `<div class="mb-2">`;
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address && d.isToken) {
// Token entry: show symbol on its own line, then dot + address + Etherscan link
const dot = addressDotHtml(d.address);
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenSymbol)}</div>`;
}
const etherscanUrl = `https://etherscan.io/token/${d.address}`;
detailsHtml += `<div class="flex items-center">${dot}${copyableHtml(d.address, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
} else if (d.address) {
// Protocol/contract entry: show name + Etherscan link
const dot = addressDotHtml(d.address);
const etherscanUrl = `https://etherscan.io/address/${d.address}`;
detailsHtml += `<div class="flex items-center">${dot}${copyableHtml(d.value, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
detailsHtml += `</div>`;
}
detailsEl.innerHTML = detailsHtml;
if (wellEl) wellEl.classList.remove("hidden");
} else {
// Unknown contract call — show method name in well
const method = txData.method || "Unknown contract call";
actionEl.textContent = method;
detailsEl.innerHTML = "";
if (wellEl) wellEl.classList.remove("hidden");
}
// Always show raw data
if (rawSection && rawEl) {
rawEl.innerHTML = copyableHtml(inputData, "break-all");
rawSection.classList.remove("hidden");
}
section.classList.remove("hidden");
// Bind copy handlers for new elements (including raw data now outside section)
const copyTargets = [section, rawSection].filter(Boolean);
for (const container of copyTargets) {
container.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
};
});
}
} catch (e) {
log.errorf("loadCalldata failed:", e.message);
}
}
function init(_ctx) {
ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => {

View File

@@ -5,10 +5,8 @@ const {
showView,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
} = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
@@ -39,13 +37,6 @@ function toAddressHtml(address) {
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(address, state.wallets);
if (title) {
return (
`<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` +
`<div class="break-all">${escapeHtml(address)}${extLink}</div>`
);
}
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
}
@@ -122,51 +113,11 @@ function showSuccess(txInfo, txHash, blockNumber) {
to: txInfo.to,
hash: txHash,
blockNumber: blockNumber,
decoded: txInfo.decoded || null,
};
renderSuccess();
ctx.doRefreshAndRender();
}
function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null;
}
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return "";
let html = "";
if (decoded.name) {
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Action</div>`;
html += `<div class="font-bold">${escapeHtml(decoded.name)}</div></div>`;
}
if (decoded.description) {
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Description</div>`;
html += `<div>${escapeHtml(decoded.description)}</div></div>`;
}
for (const d of decoded.details) {
html += `<div class="mb-2">`;
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
if (d.address) {
if (d.isToken) {
const sym = tokenLabel(d.address) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += toAddressHtml(d.address);
} else {
html += toAddressHtml(d.address);
}
} else {
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
html += `</div>`;
}
return html;
}
function renderSuccess() {
const d = state.viewData;
if (!d || !d.hash) return;
@@ -174,16 +125,6 @@ function renderSuccess() {
$("success-tx-to").innerHTML = toAddressHtml(d.to);
$("success-tx-block").textContent = String(d.blockNumber);
$("success-tx-hash").innerHTML = txHashHtml(d.hash);
// Show decoded calldata details if present
const decodedEl = $("success-tx-decoded");
if (decodedEl && d.decoded) {
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
decodedEl.classList.remove("hidden");
} else if (decodedEl) {
decodedEl.classList.add("hidden");
}
attachCopyHandlers("view-success-tx");
showView("success-tx");
}

View File

@@ -85,7 +85,6 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
balances.push({
address: item.token.address_hash,
name: item.token.name || "",
symbol: item.token.symbol || "???",
decimals: decimals,
balance: bal,
@@ -124,27 +123,15 @@ async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
}),
);
// ENS reverse lookup — only overwrite on success so that
// transient RPC errors don't wipe a previously resolved name.
// ENS reverse lookup
updates.push(
provider
.lookupAddress(addr.address)
.then((name) => {
addr.ensName = name || null;
log.debugf(
"ENS reverse",
addr.address,
"->",
addr.ensName,
);
})
.catch((e) => {
log.errorf(
"ENS reverse failed",
addr.address,
e.message,
);
// Keep existing addr.ensName if we had one
.catch(() => {
addr.ensName = null;
}),
);
@@ -205,10 +192,6 @@ async function lookupTokenInfo(contractAddress, rpcUrl) {
name = symbol;
}
// Truncate to prevent storage of excessively long values from RPC
name = String(name).slice(0, 64);
symbol = String(symbol).slice(0, 12);
log.infof("Token resolved:", symbol, "decimals", Number(decimals));
return { name, symbol, decimals: Number(decimals) };
}

View File

@@ -39,7 +39,7 @@ async function resolveEnsName(address, rpcUrl) {
return name;
} catch (e) {
log.errorf("ENS reverse lookup failed", address, e.message);
// Don't cache failures — let subsequent lookups retry
setCache(address, null);
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -27,9 +27,6 @@ function parseTx(tx, addrLower) {
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
let symbol = "ETH";
let value = formatTxValue(formatEther(rawWei));
let exactValue = formatEther(rawWei);
let rawAmount = rawWei;
let rawUnit = "wei";
let direction = from.toLowerCase() === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") {
@@ -37,27 +34,10 @@ function parseTx(tx, addrLower) {
if (token) {
symbol = token.symbol;
}
// Map known DEX methods to "Swap" for cleaner display
const SWAP_METHODS = new Set([
"execute",
"swap",
"swapExactTokensForTokens",
"swapTokensForExactTokens",
"swapExactETHForTokens",
"swapTokensForExactETH",
"swapExactTokensForETH",
"swapETHForExactTokens",
"multicall",
]);
const label = SWAP_METHODS.has(method)
? "Swap"
: method.charAt(0).toUpperCase() + method.slice(1);
const label = method.charAt(0).toUpperCase() + method.slice(1);
direction = "contract";
directionLabel = label;
value = "";
exactValue = "";
rawAmount = "";
rawUnit = "";
}
return {
@@ -67,9 +47,6 @@ function parseTx(tx, addrLower) {
from: from,
to: to,
value: value,
exactValue: exactValue,
rawAmount: rawAmount,
rawUnit: rawUnit,
valueGwei: Math.floor(Number(BigInt(rawWei) / BigInt(1000000000))),
symbol: symbol,
direction: direction,
@@ -86,21 +63,17 @@ function parseTokenTransfer(tt, addrLower) {
const from = tt.from?.hash || "";
const to = tt.to?.hash || "";
const decimals = parseInt(tt.total?.decimals || "18", 10);
const rawVal = tt.total?.value || "0";
const rawValue = tt.total?.value || "0";
const direction = from.toLowerCase() === addrLower ? "sent" : "received";
const sym = tt.token?.symbol || "?";
return {
hash: tt.transaction_hash,
blockNumber: tt.block_number,
timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
from: from,
to: to,
value: formatTxValue(formatUnits(rawVal, decimals)),
exactValue: formatUnits(rawVal, decimals),
rawAmount: rawVal,
rawUnit: sym + " base units (10^-" + decimals + ")",
value: formatTxValue(formatUnits(rawValue, decimals)),
valueGwei: null,
symbol: sym,
symbol: tt.token?.symbol || "?",
direction: direction,
directionLabel: direction === "sent" ? "Sent" : "Received",
isError: false,
@@ -153,26 +126,10 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
// 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. A single transaction (e.g. a swap) can produce
// multiple token transfers (one per token involved), so we key token
// transfers by hash + contract address to keep all of them. We also
// preserve contract-call metadata (direction, label, method) from the
// matching normal tx so swaps display correctly.
// amount and symbol. Replace the normal tx with the token transfer.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
parsed.direction = "contract";
parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true;
parsed.method = existing.method;
// Remove the bare-hash normal tx so it doesn't appear as a
// duplicate with empty value; token transfers replace it.
txsByHash.delete(parsed.hash);
}
// Use composite key so multiple token transfers per tx are kept.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
txsByHash.set(parsed.hash, parsed);
}
const txs = [...txsByHash.values()];

View File

@@ -1,497 +0,0 @@
// Decode Uniswap Universal Router execute() calldata into human-readable
// swap details. Designed to be extended with other DEX decoders later.
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const coder = AbiCoder.defaultAbiCoder();
const ROUTER_IFACE = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// Universal Router command IDs (lower 5 bits of each command byte)
const COMMAND_NAMES = {
0x00: "V3 Swap (Exact In)",
0x01: "V3 Swap (Exact Out)",
0x02: "Permit2 Transfer",
0x03: "Permit2 Permit Batch",
0x04: "Sweep",
0x05: "Transfer",
0x06: "Pay Portion",
0x08: "V2 Swap (Exact In)",
0x09: "V2 Swap (Exact Out)",
0x0a: "Permit2 Permit",
0x0b: "Wrap ETH",
0x0c: "Unwrap WETH",
0x0d: "Permit2 Transfer Batch",
0x0e: "Balance Check",
0x10: "V4 Swap",
0x11: "V3 Position Mgr Permit",
0x12: "V3 Position Mgr Call",
0x13: "V4 Initialize Pool",
0x14: "V4 Position Mgr Call",
0x21: "Execute Sub-Plan",
};
function formatAmount(raw, decimals) {
const parts = formatUnits(raw, decimals).split(".");
if (parts.length === 1) return parts[0] + ".0000";
const dec = (parts[1] + "0000").slice(0, 4);
return parts[0] + "." + dec;
}
function tokenInfo(address) {
if (!address || address === "0x0000000000000000000000000000000000000000") {
return { symbol: "ETH", decimals: 18, address: null };
}
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
if (t) return { symbol: t.symbol, decimals: t.decimals, address };
return { symbol: null, decimals: 18, address };
}
// Decode PERMIT2_PERMIT (command 0x0a) input bytes.
// ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce),
// address spender, uint256 sigDeadline), bytes signature
function decodePermit2(input) {
try {
const d = coder.decode(
[
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes",
],
input,
);
return { token: d[0][0][0], amount: d[0][0][1], spender: d[0][1] };
} catch {
return null;
}
}
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes.
// ABI: (address owner, address token, uint256 minBalance)
function decodeBalanceCheck(input) {
try {
const d = coder.decode(["address", "address", "uint256"], input);
return { owner: d[0], token: d[1], minBalance: d[2] };
} catch {
return null;
}
}
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// address[] path, bool payerIsUser)
function decodeV2SwapExactIn(input) {
try {
const d = coder.decode(
["address", "uint256", "uint256", "address[]", "bool"],
input,
);
return {
amountIn: d[1],
amountOutMin: d[2],
tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1],
};
} catch {
return null;
}
}
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
// address[] path, bool payerIsUser)
function decodeV2SwapExactOut(input) {
try {
const d = coder.decode(
["address", "uint256", "uint256", "address[]", "bool"],
input,
);
return {
amountOut: d[1],
amountInMax: d[2],
tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1],
};
} catch {
return null;
}
}
// Decode V3 swap path (packed: token(20) + fee(3) + token(20) ...)
function decodeV3Path(pathHex) {
const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex;
if (hex.length < 40) return null;
const tokenIn = "0x" + hex.slice(0, 40);
const tokenOut = "0x" + hex.slice(-40);
return { tokenIn, tokenOut };
}
// Decode V3_SWAP_EXACT_IN (command 0x00) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// bytes path, bool payerIsUser)
function decodeV3SwapExactIn(input) {
try {
const d = coder.decode(
["address", "uint256", "uint256", "bytes", "bool"],
input,
);
const path = decodeV3Path(d[3]);
if (!path) return null;
return {
amountIn: d[1],
amountOutMin: d[2],
tokenIn: path.tokenIn,
tokenOut: path.tokenOut,
};
} catch {
return null;
}
}
// Decode WRAP_ETH (command 0x0b) input bytes.
// ABI: (address recipient, uint256 amount)
function decodeWrapEth(input) {
try {
const d = coder.decode(["address", "uint256"], input);
return { amount: d[1] };
} catch {
return null;
}
}
// V4 inner action IDs
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_OUT_SINGLE = 0x08;
const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b;
const V4_TAKE = 0x0e;
// Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
// and swap amounts from the swap sub-actions.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
const actions = getBytes(d[0]);
const params = d[1];
let settleToken = null;
let takeToken = null;
let amountIn = null;
let amountOutMin = null;
for (let i = 0; i < actions.length; i++) {
const actionId = actions[i];
try {
if (actionId === V4_SETTLE) {
// SETTLE: (address currency, uint256 maxAmount, bool payerIsUser)
const s = coder.decode(
["address", "uint256", "bool"],
params[i],
);
settleToken = s[0];
} else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode(
["address", "address", "uint256"],
params[i],
);
takeToken = t[0];
} else if (
actionId === V4_SWAP_EXACT_IN ||
actionId === V4_SWAP_EXACT_IN_SINGLE
) {
// Extract amounts from exact-in swap actions
if (actionId === V4_SWAP_EXACT_IN) {
// ExactInputParams: (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
try {
const s = coder.decode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!settleToken) settleToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !takeToken) {
takeToken = path[path.length - 1][0];
}
if (!amountIn) amountIn = s[0][2];
if (!amountOutMin) amountOutMin = s[0][3];
} catch {
// Fall through — SETTLE/TAKE will provide tokens
}
} else {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
try {
const s = coder.decode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!settleToken)
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!takeToken)
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
if (!amountIn) amountIn = s[0][2];
if (!amountOutMin) amountOutMin = s[0][3];
} catch {
// Fall through
}
}
} else if (
actionId === V4_SWAP_EXACT_OUT ||
actionId === V4_SWAP_EXACT_OUT_SINGLE
) {
if (actionId === V4_SWAP_EXACT_OUT) {
try {
const s = coder.decode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!takeToken) takeToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !settleToken) {
settleToken = path[path.length - 1][0];
}
} catch {
// Fall through
}
} else {
try {
const s = coder.decode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!settleToken)
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!takeToken)
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
} catch {
// Fall through
}
}
}
} catch {
// Skip sub-actions we can't decode
}
}
return {
tokenIn: settleToken,
tokenOut: takeToken,
amountIn,
amountOutMin,
};
} catch {
return null;
}
}
// Try to decode a Universal Router execute() call.
// Returns { name, description, details } matching the format used by
// the approval UI, or null if the calldata is not a recognised execute().
function decode(data, toAddress) {
try {
const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null;
const commandsBytes = getBytes(parsed.args[0]);
const inputs = parsed.args[1];
const deadline = parsed.args[2];
let inputToken = null;
let inputAmount = null;
let outputToken = null;
let minOutput = null;
let hasUnwrapWeth = false;
const commandNames = [];
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & 0x1f;
commandNames.push(
COMMAND_NAMES[cmdId] ||
"Command 0x" + cmdId.toString(16).padStart(2, "0"),
);
try {
if (cmdId === 0x0a) {
const p = decodePermit2(inputs[i]);
if (p) {
inputToken = p.token;
inputAmount = p.amount;
}
}
if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]);
if (b) {
outputToken = b.token;
minOutput = b.minBalance;
}
}
if (cmdId === 0x00) {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
if (!inputAmount) inputAmount = s.amountIn;
if (!minOutput) minOutput = s.amountOutMin;
}
}
if (cmdId === 0x08) {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
if (!inputAmount) inputAmount = s.amountIn;
if (!minOutput) minOutput = s.amountOutMin;
}
}
if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]);
if (w && !inputToken) {
inputToken =
"0x0000000000000000000000000000000000000000";
inputAmount = w.amount;
}
}
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
if (!outputToken && v4.tokenOut)
outputToken = v4.tokenOut;
if (!inputAmount && v4.amountIn)
inputAmount = v4.amountIn;
if (!minOutput && v4.amountOutMin)
minOutput = v4.amountOutMin;
}
}
if (cmdId === 0x0c) {
hasUnwrapWeth = true;
}
} catch {
// Skip commands we can't decode
}
}
// Resolve token info
const inInfo = tokenInfo(inputToken);
const outInfo = hasUnwrapWeth
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken);
const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol;
const name =
inSymbol && outSymbol
? "Swap " + inSymbol + " \u2192 " + outSymbol
: "Uniswap Swap";
const details = [];
details.push({
label: "Protocol",
value: "Uniswap Universal Router",
address: toAddress,
});
if (inputToken && inInfo.address) {
const label = inSymbol
? inSymbol + " (" + inputToken + ")"
: inputToken;
details.push({
label: "Token In",
value: label,
address: inputToken,
isToken: true,
});
} else if (inSymbol === "ETH") {
details.push({ label: "Token In", value: "ETH (native)" });
}
if (inputAmount !== null && inputAmount !== undefined) {
const maxUint160 = BigInt(
"0xffffffffffffffffffffffffffffffffffffffff",
);
const amountStr =
inputAmount >= maxUint160
? "Unlimited"
: formatAmount(inputAmount, inInfo.decimals) +
(inSymbol ? " " + inSymbol : "");
details.push({ label: "Amount", value: amountStr });
}
if (outSymbol) {
if (outInfo.address) {
const label = outSymbol
? outSymbol + " (" + outputToken + ")"
: outputToken;
details.push({
label: "Token Out",
value: label,
address: outputToken,
isToken: true,
});
} else {
details.push({ label: "Token Out", value: outSymbol });
}
}
if (minOutput !== null && minOutput !== undefined) {
const minStr =
formatAmount(minOutput, outInfo.decimals) +
(outSymbol ? " " + outSymbol : "");
details.push({ label: "Min. received", value: minStr });
}
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
const deadlineDate = new Date(Number(deadline) * 1000);
details.push({
label: "Deadline",
value: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
});
return {
name,
description: "Swap via Uniswap Universal Router",
details,
};
} catch {
return null;
}
}
module.exports = { decode };

View File

@@ -1,356 +0,0 @@
const { AbiCoder, Interface, solidityPacked, getBytes } = require("ethers");
const uniswap = require("../src/shared/uniswap");
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// AutistMask's first-ever swap, 2026-02-27.
// Swapped USDT for ETH via Uniswap V4 Universal Router.
// https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77
const FIRST_SWAP_CALLDATA =
"0x3593564c" +
"000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0" +
"0000000000000000000000000000000000000000000000000000000069a1550f00000000000000000000000000000000000000000000000000000000000000020a10000000000000000000000000000000000000000000000000000000000000" +
"0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" +
"0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff" +
"0000000000000000000000000000000000000000000000000000000069c8daf6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" +
"0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000041" +
"230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
"00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" +
"070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" +
"00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" +
"0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e7" +
"0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064" +
"0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0" +
"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"66133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// Helper: build a minimal execute() calldata from commands + inputs
function buildExecute(commands, inputs, deadline) {
return routerIface.encodeFunctionData("execute", [
commands,
inputs,
deadline,
]);
}
// Helper: encode a PERMIT2_PERMIT input (command 0x0a)
function encodePermit2(token, amount, spender) {
return coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes",
],
[[[token, amount, 0, 0], spender, 9999999999], "0x1234"],
);
}
// Helper: encode a BALANCE_CHECK_ERC20 input (command 0x0e)
function encodeBalanceCheck(owner, token, minBalance) {
return coder.encode(
["address", "address", "uint256"],
[owner, token, minBalance],
);
}
// Helper: encode a WRAP_ETH input (command 0x0b)
function encodeWrapEth(recipient, amount) {
return coder.encode(["address", "uint256"], [recipient, amount]);
}
// Helper: encode a V2_SWAP_EXACT_IN input (command 0x08)
function encodeV2SwapExactIn(recipient, amountIn, amountOutMin, pathAddrs) {
return coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[recipient, amountIn, amountOutMin, pathAddrs, true],
);
}
// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
// V3 path: token(20) + fee(3) + token(20) ...
let pathHex = pathTokens[0].slice(2).toLowerCase();
for (let i = 1; i < pathTokens.length; i++) {
pathHex += "000bb8"; // fee 3000 = 0x000bb8
pathHex += pathTokens[i].slice(2).toLowerCase();
}
return coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[recipient, amountIn, amountOutMin, "0x" + pathHex, true],
);
}
// Helper: encode a V4_SWAP input (command 0x10) — just a passthrough blob
function encodeV4Swap(actions, params) {
return coder.encode(["bytes", "bytes[]"], [actions, params]);
}
describe("uniswap decoder", () => {
test("returns null for non-execute calldata", () => {
expect(uniswap.decode("0x", ROUTER_ADDR)).toBeNull();
expect(uniswap.decode("0xdeadbeef", ROUTER_ADDR)).toBeNull();
expect(uniswap.decode(null, ROUTER_ADDR)).toBeNull();
});
test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 ETH");
expect(result.description).toContain("Uniswap");
const labels = result.details.map((d) => d.label);
expect(labels).toContain("Protocol");
expect(labels).toContain("Token In");
expect(labels).toContain("Steps");
expect(labels).toContain("Deadline");
const tokenIn = result.details.find((d) => d.label === "Token In");
expect(tokenIn.value).toContain("USDT");
expect(tokenIn.address.toLowerCase()).toBe(USDT_ADDR.toLowerCase());
const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toContain("Permit2 Permit");
expect(steps.value).toContain("V4 Swap");
});
test("decodes V2_SWAP_EXACT_IN with known tokens", () => {
const data = buildExecute(
"0x08", // V2_SWAP_EXACT_IN
[
encodeV2SwapExactIn(
USER_ADDR,
1000000n, // 1 USDT (6 decimals)
500000000000000n, // 0.0005 ETH
[USDT_ADDR, WETH_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH");
const amount = result.details.find((d) => d.label === "Amount");
expect(amount.value).toBe("1.0000 USDT");
const minOut = result.details.find((d) => d.label === "Min. received");
expect(minOut.value).toContain("WETH");
});
test("decodes V3_SWAP_EXACT_IN with known tokens", () => {
const data = buildExecute(
"0x00", // V3_SWAP_EXACT_IN
[
encodeV3SwapExactIn(
USER_ADDR,
2000000n, // 2 USDT
1000000000000000n, // 0.001 ETH
[USDT_ADDR, WETH_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH");
});
test("decodes WRAP_ETH as ETH input", () => {
const data = buildExecute(
"0x0b", // WRAP_ETH
[encodeWrapEth(ROUTER_ADDR, 1000000000000000000n)],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
const tokenIn = result.details.find((d) => d.label === "Token In");
expect(tokenIn.value).toBe("ETH (native)");
const amount = result.details.find((d) => d.label === "Amount");
expect(amount.value).toContain("1.0000");
expect(amount.value).toContain("ETH");
});
test("decodes UNWRAP_WETH as ETH output", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
[
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH same encoding
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
// UNWRAP_WETH means output is native ETH
expect(result.name).toBe("Swap USDT \u2192 ETH");
});
test("decodes BALANCE_CHECK_ERC20 for min output", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
[
encodeWrapEth(ROUTER_ADDR, 1000000000000000000n),
encodeBalanceCheck(USER_ADDR, USDT_ADDR, 2000000n),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
const minOut = result.details.find((d) => d.label === "Min. received");
expect(minOut).toBeDefined();
expect(minOut.value).toContain("2.0000");
expect(minOut.value).toContain("USDT");
});
test("shows command names in steps", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x10, 0x0c]),
[
encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR),
encodeV4Swap("0x07", ["0x"]),
encodeWrapEth(USER_ADDR, 0n), // reusing for UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toBe(
"Permit2 Permit \u2192 V4 Swap \u2192 Unwrap WETH",
);
});
test("formats permit amount when not unlimited", () => {
const data = buildExecute(
"0x0a",
[encodePermit2(USDT_ADDR, 5000000n, ROUTER_ADDR)],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
const amount = result.details.find((d) => d.label === "Amount");
expect(amount.value).toBe("5.0000 USDT");
});
// This test validates the decodeV4Swap() fix: a V4 ERC20→ERC20 swap
// (USDT→USDC) where the token addresses are ONLY discoverable inside
// the V4_SWAP sub-actions (SETTLE/TAKE). Before decodeV4Swap() was added,
// command 0x10 was opaque and this would decode as "Uniswap Swap" with
// no token info (or "ETH → ETH"). Now it correctly shows "USDT → USDC".
test("decodes V4_SWAP ERC20→ERC20 tokens via SETTLE/TAKE (regression: #59)", () => {
// Build a V4_SWAP input with SETTLE(USDT) + SWAP_EXACT_IN_SINGLE + TAKE(USDC)
const V4_SETTLE = 0x0b;
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
const V4_TAKE = 0x0e;
// actions: SETTLE, SWAP_EXACT_IN_SINGLE, TAKE
const actions = new Uint8Array([
V4_SETTLE,
V4_SWAP_EXACT_IN_SINGLE,
V4_TAKE,
]);
// SETTLE params: (address currency, uint256 maxAmount, bool payerIsUser)
const settleParam = coder.encode(
["address", "uint256", "bool"],
[USDT_ADDR, 5000000n, true],
);
// SWAP_EXACT_IN_SINGLE params:
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
const swapParam = coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[
USDT_ADDR,
USDC_ADDR,
100, // fee
1, // tickSpacing
"0x0000000000000000000000000000000000000000", // hooks
],
true, // zeroForOne
5000000n, // amountIn (5 USDT)
4900000n, // amountOutMin (4.9 USDC)
"0x", // hookData
],
],
);
// TAKE params: (address currency, address recipient, uint256 amount)
const takeParam = coder.encode(
["address", "address", "uint256"],
[USDC_ADDR, USER_ADDR, 0n],
);
// Encode the V4_SWAP input: (bytes actions, bytes[] params)
const v4Input = coder.encode(
["bytes", "bytes[]"],
[actions, [settleParam, swapParam, takeParam]],
);
// Build execute() with PERMIT2_PERMIT (0x0a) + V4_SWAP (0x10)
// The permit provides the input token, but V4_SWAP must provide
// the OUTPUT token — without decodeV4Swap, output would be unknown.
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0a, 0x10]),
[encodePermit2(USDT_ADDR, 5000000n, ROUTER_ADDR), v4Input],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
// Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap"
// After fix: correctly identifies both tokens from V4 sub-actions
expect(result.name).toBe("Swap USDT \u2192 USDC");
const tokenIn = result.details.find((d) => d.label === "Token In");
expect(tokenIn.value).toContain("USDT");
const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toContain("V4 Swap");
});
test("handles unknown tokens gracefully", () => {
const fakeToken = "0x1111111111111111111111111111111111111111";
const data = buildExecute(
"0x0a",
[encodePermit2(fakeToken, 1000000000000000000n, ROUTER_ADDR)],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Uniswap Swap");
const tokenIn = result.details.find((d) => d.label === "Token In");
expect(tokenIn.value).toContain(fakeToken);
});
});

676
yarn.lock

File diff suppressed because it is too large Load Diff