Compare commits
1 Commits
f290298244
...
feat/messa
| Author | SHA1 | Date | |
|---|---|---|---|
| 71f1d11a33 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,4 +25,3 @@ dist/
|
||||
|
||||
# Yarn
|
||||
.yarn-integrity
|
||||
package-lock.json
|
||||
|
||||
372
README.md
372
README.md
@@ -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.js — chain 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
121
RULES.md
@@ -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 ` ` 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
⚙
|
||||
@@ -56,104 +56,37 @@
|
||||
< Back
|
||||
</button>
|
||||
<h2 class="font-bold mb-2">Add Wallet</h2>
|
||||
|
||||
<!-- Mode selector tabs -->
|
||||
<p class="mb-2">
|
||||
Enter your 12 or 24 word recovery phrase below, or click the
|
||||
button to roll the die for a new one.
|
||||
</p>
|
||||
<div class="mb-1 flex justify-end">
|
||||
<button
|
||||
id="btn-generate-phrase"
|
||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
|
||||
title="Generate a random recovery phrase"
|
||||
>
|
||||
[⚀]
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<textarea
|
||||
id="wallet-mnemonic"
|
||||
rows="3"
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg resize-y"
|
||||
placeholder="word word word ..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div
|
||||
class="flex border-b border-border mb-3"
|
||||
id="add-wallet-tabs"
|
||||
id="add-wallet-phrase-warning"
|
||||
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
|
||||
>
|
||||
<button
|
||||
id="tab-mnemonic"
|
||||
class="px-3 py-1.5 cursor-pointer text-xs font-bold border border-border border-b-bg bg-bg -mb-px"
|
||||
>
|
||||
From Phrase
|
||||
</button>
|
||||
<button
|
||||
id="tab-privkey"
|
||||
class="px-3 py-1.5 cursor-pointer text-xs text-muted border border-dashed border-border-light border-b-transparent -mb-px hover:bg-fg hover:text-bg"
|
||||
>
|
||||
From Key
|
||||
</button>
|
||||
<button
|
||||
id="tab-xprv"
|
||||
class="px-3 py-1.5 cursor-pointer text-xs text-muted border border-dashed border-border-light border-b-transparent -mb-px hover:bg-fg hover:text-bg"
|
||||
>
|
||||
From xprv
|
||||
</button>
|
||||
Write these words down and keep them safe. Anyone with them
|
||||
can take your funds; if you lose them, your wallet is gone.
|
||||
</div>
|
||||
|
||||
<!-- Mnemonic form section -->
|
||||
<div id="add-wallet-section-mnemonic">
|
||||
<p class="mb-2">
|
||||
Enter your 12 or 24 word recovery phrase below, or click
|
||||
the button to roll the die for a new one.
|
||||
</p>
|
||||
<div class="mb-1 flex justify-end">
|
||||
<button
|
||||
id="btn-generate-phrase"
|
||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
|
||||
title="Generate a random recovery phrase"
|
||||
>
|
||||
[⚀]
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<textarea
|
||||
id="wallet-mnemonic"
|
||||
rows="3"
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg resize-y"
|
||||
placeholder="word word word ..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div
|
||||
id="add-wallet-phrase-warning"
|
||||
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
|
||||
>
|
||||
Write these words down and keep them safe. Anyone with
|
||||
them can take your funds; if you lose them, your wallet
|
||||
is gone.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Private key form section -->
|
||||
<div id="add-wallet-section-privkey" class="hidden">
|
||||
<p class="mb-2">
|
||||
Paste your private key below. This wallet will have a
|
||||
single address.
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
type="password"
|
||||
id="import-private-key"
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extended key (xprv) form section -->
|
||||
<div id="add-wallet-section-xprv" class="hidden">
|
||||
<p class="mb-2">
|
||||
Paste your extended private key (xprv) below. This will
|
||||
import the HD wallet and scan for used addresses.
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
type="password"
|
||||
id="import-xprv-key"
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||
placeholder="xprv..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared password fields -->
|
||||
<div class="mb-2" id="add-wallet-password-section">
|
||||
<label class="block mb-1">Choose a password</label>
|
||||
<p
|
||||
class="text-xs text-muted mb-1"
|
||||
id="add-wallet-password-hint"
|
||||
>
|
||||
<p class="text-xs text-muted mb-1">
|
||||
This password encrypts your recovery phrase on this
|
||||
device. You will need it to send funds.
|
||||
</p>
|
||||
@@ -174,6 +107,64 @@
|
||||
<button
|
||||
id="btn-add-wallet-confirm"
|
||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<div class="mt-3 text-xs text-muted">
|
||||
Have a private key instead?
|
||||
<button
|
||||
id="btn-add-wallet-import-key"
|
||||
class="underline cursor-pointer bg-transparent border-none text-fg text-xs font-mono p-0"
|
||||
>
|
||||
Import private key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ IMPORT PRIVATE KEY ============ -->
|
||||
<div id="view-import-key" class="view hidden">
|
||||
<button
|
||||
id="btn-import-key-back"
|
||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
|
||||
>
|
||||
< Back
|
||||
</button>
|
||||
<h2 class="font-bold mb-2">Import Private Key</h2>
|
||||
<p class="mb-2">
|
||||
Paste your private key below. This wallet will have a single
|
||||
address.
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
type="password"
|
||||
id="import-private-key"
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-2" id="import-key-password-section">
|
||||
<label class="block mb-1">Choose a password</label>
|
||||
<p class="text-xs text-muted mb-1">
|
||||
This password encrypts your private key on this device.
|
||||
You will need it to send funds.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
id="import-key-password"
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-2" id="import-key-password-confirm-section">
|
||||
<label class="block mb-1">Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="import-key-password-confirm"
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="btn-import-key-confirm"
|
||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
@@ -283,15 +274,11 @@
|
||||
</div>
|
||||
<div
|
||||
id="address-usd-total"
|
||||
class="text-xs text-muted mb-3 text-right min-h-[1rem]"
|
||||
>
|
||||
|
||||
</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]">
|
||||
|
||||
</div>
|
||||
<div id="address-balances"></div>
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
@@ -314,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"
|
||||
>
|
||||
···
|
||||
</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 -->
|
||||
@@ -347,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"
|
||||
>
|
||||
< 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
|
||||
@@ -430,15 +343,11 @@
|
||||
</div>
|
||||
<div
|
||||
id="address-token-usd-total"
|
||||
class="text-xs text-muted mb-3 text-right min-h-[1rem]"
|
||||
>
|
||||
|
||||
</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]">
|
||||
|
||||
</div>
|
||||
<div id="address-token-balance"></div>
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
@@ -457,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">
|
||||
@@ -505,11 +408,6 @@
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||
placeholder="Address (0x...) or ENS name"
|
||||
/>
|
||||
<div
|
||||
id="send-to-error"
|
||||
class="text-xs"
|
||||
style="min-height: 1.25rem; color: #cc0000"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<div class="flex justify-between mb-1">
|
||||
@@ -586,65 +484,15 @@
|
||||
<div id="confirm-fee-amount" class="text-xs"></div>
|
||||
</div>
|
||||
<div id="confirm-warnings" class="mb-2 hidden"></div>
|
||||
<div
|
||||
id="confirm-recipient-warning"
|
||||
class="mb-2"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
<div
|
||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||
>
|
||||
WARNING: The recipient address has ZERO transaction
|
||||
history. This may indicate a fresh or unused address.
|
||||
Double-check the address before sending.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="confirm-contract-warning"
|
||||
class="mb-2"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
<div
|
||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||
>
|
||||
WARNING: The recipient is a smart contract. Sending ETH
|
||||
or tokens directly to a contract may result in permanent
|
||||
loss of funds.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="confirm-burn-warning"
|
||||
class="mb-2"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
<div
|
||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||
>
|
||||
WARNING: This is a known null/burn address. Funds sent
|
||||
here are permanently destroyed and cannot be recovered.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="confirm-errors"
|
||||
class="mb-2 border border-border border-dashed p-2 hidden"
|
||||
></div>
|
||||
<div class="mb-2">
|
||||
<label class="block mb-1 text-xs">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm-tx-password"
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="confirm-tx-password-error"
|
||||
class="text-xs mb-2 min-h-[1.25rem]"
|
||||
></div>
|
||||
<button
|
||||
id="btn-confirm-send"
|
||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||
>
|
||||
Sign & Send
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -669,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>
|
||||
@@ -723,6 +570,42 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ PASSWORD MODAL ============ -->
|
||||
<div
|
||||
id="password-modal"
|
||||
class="hidden fixed inset-0 bg-bg flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="border border-border p-4 bg-bg w-80">
|
||||
<h2 class="font-bold mb-2">Enter Password</h2>
|
||||
<p class="text-xs text-muted mb-2">
|
||||
Your password is needed to authorize this transaction.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
id="modal-password"
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg mb-2"
|
||||
/>
|
||||
<div
|
||||
id="modal-password-error"
|
||||
class="text-xs mb-2 border border-border border-dashed p-1 hidden"
|
||||
></div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
id="btn-modal-confirm"
|
||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
id="btn-modal-cancel"
|
||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ RECEIVE ============ -->
|
||||
<div id="view-receive" class="view hidden">
|
||||
<button
|
||||
@@ -739,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
|
||||
@@ -812,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"
|
||||
@@ -821,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
|
||||
@@ -947,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"
|
||||
>
|
||||
< 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"
|
||||
>
|
||||
< 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
|
||||
@@ -1057,13 +824,7 @@
|
||||
>
|
||||
< 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>
|
||||
@@ -1074,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>
|
||||
@@ -1088,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 ============ -->
|
||||
@@ -1152,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>
|
||||
@@ -1162,10 +900,7 @@
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="approve-tx-error"
|
||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem] hidden"
|
||||
></div>
|
||||
<div id="approve-tx-error" class="text-xs hidden mb-2"></div>
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
id="btn-approve-tx"
|
||||
@@ -1190,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>
|
||||
@@ -1228,10 +952,7 @@
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="approve-sign-error"
|
||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem] hidden"
|
||||
></div>
|
||||
<div id="approve-sign-error" class="text-xs hidden mb-2"></div>
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
id="btn-approve-sign"
|
||||
|
||||
@@ -10,6 +10,7 @@ const { $, showView } = require("./views/helpers");
|
||||
const home = require("./views/home");
|
||||
const welcome = require("./views/welcome");
|
||||
const addWallet = require("./views/addWallet");
|
||||
const importKey = require("./views/importKey");
|
||||
const addressDetail = require("./views/addressDetail");
|
||||
const addressToken = require("./views/addressToken");
|
||||
const send = require("./views/send");
|
||||
@@ -19,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() {
|
||||
@@ -53,14 +53,13 @@ const ctx = {
|
||||
renderWalletList,
|
||||
doRefreshAndRender,
|
||||
showAddWalletView: () => addWallet.show(),
|
||||
showImportKeyView: () => importKey.show(),
|
||||
showAddressDetail: () => addressDetail.show(),
|
||||
showAddressToken: () => addressToken.show(),
|
||||
showAddTokenView: () => addToken.show(),
|
||||
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.
|
||||
@@ -71,8 +70,6 @@ const RESTORABLE_VIEWS = new Set([
|
||||
"address-token",
|
||||
"receive",
|
||||
"settings",
|
||||
"settings-addtoken",
|
||||
"confirm-tx",
|
||||
"transaction",
|
||||
"success-tx",
|
||||
"error-tx",
|
||||
@@ -123,16 +120,6 @@ function restoreView() {
|
||||
case "settings":
|
||||
settings.show();
|
||||
break;
|
||||
case "settings-addtoken":
|
||||
settingsAddToken.show();
|
||||
break;
|
||||
case "confirm-tx":
|
||||
if (state.viewData && state.viewData.pendingTx) {
|
||||
confirmTx.restore();
|
||||
} else {
|
||||
fallbackView();
|
||||
}
|
||||
break;
|
||||
case "transaction":
|
||||
if (state.viewData && state.viewData.tx) {
|
||||
transactionDetail.render();
|
||||
@@ -195,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;
|
||||
}
|
||||
@@ -215,6 +202,7 @@ async function init() {
|
||||
|
||||
welcome.init(ctx);
|
||||
addWallet.init(ctx);
|
||||
importKey.init(ctx);
|
||||
home.init(ctx);
|
||||
addressDetail.init(ctx);
|
||||
addressToken.init(ctx);
|
||||
@@ -224,7 +212,6 @@ async function init() {
|
||||
receive.init(ctx);
|
||||
addToken.init(ctx);
|
||||
settings.init(ctx);
|
||||
settingsAddToken.init(ctx);
|
||||
|
||||
if (!state.hasWallet) {
|
||||
showView("welcome");
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
--color-border-light: #cccccc;
|
||||
--color-hover: #eeeeee;
|
||||
--color-well: #f5f5f5;
|
||||
--color-danger-well: #fef2f2;
|
||||
--color-section: #dddddd;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,285 +3,114 @@ const {
|
||||
generateMnemonic,
|
||||
hdWalletFromMnemonic,
|
||||
isValidMnemonic,
|
||||
addressFromPrivateKey,
|
||||
hdWalletFromXprv,
|
||||
isValidXprv,
|
||||
} = require("../../shared/wallet");
|
||||
const { encryptWithPassword } = require("../../shared/vault");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { scanForAddresses } = require("../../shared/balances");
|
||||
|
||||
let currentMode = "mnemonic";
|
||||
|
||||
const MODES = ["mnemonic", "privkey", "xprv"];
|
||||
|
||||
const PASSWORD_HINTS = {
|
||||
mnemonic:
|
||||
"This password encrypts your recovery phrase on this device. You will need it to send funds.",
|
||||
privkey:
|
||||
"This password encrypts your private key on this device. You will need it to send funds.",
|
||||
xprv: "This password encrypts your key on this device. You will need it to send funds.",
|
||||
};
|
||||
|
||||
function switchMode(mode) {
|
||||
currentMode = mode;
|
||||
for (const m of MODES) {
|
||||
$("add-wallet-section-" + m).classList.toggle("hidden", m !== mode);
|
||||
const tab = $("tab-" + m);
|
||||
const isActive = m === mode;
|
||||
// Active: bold, solid border on top/sides, no bottom border (connects to content)
|
||||
tab.classList.toggle("font-bold", isActive);
|
||||
tab.classList.toggle("border-solid", isActive);
|
||||
tab.classList.toggle("border-border", isActive);
|
||||
tab.classList.toggle("border-b-bg", isActive);
|
||||
tab.classList.toggle("bg-bg", isActive);
|
||||
// Inactive: muted text, dashed border on top/sides, transparent bottom, hover invert
|
||||
tab.classList.toggle("text-muted", !isActive);
|
||||
tab.classList.toggle("border-dashed", !isActive);
|
||||
tab.classList.toggle("border-border-light", !isActive);
|
||||
tab.classList.toggle("border-b-transparent", !isActive);
|
||||
tab.classList.toggle("hover:bg-fg", !isActive);
|
||||
tab.classList.toggle("hover:text-bg", !isActive);
|
||||
}
|
||||
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
|
||||
}
|
||||
|
||||
function show() {
|
||||
$("wallet-mnemonic").value = "";
|
||||
$("import-private-key").value = "";
|
||||
$("import-xprv-key").value = "";
|
||||
$("add-wallet-password").value = "";
|
||||
$("add-wallet-password-confirm").value = "";
|
||||
$("add-wallet-phrase-warning").classList.add("hidden");
|
||||
switchMode("mnemonic");
|
||||
showView("add-wallet");
|
||||
}
|
||||
|
||||
function validatePassword() {
|
||||
const pw = $("add-wallet-password").value;
|
||||
const pw2 = $("add-wallet-password-confirm").value;
|
||||
if (!pw) {
|
||||
showFlash("Please choose a password.");
|
||||
return null;
|
||||
}
|
||||
if (pw.length < 12) {
|
||||
showFlash("Password must be at least 12 characters.");
|
||||
return null;
|
||||
}
|
||||
if (pw !== pw2) {
|
||||
showFlash("Passwords do not match.");
|
||||
return null;
|
||||
}
|
||||
return pw;
|
||||
}
|
||||
|
||||
async function importMnemonic(ctx) {
|
||||
const mnemonic = $("wallet-mnemonic").value.trim();
|
||||
if (!mnemonic) {
|
||||
showFlash("Enter a recovery phrase or press the die to generate one.");
|
||||
return;
|
||||
}
|
||||
const words = mnemonic.split(/\s+/);
|
||||
if (words.length !== 12 && words.length !== 24) {
|
||||
showFlash(
|
||||
"Recovery phrase must be 12 or 24 words. You entered " +
|
||||
words.length +
|
||||
".",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isValidMnemonic(mnemonic)) {
|
||||
showFlash("Invalid recovery phrase. Check for typos.");
|
||||
return;
|
||||
}
|
||||
const pw = validatePassword();
|
||||
if (!pw) return;
|
||||
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
|
||||
const duplicate = state.wallets.find(
|
||||
(w) =>
|
||||
w.type === "hd" &&
|
||||
w.addresses[0] &&
|
||||
w.addresses[0].address.toLowerCase() === firstAddress.toLowerCase(),
|
||||
);
|
||||
if (duplicate) {
|
||||
showFlash(
|
||||
"This recovery phrase is already added (" + duplicate.name + ").",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const encrypted = await encryptWithPassword(mnemonic, pw);
|
||||
const walletNum = state.wallets.length + 1;
|
||||
const wallet = {
|
||||
type: "hd",
|
||||
name: "Wallet " + walletNum,
|
||||
xpub: xpub,
|
||||
encryptedSecret: encrypted,
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
|
||||
],
|
||||
};
|
||||
state.wallets.push(wallet);
|
||||
state.hasWallet = true;
|
||||
await saveState();
|
||||
ctx.renderWalletList();
|
||||
showView("main");
|
||||
|
||||
// Scan for used HD addresses beyond index 0.
|
||||
showFlash("Scanning for addresses...", 30000);
|
||||
const scan = await scanForAddresses(xpub, state.rpcUrl);
|
||||
if (scan.addresses.length > 1) {
|
||||
wallet.addresses = scan.addresses.map((a) => ({
|
||||
address: a.address,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
}));
|
||||
wallet.nextIndex = scan.nextIndex;
|
||||
await saveState();
|
||||
ctx.renderWalletList();
|
||||
showFlash("Found " + scan.addresses.length + " addresses.");
|
||||
} else {
|
||||
showFlash("Ready.", 1000);
|
||||
}
|
||||
|
||||
ctx.doRefreshAndRender();
|
||||
}
|
||||
|
||||
async function importPrivateKey(ctx) {
|
||||
const key = $("import-private-key").value.trim();
|
||||
if (!key) {
|
||||
showFlash("Please enter your private key.");
|
||||
return;
|
||||
}
|
||||
let addr;
|
||||
try {
|
||||
addr = addressFromPrivateKey(key);
|
||||
} catch (e) {
|
||||
showFlash("Invalid private key.");
|
||||
return;
|
||||
}
|
||||
const pw = validatePassword();
|
||||
if (!pw) return;
|
||||
const duplicate = state.wallets.find(
|
||||
(w) =>
|
||||
w.type === "key" &&
|
||||
w.addresses[0] &&
|
||||
w.addresses[0].address.toLowerCase() === addr.toLowerCase(),
|
||||
);
|
||||
if (duplicate) {
|
||||
showFlash(
|
||||
"This private key is already added (" + duplicate.name + ").",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const encrypted = await encryptWithPassword(key, pw);
|
||||
const walletNum = state.wallets.length + 1;
|
||||
state.wallets.push({
|
||||
type: "key",
|
||||
name: "Wallet " + walletNum,
|
||||
encryptedSecret: encrypted,
|
||||
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
|
||||
});
|
||||
state.hasWallet = true;
|
||||
await saveState();
|
||||
ctx.renderWalletList();
|
||||
showView("main");
|
||||
|
||||
ctx.doRefreshAndRender();
|
||||
}
|
||||
|
||||
async function importXprvKey(ctx) {
|
||||
const xprv = $("import-xprv-key").value.trim();
|
||||
if (!xprv) {
|
||||
showFlash("Please enter your extended private key.");
|
||||
return;
|
||||
}
|
||||
if (!isValidXprv(xprv)) {
|
||||
showFlash("Invalid extended private key.");
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = hdWalletFromXprv(xprv);
|
||||
} catch (e) {
|
||||
showFlash("Invalid extended private key.");
|
||||
return;
|
||||
}
|
||||
const { xpub, firstAddress } = result;
|
||||
const duplicate = state.wallets.find(
|
||||
(w) =>
|
||||
(w.type === "hd" || w.type === "xprv") &&
|
||||
w.addresses[0] &&
|
||||
w.addresses[0].address.toLowerCase() === firstAddress.toLowerCase(),
|
||||
);
|
||||
if (duplicate) {
|
||||
showFlash("This key is already added (" + duplicate.name + ").");
|
||||
return;
|
||||
}
|
||||
const pw = validatePassword();
|
||||
if (!pw) return;
|
||||
const encrypted = await encryptWithPassword(xprv, pw);
|
||||
const walletNum = state.wallets.length + 1;
|
||||
const wallet = {
|
||||
type: "xprv",
|
||||
name: "Wallet " + walletNum,
|
||||
xpub: xpub,
|
||||
encryptedSecret: encrypted,
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
|
||||
],
|
||||
};
|
||||
state.wallets.push(wallet);
|
||||
state.hasWallet = true;
|
||||
await saveState();
|
||||
ctx.renderWalletList();
|
||||
showView("main");
|
||||
|
||||
// Scan for used HD addresses beyond index 0.
|
||||
showFlash("Scanning for addresses...", 30000);
|
||||
const scan = await scanForAddresses(xpub, state.rpcUrl);
|
||||
if (scan.addresses.length > 1) {
|
||||
wallet.addresses = scan.addresses.map((a) => ({
|
||||
address: a.address,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
}));
|
||||
wallet.nextIndex = scan.nextIndex;
|
||||
await saveState();
|
||||
ctx.renderWalletList();
|
||||
showFlash("Found " + scan.addresses.length + " addresses.");
|
||||
} else {
|
||||
showFlash("Ready.", 1000);
|
||||
}
|
||||
|
||||
ctx.doRefreshAndRender();
|
||||
}
|
||||
|
||||
function init(ctx) {
|
||||
// Tab click handlers
|
||||
$("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic"));
|
||||
$("tab-privkey").addEventListener("click", () => switchMode("privkey"));
|
||||
$("tab-xprv").addEventListener("click", () => switchMode("xprv"));
|
||||
|
||||
// Generate mnemonic
|
||||
$("btn-generate-phrase").addEventListener("click", () => {
|
||||
$("wallet-mnemonic").value = generateMnemonic();
|
||||
$("add-wallet-phrase-warning").classList.remove("hidden");
|
||||
});
|
||||
|
||||
// Import / confirm
|
||||
$("btn-add-wallet-confirm").addEventListener("click", async () => {
|
||||
if (currentMode === "mnemonic") {
|
||||
await importMnemonic(ctx);
|
||||
} else if (currentMode === "privkey") {
|
||||
await importPrivateKey(ctx);
|
||||
} else if (currentMode === "xprv") {
|
||||
await importXprvKey(ctx);
|
||||
const mnemonic = $("wallet-mnemonic").value.trim();
|
||||
if (!mnemonic) {
|
||||
showFlash(
|
||||
"Enter a recovery phrase or press the die to generate one.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const words = mnemonic.split(/\s+/);
|
||||
if (words.length !== 12 && words.length !== 24) {
|
||||
showFlash(
|
||||
"Recovery phrase must be 12 or 24 words. You entered " +
|
||||
words.length +
|
||||
".",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isValidMnemonic(mnemonic)) {
|
||||
showFlash("Invalid recovery phrase. Check for typos.");
|
||||
return;
|
||||
}
|
||||
const pw = $("add-wallet-password").value;
|
||||
const pw2 = $("add-wallet-password-confirm").value;
|
||||
if (!pw) {
|
||||
showFlash("Please choose a password.");
|
||||
return;
|
||||
}
|
||||
if (pw.length < 8) {
|
||||
showFlash("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (pw !== pw2) {
|
||||
showFlash("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
|
||||
const duplicate = state.wallets.find(
|
||||
(w) =>
|
||||
w.type === "hd" &&
|
||||
w.addresses[0] &&
|
||||
w.addresses[0].address.toLowerCase() ===
|
||||
firstAddress.toLowerCase(),
|
||||
);
|
||||
if (duplicate) {
|
||||
showFlash(
|
||||
"This recovery phrase is already added (" +
|
||||
duplicate.name +
|
||||
").",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const encrypted = await encryptWithPassword(mnemonic, pw);
|
||||
const walletNum = state.wallets.length + 1;
|
||||
const wallet = {
|
||||
type: "hd",
|
||||
name: "Wallet " + walletNum,
|
||||
xpub: xpub,
|
||||
encryptedSecret: encrypted,
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
|
||||
],
|
||||
};
|
||||
state.wallets.push(wallet);
|
||||
state.hasWallet = true;
|
||||
await saveState();
|
||||
ctx.renderWalletList();
|
||||
showView("main");
|
||||
|
||||
// Scan for used HD addresses beyond index 0.
|
||||
showFlash("Scanning for addresses...", 30000);
|
||||
const scan = await scanForAddresses(xpub, state.rpcUrl);
|
||||
if (scan.addresses.length > 1) {
|
||||
wallet.addresses = scan.addresses.map((a) => ({
|
||||
address: a.address,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
}));
|
||||
wallet.nextIndex = scan.nextIndex;
|
||||
await saveState();
|
||||
ctx.renderWalletList();
|
||||
showFlash("Found " + scan.addresses.length + " addresses.");
|
||||
} else {
|
||||
showFlash("Ready.", 1000);
|
||||
}
|
||||
|
||||
ctx.doRefreshAndRender();
|
||||
});
|
||||
|
||||
// Back button
|
||||
$("btn-add-wallet-back").addEventListener("click", () => {
|
||||
if (!state.hasWallet) {
|
||||
showView("welcome");
|
||||
@@ -290,6 +119,11 @@ function init(ctx) {
|
||||
showView("main");
|
||||
}
|
||||
});
|
||||
|
||||
$("btn-add-wallet-import-key").addEventListener(
|
||||
"click",
|
||||
ctx.showImportKeyView,
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { init, show };
|
||||
|
||||
@@ -4,7 +4,6 @@ const {
|
||||
showFlash,
|
||||
balanceLinesForAddress,
|
||||
addressDotHtml,
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
truncateMiddle,
|
||||
} = require("./helpers");
|
||||
@@ -15,15 +14,9 @@ const {
|
||||
filterTransactions,
|
||||
} = require("../../shared/transactions");
|
||||
const { resolveEnsNames } = require("../../shared/ens");
|
||||
const {
|
||||
updateSendBalance,
|
||||
renderSendTokenSelect,
|
||||
resetSendValidation,
|
||||
} = require("./send");
|
||||
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;
|
||||
|
||||
@@ -61,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 || " ";
|
||||
$("address-usd-total").textContent = formatUsd(getAddressValueUsd(addr));
|
||||
const ensEl = $("address-ens");
|
||||
if (addr.ensName) {
|
||||
ensEl.innerHTML =
|
||||
@@ -157,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 {
|
||||
@@ -192,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)" : "";
|
||||
@@ -263,7 +246,6 @@ function init(_ctx) {
|
||||
$("send-token").classList.remove("hidden");
|
||||
$("send-token-static").classList.add("hidden");
|
||||
updateSendBalance();
|
||||
resetSendValidation();
|
||||
showView("send");
|
||||
});
|
||||
|
||||
@@ -272,108 +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 btn = $("btn-export-privkey-confirm");
|
||||
btn.disabled = true;
|
||||
btn.classList.add("text-muted");
|
||||
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");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("text-muted");
|
||||
}
|
||||
});
|
||||
|
||||
$("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 };
|
||||
|
||||
@@ -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,
|
||||
@@ -23,11 +21,7 @@ const {
|
||||
filterTransactions,
|
||||
} = require("../../shared/transactions");
|
||||
const { resolveEnsNames } = require("../../shared/ens");
|
||||
const {
|
||||
updateSendBalance,
|
||||
renderSendTokenSelect,
|
||||
resetSendValidation,
|
||||
} = require("./send");
|
||||
const { updateSendBalance, renderSendTokenSelect } = require("./send");
|
||||
const { log } = require("../../shared/log");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
|
||||
@@ -91,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");
|
||||
@@ -100,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);
|
||||
}
|
||||
@@ -135,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 || " ";
|
||||
$("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>';
|
||||
@@ -242,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 {
|
||||
@@ -278,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)" : "";
|
||||
@@ -320,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();
|
||||
});
|
||||
@@ -376,7 +299,6 @@ function init(_ctx) {
|
||||
});
|
||||
}
|
||||
updateSendBalance();
|
||||
resetSendValidation();
|
||||
showView("send");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
const {
|
||||
$,
|
||||
addressDotHtml,
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
showView,
|
||||
showError,
|
||||
hideError,
|
||||
} = 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;
|
||||
@@ -30,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) {
|
||||
@@ -57,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) {
|
||||
@@ -172,41 +134,20 @@ function showTxApproval(details) {
|
||||
// If this is an ERC-20 call, try to extract the real recipient and amount
|
||||
const decoded = decodeCalldata(details.txParams.data, toAddr || "");
|
||||
if (decoded && decoded.details) {
|
||||
let decodedTokenAddr = null;
|
||||
let decodedTokenSymbol = null;
|
||||
for (const d of decoded.details) {
|
||||
if (d.label === "Recipient" && d.address) {
|
||||
pendingTxDetails.to = d.address;
|
||||
}
|
||||
if (d.label === "Amount") {
|
||||
pendingTxDetails.amount = d.rawValue || d.value;
|
||||
}
|
||||
if (d.label === "Token In" && d.isToken && d.address) {
|
||||
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
|
||||
if (t) {
|
||||
decodedTokenAddr = d.address;
|
||||
decodedTokenSymbol = t.symbol;
|
||||
}
|
||||
pendingTxDetails.amount = d.value;
|
||||
}
|
||||
}
|
||||
if (token) {
|
||||
pendingTxDetails.token = toAddr;
|
||||
pendingTxDetails.tokenSymbol = token.symbol;
|
||||
} else if (decodedTokenAddr) {
|
||||
pendingTxDetails.token = decodedTokenAddr;
|
||||
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -268,9 +209,6 @@ function showTxApproval(details) {
|
||||
$("approve-tx-data-section").classList.add("hidden");
|
||||
}
|
||||
|
||||
$("approve-tx-password").value = "";
|
||||
$("approve-tx-error").classList.add("hidden");
|
||||
|
||||
showView("approve-tx");
|
||||
}
|
||||
|
||||
@@ -346,20 +284,8 @@ 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 = "";
|
||||
hideError("approve-sign-error");
|
||||
$("approve-sign-error").classList.add("hidden");
|
||||
$("btn-approve-sign").disabled = false;
|
||||
$("btn-approve-sign").classList.remove("text-muted");
|
||||
|
||||
@@ -424,10 +350,11 @@ function init(ctx) {
|
||||
$("btn-approve-tx").addEventListener("click", () => {
|
||||
const password = $("approve-tx-password").value;
|
||||
if (!password) {
|
||||
showError("approve-tx-error", "Please enter your password.");
|
||||
$("approve-tx-error").textContent = "Please enter your password.";
|
||||
$("approve-tx-error").classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
hideError("approve-tx-error");
|
||||
$("approve-tx-error").classList.add("hidden");
|
||||
$("btn-approve-tx").disabled = true;
|
||||
$("btn-approve-tx").classList.add("text-muted");
|
||||
|
||||
@@ -436,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) => {
|
||||
@@ -463,10 +389,11 @@ function init(ctx) {
|
||||
$("btn-approve-sign").addEventListener("click", () => {
|
||||
const password = $("approve-sign-password").value;
|
||||
if (!password) {
|
||||
showError("approve-sign-error", "Please enter your password.");
|
||||
$("approve-sign-error").textContent = "Please enter your password.";
|
||||
$("approve-sign-error").classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
hideError("approve-sign-error");
|
||||
$("approve-sign-error").classList.add("hidden");
|
||||
$("btn-approve-sign").disabled = true;
|
||||
$("btn-approve-sign").classList.add("text-muted");
|
||||
|
||||
@@ -475,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) => {
|
||||
@@ -484,7 +410,8 @@ function init(ctx) {
|
||||
} else {
|
||||
const msg =
|
||||
(response && response.error) || "Signing failed.";
|
||||
showError("approve-sign-error", msg);
|
||||
$("approve-sign-error").textContent = msg;
|
||||
$("approve-sign-error").classList.remove("hidden");
|
||||
$("btn-approve-sign").disabled = false;
|
||||
$("btn-approve-sign").classList.remove("text-muted");
|
||||
}
|
||||
@@ -502,4 +429,4 @@ function init(ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show, decodeCalldata };
|
||||
module.exports = { init, show };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Transaction confirmation view with inline password.
|
||||
// Shows transaction details, warnings, errors. On Sign & Send,
|
||||
// reads inline password, decrypts secret, signs and broadcasts.
|
||||
// Transaction confirmation view + password modal.
|
||||
// Shows transaction details, warnings, errors. On proceed, opens
|
||||
// password modal, decrypts secret, signs and broadcasts.
|
||||
|
||||
const {
|
||||
parseEther,
|
||||
@@ -14,7 +14,6 @@ const {
|
||||
showError,
|
||||
hideError,
|
||||
showView,
|
||||
showFlash,
|
||||
addressTitle,
|
||||
addressDotHtml,
|
||||
escapeHtml,
|
||||
@@ -25,7 +24,7 @@ const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const { isScamAddress } = require("../../shared/scamlist");
|
||||
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
|
||||
const { ERC20_ABI } = require("../../shared/constants");
|
||||
const { log } = require("../../shared/log");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const txStatus = require("./txStatus");
|
||||
@@ -39,13 +38,6 @@ const EXT_ICON =
|
||||
|
||||
let pendingTx = null;
|
||||
|
||||
function restore() {
|
||||
const d = state.viewData;
|
||||
if (d && d.pendingTx) {
|
||||
show(d.pendingTx);
|
||||
}
|
||||
}
|
||||
|
||||
function etherscanTokenLink(address) {
|
||||
return `https://etherscan.io/token/${address}`;
|
||||
}
|
||||
@@ -103,22 +95,11 @@ function show(txInfo) {
|
||||
// Token contract section (ERC-20 only)
|
||||
const tokenSection = $("confirm-token-section");
|
||||
if (isErc20) {
|
||||
const dot = addressDotHtml(txInfo.token);
|
||||
const link = etherscanTokenLink(txInfo.token);
|
||||
$("confirm-token-contract").innerHTML =
|
||||
`<div class="flex items-center">${dot}` +
|
||||
`<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(txInfo.token)}">${escapeHtml(txInfo.token)}</span>` +
|
||||
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
|
||||
`</div>`;
|
||||
escapeHtml(txInfo.token) +
|
||||
` <a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
|
||||
tokenSection.classList.remove("hidden");
|
||||
// Attach click-to-copy on the contract address
|
||||
const copyEl = tokenSection.querySelector("[data-copy]");
|
||||
if (copyEl) {
|
||||
copyEl.onclick = () => {
|
||||
navigator.clipboard.writeText(copyEl.dataset.copy);
|
||||
showFlash("Copied!");
|
||||
};
|
||||
}
|
||||
} else {
|
||||
tokenSection.classList.add("hidden");
|
||||
}
|
||||
@@ -165,18 +146,13 @@ function show(txInfo) {
|
||||
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
|
||||
}
|
||||
|
||||
// Check for warnings (synchronous checks)
|
||||
// Check for warnings
|
||||
const warnings = [];
|
||||
if (isScamAddress(txInfo.to)) {
|
||||
warnings.push(
|
||||
"This address is on a known scam/fraud list. Do not send funds to this address.",
|
||||
);
|
||||
}
|
||||
if (isBurnAddress(txInfo.to)) {
|
||||
warnings.push(
|
||||
"This is a known null/burn address. Funds sent here are permanently destroyed and cannot be recovered.",
|
||||
);
|
||||
}
|
||||
if (txInfo.to.toLowerCase() === txInfo.from.toLowerCase()) {
|
||||
warnings.push("You are sending to your own address.");
|
||||
}
|
||||
@@ -238,28 +214,12 @@ function show(txInfo) {
|
||||
sendBtn.classList.remove("text-muted");
|
||||
}
|
||||
|
||||
// Reset password field and error
|
||||
$("confirm-tx-password").value = "";
|
||||
hideError("confirm-tx-password-error");
|
||||
|
||||
// Gas estimate — show placeholder then fetch async
|
||||
$("confirm-fee").classList.remove("hidden");
|
||||
$("confirm-fee-amount").textContent = "Estimating...";
|
||||
state.viewData = { pendingTx: txInfo };
|
||||
showView("confirm-tx");
|
||||
|
||||
// Reset async warnings to hidden (space always reserved, no layout shift)
|
||||
$("confirm-recipient-warning").style.visibility = "hidden";
|
||||
$("confirm-contract-warning").style.visibility = "hidden";
|
||||
$("confirm-burn-warning").style.visibility = "hidden";
|
||||
|
||||
// Show burn warning via reserved element (in addition to inline warning)
|
||||
if (isBurnAddress(txInfo.to)) {
|
||||
$("confirm-burn-warning").style.visibility = "visible";
|
||||
}
|
||||
|
||||
estimateGas(txInfo);
|
||||
checkRecipientHistory(txInfo);
|
||||
}
|
||||
|
||||
async function estimateGas(txInfo) {
|
||||
@@ -302,38 +262,39 @@ async function estimateGas(txInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRecipientHistory(txInfo) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const code = await provider.getCode(txInfo.to);
|
||||
if (code && code !== "0x") {
|
||||
// Recipient is a contract — warn the user
|
||||
$("confirm-contract-warning").style.visibility = "visible";
|
||||
return;
|
||||
}
|
||||
const txCount = await provider.getTransactionCount(txInfo.to);
|
||||
if (txCount === 0) {
|
||||
$("confirm-recipient-warning").style.visibility = "visible";
|
||||
}
|
||||
} catch (e) {
|
||||
log.errorf("recipient history check failed:", e.message);
|
||||
}
|
||||
function showPasswordModal() {
|
||||
$("modal-password").value = "";
|
||||
hideError("modal-password-error");
|
||||
$("password-modal").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hidePasswordModal() {
|
||||
$("password-modal").classList.add("hidden");
|
||||
}
|
||||
|
||||
function init(ctx) {
|
||||
$("btn-confirm-send").addEventListener("click", async () => {
|
||||
const password = $("confirm-tx-password").value;
|
||||
$("btn-confirm-send").addEventListener("click", () => {
|
||||
showPasswordModal();
|
||||
});
|
||||
|
||||
$("btn-confirm-back").addEventListener("click", () => {
|
||||
showView("send");
|
||||
});
|
||||
|
||||
$("btn-modal-cancel").addEventListener("click", () => {
|
||||
hidePasswordModal();
|
||||
});
|
||||
|
||||
$("btn-modal-confirm").addEventListener("click", async () => {
|
||||
const password = $("modal-password").value;
|
||||
if (!password) {
|
||||
showError(
|
||||
"confirm-tx-password-error",
|
||||
"Please enter your password.",
|
||||
);
|
||||
showError("modal-password-error", "Please enter your password.");
|
||||
return;
|
||||
}
|
||||
|
||||
const wallet = state.wallets[state.selectedWallet];
|
||||
let decryptedSecret;
|
||||
hideError("confirm-tx-password-error");
|
||||
hideError("modal-password-error");
|
||||
|
||||
try {
|
||||
decryptedSecret = await decryptWithPassword(
|
||||
@@ -341,12 +302,11 @@ function init(ctx) {
|
||||
password,
|
||||
);
|
||||
} catch (e) {
|
||||
showError("confirm-tx-password-error", "Wrong password.");
|
||||
showError("modal-password-error", "Wrong password.");
|
||||
return;
|
||||
}
|
||||
|
||||
$("btn-confirm-send").disabled = true;
|
||||
$("btn-confirm-send").classList.add("text-muted");
|
||||
hidePasswordModal();
|
||||
|
||||
let tx;
|
||||
try {
|
||||
@@ -374,24 +334,12 @@ 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);
|
||||
} finally {
|
||||
$("btn-confirm-send").disabled = false;
|
||||
$("btn-confirm-send").classList.remove("text-muted");
|
||||
}
|
||||
});
|
||||
|
||||
$("btn-confirm-back").addEventListener("click", () => {
|
||||
showView("send");
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show, restore };
|
||||
module.exports = { init, show };
|
||||
|
||||
@@ -1,96 +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 btn = $("btn-delete-wallet-confirm");
|
||||
btn.disabled = true;
|
||||
btn.classList.add("text-muted");
|
||||
|
||||
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");
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("text-muted");
|
||||
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 };
|
||||
@@ -8,11 +8,10 @@ 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",
|
||||
"import-key",
|
||||
"main",
|
||||
"address",
|
||||
"address-token",
|
||||
@@ -24,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) {
|
||||
@@ -86,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) || " " : " ";
|
||||
const usd = price ? formatUsd(amount * price) : "";
|
||||
const tokenAttr = tokenId ? ` data-token="${tokenId}"` : "";
|
||||
const clickClass = tokenId
|
||||
? " cursor-pointer hover:bg-hover balance-row"
|
||||
@@ -135,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));
|
||||
}
|
||||
@@ -223,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,
|
||||
@@ -272,6 +224,4 @@ module.exports = {
|
||||
addressTitle,
|
||||
formatAddressHtml,
|
||||
truncateMiddle,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
};
|
||||
|
||||
@@ -3,19 +3,12 @@ const {
|
||||
showView,
|
||||
showFlash,
|
||||
balanceLinesForAddress,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
addressDotHtml,
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
truncateMiddle,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentAddress } = require("../../shared/state");
|
||||
const {
|
||||
updateSendBalance,
|
||||
renderSendTokenSelect,
|
||||
resetSendValidation,
|
||||
} = require("./send");
|
||||
const { updateSendBalance, renderSendTokenSelect } = require("./send");
|
||||
const { deriveAddressFromXpub } = require("../../shared/wallet");
|
||||
const {
|
||||
formatUsd,
|
||||
@@ -45,15 +38,13 @@ function renderTotalValue() {
|
||||
|
||||
const ethPrice = getPrice("ETH");
|
||||
if (priceEl) {
|
||||
priceEl.innerHTML = ethPrice
|
||||
? formatUsd(ethPrice) + " USD/ETH"
|
||||
: " ";
|
||||
priceEl.textContent = ethPrice ? formatUsd(ethPrice) + " USD/ETH" : "";
|
||||
}
|
||||
|
||||
const addr = findActiveAddr();
|
||||
if (!addr) {
|
||||
el.innerHTML = " ";
|
||||
if (subEl) subEl.innerHTML = " ";
|
||||
el.textContent = "";
|
||||
if (subEl) subEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
const ethBal = parseFloat(addr.balance || "0");
|
||||
@@ -63,8 +54,8 @@ function renderTotalValue() {
|
||||
|
||||
if (subEl) {
|
||||
const totalUsd = getAddressValueUsd(addr);
|
||||
subEl.innerHTML =
|
||||
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : " ";
|
||||
subEl.textContent =
|
||||
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,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) {
|
||||
@@ -107,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)" : "";
|
||||
@@ -239,7 +256,7 @@ function render(ctx) {
|
||||
html += `<div>`;
|
||||
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
|
||||
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
|
||||
if (wallet.type === "hd" || wallet.type === "xprv") {
|
||||
if (wallet.type === "hd") {
|
||||
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
@@ -259,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 || " "}</div>`;
|
||||
html += `<div class="text-xs text-muted text-right">${addrUsd}</div>`;
|
||||
html += balanceLinesForAddress(
|
||||
addr,
|
||||
state.trackedTokens,
|
||||
@@ -392,7 +409,6 @@ function init(ctx) {
|
||||
$("send-token-static").classList.add("hidden");
|
||||
renderSendTokenSelect(addr);
|
||||
updateSendBalance();
|
||||
resetSendValidation();
|
||||
showView("send");
|
||||
});
|
||||
|
||||
|
||||
69
src/popup/views/importKey.js
Normal file
69
src/popup/views/importKey.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const { $, showView, showFlash } = require("./helpers");
|
||||
const { addressFromPrivateKey } = require("../../shared/wallet");
|
||||
const { encryptWithPassword } = require("../../shared/vault");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
|
||||
function show() {
|
||||
$("import-private-key").value = "";
|
||||
$("import-key-password").value = "";
|
||||
$("import-key-password-confirm").value = "";
|
||||
showView("import-key");
|
||||
}
|
||||
|
||||
function init(ctx) {
|
||||
$("btn-import-key-confirm").addEventListener("click", async () => {
|
||||
const key = $("import-private-key").value.trim();
|
||||
if (!key) {
|
||||
showFlash("Please enter your private key.");
|
||||
return;
|
||||
}
|
||||
let addr;
|
||||
try {
|
||||
addr = addressFromPrivateKey(key);
|
||||
} catch (e) {
|
||||
showFlash("Invalid private key.");
|
||||
return;
|
||||
}
|
||||
const pw = $("import-key-password").value;
|
||||
const pw2 = $("import-key-password-confirm").value;
|
||||
if (!pw) {
|
||||
showFlash("Please choose a password.");
|
||||
return;
|
||||
}
|
||||
if (pw.length < 8) {
|
||||
showFlash("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (pw !== pw2) {
|
||||
showFlash("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
const encrypted = await encryptWithPassword(key, pw);
|
||||
const walletNum = state.wallets.length + 1;
|
||||
state.wallets.push({
|
||||
type: "key",
|
||||
name: "Wallet " + walletNum,
|
||||
encryptedSecret: encrypted,
|
||||
addresses: [
|
||||
{ address: addr, balance: "0.0000", tokenBalances: [] },
|
||||
],
|
||||
});
|
||||
state.hasWallet = true;
|
||||
await saveState();
|
||||
ctx.renderWalletList();
|
||||
showView("main");
|
||||
|
||||
ctx.doRefreshAndRender();
|
||||
});
|
||||
|
||||
$("btn-import-key-back").addEventListener("click", () => {
|
||||
if (!state.hasWallet) {
|
||||
showView("welcome");
|
||||
} else {
|
||||
ctx.renderWalletList();
|
||||
showView("main");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show };
|
||||
@@ -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!");
|
||||
|
||||
@@ -1,117 +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 { getAddress } = require("ethers");
|
||||
|
||||
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
||||
|
||||
/**
|
||||
* Validate a destination address string.
|
||||
* Returns { valid: true } or { valid: false, error: "..." }.
|
||||
*/
|
||||
function validateToAddress(value) {
|
||||
const v = value.trim();
|
||||
if (!v) return { valid: false, error: "" };
|
||||
|
||||
// ENS names: contains a dot and doesn't start with 0x
|
||||
if (v.includes(".") && !v.startsWith("0x")) {
|
||||
// Basic ENS format check: at least one label before and after dot
|
||||
if (/^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$/.test(v)) {
|
||||
return { valid: true };
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
error: "Please enter a valid ENS name.",
|
||||
};
|
||||
}
|
||||
|
||||
// Must look like an Ethereum address
|
||||
if (!/^0x[0-9a-fA-F]{40}$/.test(v)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Please enter a valid Ethereum address.",
|
||||
};
|
||||
}
|
||||
|
||||
// Reject zero address
|
||||
if (v.toLowerCase() === ZERO_ADDRESS) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Sending to the zero address is not allowed.",
|
||||
};
|
||||
}
|
||||
|
||||
// EIP-55 checksum validation: all-lowercase is ok, otherwise must match checksum
|
||||
if (v !== v.toLowerCase()) {
|
||||
try {
|
||||
const checksummed = getAddress(v);
|
||||
if (checksummed !== v) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Address checksum is invalid. Please double-check the address.",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Address checksum is invalid. Please double-check the address.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if sending to own address
|
||||
const addr = currentAddress();
|
||||
if (addr && v.toLowerCase() === addr.address.toLowerCase()) {
|
||||
// Allow but will warn — we return valid with a warning
|
||||
return {
|
||||
valid: true,
|
||||
warning: "This is your own address. Are you sure?",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function updateToValidation() {
|
||||
const input = $("send-to");
|
||||
const errorEl = $("send-to-error");
|
||||
const btn = $("btn-send-review");
|
||||
const value = input.value.trim();
|
||||
|
||||
if (!value) {
|
||||
errorEl.textContent = "";
|
||||
btn.disabled = true;
|
||||
btn.classList.add("opacity-50");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = validateToAddress(value);
|
||||
if (!result.valid) {
|
||||
errorEl.textContent = result.error;
|
||||
errorEl.style.color = "#cc0000";
|
||||
btn.disabled = true;
|
||||
btn.classList.add("opacity-50");
|
||||
} else if (result.warning) {
|
||||
errorEl.textContent = result.warning;
|
||||
errorEl.style.color = "#b8860b";
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("opacity-50");
|
||||
} else {
|
||||
errorEl.textContent = "";
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("opacity-50");
|
||||
}
|
||||
}
|
||||
const { KNOWN_SYMBOLS } = require("../../shared/tokenList");
|
||||
|
||||
const EXT_ICON =
|
||||
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
||||
@@ -151,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 {
|
||||
@@ -174,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;
|
||||
@@ -189,13 +71,6 @@ function init(_ctx) {
|
||||
ctx = _ctx;
|
||||
$("send-token").addEventListener("change", updateSendBalance);
|
||||
|
||||
// Initial state: disable review button until address is entered
|
||||
$("btn-send-review").disabled = true;
|
||||
$("btn-send-review").classList.add("opacity-50");
|
||||
|
||||
// Validate address on input
|
||||
$("send-to").addEventListener("input", updateToValidation);
|
||||
|
||||
$("btn-send-review").addEventListener("click", async () => {
|
||||
const to = $("send-to").value.trim();
|
||||
const amount = $("send-amount").value.trim();
|
||||
@@ -203,15 +78,6 @@ function init(_ctx) {
|
||||
showFlash("Please enter a recipient address.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-validate before proceeding
|
||||
const validation = validateToAddress(to);
|
||||
if (!validation.valid) {
|
||||
showFlash(
|
||||
validation.error || "Please enter a valid Ethereum address.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
|
||||
showFlash("Please enter a valid amount.");
|
||||
return;
|
||||
@@ -245,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";
|
||||
}
|
||||
|
||||
@@ -276,19 +138,4 @@ function init(_ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
function resetSendValidation() {
|
||||
const errorEl = $("send-to-error");
|
||||
const btn = $("btn-send-review");
|
||||
if (errorEl) errorEl.textContent = "";
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("opacity-50");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
updateSendBalance,
|
||||
renderSendTokenSelect,
|
||||
resetSendValidation,
|
||||
};
|
||||
module.exports = { init, updateSendBalance, renderSendTokenSelect };
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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, """)}"` +
|
||||
`${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, """)}"` +
|
||||
`${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 };
|
||||
@@ -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,60 +120,13 @@ 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);
|
||||
}
|
||||
|
||||
const isoStr = isoDate(tx.timestamp);
|
||||
$("tx-detail-time").innerHTML =
|
||||
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
|
||||
$("tx-detail-time").textContent =
|
||||
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
|
||||
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
|
||||
showView("transaction");
|
||||
|
||||
@@ -175,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", () => {
|
||||
|
||||
@@ -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,15 +37,7 @@ 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 underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</div>` +
|
||||
extLink
|
||||
);
|
||||
}
|
||||
return `<div class="flex items-center">${dot}<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</span>${extLink}</div>`;
|
||||
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
|
||||
}
|
||||
|
||||
function txHashHtml(hash) {
|
||||
@@ -59,16 +49,6 @@ function txHashHtml(hash) {
|
||||
);
|
||||
}
|
||||
|
||||
function blockNumberHtml(blockNumber) {
|
||||
const num = String(blockNumber);
|
||||
const link = `https://etherscan.io/block/${num}`;
|
||||
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
|
||||
return (
|
||||
`<span class="underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(num)}">${escapeHtml(num)}</span>` +
|
||||
extLink
|
||||
);
|
||||
}
|
||||
|
||||
function attachCopyHandlers(viewId) {
|
||||
document
|
||||
.getElementById(viewId)
|
||||
@@ -133,84 +113,18 @@ 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 = `<div class="border border-border border-dashed p-2 mb-3">`;
|
||||
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>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderSuccess() {
|
||||
const d = state.viewData;
|
||||
if (!d || !d.hash) return;
|
||||
|
||||
const hasDecoded = d.decoded && d.decoded.details;
|
||||
|
||||
// When decoded details are present, the Amount and To are already
|
||||
// shown inside the decoded well — hide the top-level duplicates.
|
||||
const summarySection = $("success-tx-summary").parentElement;
|
||||
const toSection = $("success-tx-to").parentElement;
|
||||
if (hasDecoded) {
|
||||
summarySection.classList.add("hidden");
|
||||
toSection.classList.add("hidden");
|
||||
} else {
|
||||
summarySection.classList.remove("hidden");
|
||||
toSection.classList.remove("hidden");
|
||||
$("success-tx-summary").textContent = d.amount + " " + d.symbol;
|
||||
$("success-tx-to").innerHTML = toAddressHtml(d.to);
|
||||
}
|
||||
|
||||
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
|
||||
$("success-tx-summary").textContent = d.amount + " " + d.symbol;
|
||||
$("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 && hasDecoded) {
|
||||
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
|
||||
decodedEl.classList.remove("hidden");
|
||||
} else if (decodedEl) {
|
||||
decodedEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
attachCopyHandlers("view-success-tx");
|
||||
showView("success-tx");
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -20,19 +20,6 @@ const ERC20_ABI = [
|
||||
"function approve(address spender, uint256 amount) returns (bool)",
|
||||
];
|
||||
|
||||
// Known null/burn addresses that permanently destroy funds.
|
||||
const BURN_ADDRESSES = new Set([
|
||||
"0x0000000000000000000000000000000000000000",
|
||||
"0x0000000000000000000000000000000000000001",
|
||||
"0x000000000000000000000000000000000000dead",
|
||||
"0xdead000000000000000000000000000000000000",
|
||||
"0x00000000000000000000000000000000deadbeef",
|
||||
]);
|
||||
|
||||
function isBurnAddress(address) {
|
||||
return BURN_ADDRESSES.has(address.toLowerCase());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEBUG,
|
||||
DEBUG_MNEMONIC,
|
||||
@@ -41,6 +28,4 @@ module.exports = {
|
||||
DEFAULT_BLOCKSCOUT_URL,
|
||||
BIP44_ETH_PATH,
|
||||
ERC20_ABI,
|
||||
BURN_ADDRESSES,
|
||||
isBurnAddress,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,666 +8,23 @@
|
||||
// and does not enforce jurisdiction-specific sanctions.
|
||||
//
|
||||
// Sources:
|
||||
// - MyEtherWallet/ethereum-lists addresses-darklist.json (MIT license)
|
||||
// https://github.com/MyEtherWallet/ethereum-lists
|
||||
// - Known wallet-drainer contracts identified via Etherscan labels,
|
||||
// MistTrack alerts, and community incident reports.
|
||||
// MistTrack alerts, and community incident reports (e.g. address-
|
||||
// poisoning campaigns, phishing kit deployments).
|
||||
//
|
||||
// All addresses lowercased for comparison.
|
||||
|
||||
const SCAM_ADDRESSES = new Set([
|
||||
"0x0059b14e35dab1b4eee1e2926c7a5660da66f747",
|
||||
"0x008f3db10374099a11ec263415cb88c952abeedc",
|
||||
"0x00e01a648ff41346cdeb873182383333d2184dd1",
|
||||
"0x0153775362c3071c1860e8dbfd53ccc82fa226f5",
|
||||
"0x02673ebfbfaed5891f6b14248b0e2753a8758fbf",
|
||||
"0x02f4a464eebb46a50dd087074d7a2cf3f5a3598b",
|
||||
"0x0310c4385311d225cbcaca26fefc9ab45d3eba3e",
|
||||
"0x0387cfc283ffcf25a4a6a61e832545bbeb7c8fda",
|
||||
"0x03f034fb47965123ea4148e3147e2cfdc5b1f7a5",
|
||||
"0x052f585fa599bfb4bed290ff30c057627ccd8059",
|
||||
"0x05dd62c007cde143b402fa5da3937c40c70b4b14",
|
||||
"0x05e3650bfc907cc1b3c774f0c053c8b990ce1a4b",
|
||||
"0x05e5061133f752ea565f40f1e755d9604c313bc9",
|
||||
"0x069a6761e9897ced3f5d13f4aa9ad745e526c913",
|
||||
"0x071cf579507dd0661845694f709e68cf7ea506a4",
|
||||
"0x07425527076b4f73b5616716dda54fb14cf1219e",
|
||||
"0x0755e59d505c9b473aec50b476df3ea2045739aa",
|
||||
"0x07a065bbc565002740caccb22b452fdd029ba988",
|
||||
"0x07faacbe52a8b5d3d22f49206bdd9d9c1f71991d",
|
||||
"0x08389b19ad52f0d983609ab785b3a43a0e90355f",
|
||||
"0x09750ad360fdb7a2ee23669c4503c974d86d8694",
|
||||
"0x09cc69f6b484cada8e152d2002adfca496d723f3",
|
||||
"0x09faf25e57abd0a401bb5a2341d7f926c389f8d1",
|
||||
"0x0a00fb2e074ffaaf6c561164c6458b5c448120fc",
|
||||
"0x0a4392f623faf54de72cbf6f567e1a7808251cd3",
|
||||
"0x0a78fa6dab70385dd46fbdd20da5d868eb43f904",
|
||||
"0x0a9f58ee19a7131ed031ea66a032c05c7efe965a",
|
||||
"0x0ae775637e63fa95855246fd82e96802d05883fc",
|
||||
"0x0af16719353eddc122eaf834638406c499197860",
|
||||
"0x0b1f025593acd090a6c9b754e6e46acf88e89646",
|
||||
"0x0b21fd643aaaf5af800af67e14ebf4886be20164",
|
||||
"0x0b591a58137f154f59aea1284166d6a7b49821fd",
|
||||
"0x0b7f284d74f549731499c44aed2a10adcc9e9cc0",
|
||||
"0x0bdd81d2a676166f2a28691e8af64ebeeca67fae",
|
||||
"0x0c8b0b3e963becf16cda410fe1eb4b5d64022c94",
|
||||
"0x0c9d1b3ca852c67bbeabeeb51e9482a845d56868",
|
||||
"0x0d502f90c0a740b9c069ddec1c436d483ed7ab8e",
|
||||
"0x0d87ca679a7ab95598b34eb54dafc356b0373388",
|
||||
"0x0d9786138578fcdcce04435592545ef08422d755",
|
||||
"0x0d979f9ffdd579d67c29531cccba568d2172d0b0",
|
||||
"0x0dac7a548551dd2e0f7293005f14f9261d7e0196",
|
||||
"0x0dc04198cd942b55a7d63f5c3891122b6fa277ea",
|
||||
"0x0dcc89f3091d3ef66e00fb595ed0fafef4434192",
|
||||
"0x0dea8a35f4e39cdf65d3cc11f10612e9e0278b60",
|
||||
"0x0e04298d9eaffaf538fe04b4cd525b6d1b64c3ca",
|
||||
"0x0ef2724a6d8be9f72f3d35b62e1e8a37ceaf721e",
|
||||
"0x0f7a4c36b5ee28e581504e8dfa62fa83a11ca7a9",
|
||||
"0x101c55c14bbde713d4c718b67256133bac8126c6",
|
||||
"0x1086feeb031e45e8fcd770662aa8302d7c23ba1f",
|
||||
"0x116574a18659f0063150e0064e4ea00349a1492c",
|
||||
"0x119240ef17333e8dcb19a0dd4f5fc848981b0ff4",
|
||||
"0x122c7f492c51c247e293b0f996fa63de61474959",
|
||||
"0x12736c6b02381c3c50e41db3a69d7bb651a77d57",
|
||||
"0x1363077895b20ae90f80794ce4e575559517d033",
|
||||
"0x14130d36b36887620c37a404f859d11e293ec06a",
|
||||
"0x143494359bbacc878308075c1c9fa05fcda96651",
|
||||
"0x1447b1cb205158d98fdbd312b37ed9dd1481fb62",
|
||||
"0x1494403137159bb0dc545d11963fdf797ea1ecab",
|
||||
"0x15577e328dd6e17b7721c9ca4d00610d3ce3762f",
|
||||
"0x15847dcd428033c2cc2cb70edf2cbd6c84afc146",
|
||||
"0x15a99893dc47bfae5b06e9b4c8941aaca60f8af5",
|
||||
"0x15f4a5d5bbc071fc20bd60b9b7d81aaa8a1142ab",
|
||||
"0x16112015d50fac2d084e096feea0863800517f94",
|
||||
"0x169b6c7ed548e45ba9d87b2d32ff27e2d00051ef",
|
||||
"0x16b9c4333559256ca7d87b28c6fb2e8415b4711f",
|
||||
"0x175e0d5f3757b066a6f0cf7634b45f3085cb490b",
|
||||
"0x175f6dbf87215a6da7107977b4680bc3db31b2de",
|
||||
"0x17bd6f815fb71a77dc20e12177c4d763a3f67632",
|
||||
"0x181c71726f12ce2514e8b93019eb22645a79f966",
|
||||
"0x18345118bd04c405b4d74941563a21b5a2bf06b7",
|
||||
"0x18e81898ac31a43850fb8d0224477349de6d8d9a",
|
||||
"0x18ec1d727320cbed7c0c63cd655ade997b292c5e",
|
||||
"0x18f3489d959fd0e3fac366646e08f9aeabea4d75",
|
||||
"0x1982b0e96a0375e5b570e8e466d423a37af34b43",
|
||||
"0x199eee5b6ca1aaf030c77f0b5c50e39908fd2072",
|
||||
"0x19fb7020381fe9197bf841d5034d5467fe5a3c93",
|
||||
"0x1a2c53d132ff8a6e26f6b32c0abdc179b94f0721",
|
||||
"0x1aef1e14108786e0b049f37758529d866cc3c7e6",
|
||||
"0x1b42bf30cd988ad20500ce47cce52098576ec2a2",
|
||||
"0x1c0dffa23f342a62ace37bd4eede6180c40ccbec",
|
||||
"0x1c0e294310091654beac3d191089d3c376be123f",
|
||||
"0x1c2fb401d3dc4fd8131ccfc4589f281d58921402",
|
||||
"0x1c39d6e0278d1a28ce21dbd73826559b010224e5",
|
||||
"0x1c50139c266559b29d7cb27635e0da13bef76a09",
|
||||
"0x1ccc9b2769741ab0e1620721df7cf8ff1d70716b",
|
||||
"0x1ccd65d573057c388ea96cc8be30c18a6d21185d",
|
||||
"0x1cea7999fdafe5d156952ebf1816e8aa67a5485e",
|
||||
"0x1d60606d8a09b5015d773a80b0c660bb8d91809c",
|
||||
"0x1d64ea27764164debb4e891eb04d524f42904b08",
|
||||
"0x1d82886f997d0b124dfab342558bc463299c8a6b",
|
||||
"0x1de23f02e185fdc4f6bacc98d6c6419370e38538",
|
||||
"0x1dea82c5628ae89572a153a556312832ee33fda9",
|
||||
"0x1e12436889e2459202437077549e35b56f1299cb",
|
||||
"0x1e3c07ce10973fcaebc81468af1d3f390d2a4c71",
|
||||
"0x1e67f055367bcbce045e1211f09355eb7411236f",
|
||||
"0x1e75f03ff928bca6c1692179679afc0223d7824f",
|
||||
"0x1e80dad60d19fb8159af3f440a8ceaa0e5581847",
|
||||
"0x2056d2e97cc9ad78cf527b382495a8b9704ce011",
|
||||
"0x21918461c6aeca5eaec825b4746d64a0d4028df6",
|
||||
"0x22374a2e9e19bcea56e64f9de3329670cd1a458c",
|
||||
"0x2268751eafc860781074d25f4bd10ded480310b9",
|
||||
"0x22764de8f82f2d2a90e0ccca4556a2a5114b6461",
|
||||
"0x22eb0b23e5ad7ef827aff13b2e40ed63e277844e",
|
||||
"0x2315314c5cadb895c2c9982b847fd6f6a32d6129",
|
||||
"0x23d56797f64640e96feaf57b7874c897b53ef0e4",
|
||||
"0x240e125c20a4cc84bd6e7f8d1fd07aff4c06d43d",
|
||||
"0x247733af8b2bb8e63e3a2a419761c0fd71d40499",
|
||||
"0x24aa18952e80707dad3ab4c9c97e787f2af337ca",
|
||||
"0x24c00035b050bec633287c2babfbb9f4fe3c8a83",
|
||||
"0x259efb559a5d9ed64ccf44bd5188b325dc4738c8",
|
||||
"0x25dc5894db8ef7f7ff7096ea73b890c1a188d549",
|
||||
"0x25f8b4acd2c37d7ebae0066fdf9f13f327c0c4eb",
|
||||
"0x26f7eb488661c33c43089ad61944f3231222b32f",
|
||||
"0x2789b7b3557f39643198433daebf6b43512be65b",
|
||||
"0x2804e8b4c04c655376d18ada84d46fc345240e0a",
|
||||
"0x285cfe87f4ad22228a625dcd1f64b2bc6b27ecff",
|
||||
"0x2903e9f1d81fac8ccc313b5b496f2db7db6750b6",
|
||||
"0x29e225d888cf11c5e67613bffd30bcf071eb3d4a",
|
||||
"0x2a6d8021861f27ab992572d8689017b7a83c989d",
|
||||
"0x2a7d04018d7295d8069ec9721ee415c4bdc57909",
|
||||
"0x2b065809f6ec6df32878bcd26711a0e2bcf59c26",
|
||||
"0x2b3f15a55a68c4c81ae8331c2fe8e90008993f51",
|
||||
"0x2b5268fde5041f6b1afd77166de4e9ea5d9e967a",
|
||||
"0x2bcc8aefa2fc9d8e52dad098778bb0a8d08a4aa3",
|
||||
"0x2d146aa23645950fdefbb23f636a5d1674fe1047",
|
||||
"0x2d4498a2cf71f5b0946f0ebcfff97ca9d0cd5d04",
|
||||
"0x2da8703d18afed53b303119e4ff06cf035a9fadb",
|
||||
"0x2f141d0cd81011436df8fa04bdac01cb6cf51a65",
|
||||
"0x2f77f4523a13138b472ac6faef624cce68da2c46",
|
||||
"0x2f80a6700e4bea478ac027f019f04a78a7538d06",
|
||||
"0x3007a0db9caad557cf4589cfd550c21ce9399b15",
|
||||
"0x300d4fbdb7ef38488eb9338982686e04ba426715",
|
||||
"0x3018a3eab048a0a1c15003b383a181f089a039cc",
|
||||
"0x303f41f4e6a1e1fc9cc2738c722b73b3d9fe54cb",
|
||||
"0x31537238bb5b237254b629bf226b566c617f182b",
|
||||
"0x316b3b83c33b20249b61ded124f2620d8e823793",
|
||||
"0x33bf8bde242216da572d6486d641ae0a18137306",
|
||||
"0x33f6ee23ee223c4558a95bf4c0e9f5ed21e06319",
|
||||
"0x33f74739ce6be3b2c38af76f5adb3866cb4784c4",
|
||||
"0x34743ffa1f93e745668725e44efeb3c50a237796",
|
||||
"0x35ad1cb9a39de486cebcce3cf51661fd6c848193",
|
||||
"0x362291375bd3fc2ff0f928184bd6f373f92dea57",
|
||||
"0x3639b8b8eda816b2cb62b65159a183972a3cf863",
|
||||
"0x3681828da105fc3c44e212f6c3dc51a0a5a6f5c6",
|
||||
"0x36db23fd8e0fabb300ba95bfdf4a0f20b05eebfb",
|
||||
"0x3715b1ed9bbd5e165af97619b5c5e13e39f55504",
|
||||
"0x371850498d1bd60d67110d1d046ffabc10f957ec",
|
||||
"0x379ce20c018fb6301c1872c429ec7270ffa4dc5b",
|
||||
"0x37c81cc9ff71d0f97849a947bf22e8e529b72230",
|
||||
"0x37c85a7523b1832b4d0380b2f513e88b66e8c850",
|
||||
"0x37d0909cc7ec38f2d966092ea3607f77bc6bc008",
|
||||
"0x3810bd434103f1d0618b909ba14e4d2d707d79f7",
|
||||
"0x3853ba76ec6ae97818e2d0e0839c9eda6c396690",
|
||||
"0x3884eb0ae2a04bce65b5b0ca9c1bd069cbd52c66",
|
||||
"0x38b7ca3b78c51364095bc56146d25f55aee0af21",
|
||||
"0x38c36d3faab64c0770af26cc6ee95d91312bc53c",
|
||||
"0x394f59e83d88469d3ca3b8da15f31b73f27eada7",
|
||||
"0x395b53a4ef9736d698e86607047267f7f640fe34",
|
||||
"0x39a7c26ada0e575d69f05dd6b8086f0ddd99e207",
|
||||
"0x39ac3008be15cd3e3f23786faa0550c3e0b92ff2",
|
||||
"0x39d7c7acec5bb282a4fb6e998d3d0099ff7780cf",
|
||||
"0x39f3e7fa18d342de467ad9d7065a46c8385589f7",
|
||||
"0x3a3999e6501e2a36dd3c0b8fc2bd165fc4a22e54",
|
||||
"0x3aa65f17cde339df49afd2f88b3c8495842d5fb0",
|
||||
"0x3ad44a16451d65d97394ac793b0a2d90c8530499",
|
||||
"0x3b0009071a0a9983e9aab537fd8c9ad478310aeb",
|
||||
"0x3b5744c7f340e0d2dcf7a072a4c963b9a43c982b",
|
||||
"0x3c3b85b2ae785a8cc16c3d4df12cb27c6983dff5",
|
||||
"0x3cf71871d2f56a6af21bf17ca2652cbc12e8ba1f",
|
||||
"0x3d11b2189ad65e0e91e2f2b0e07cbc05ddc75b7a",
|
||||
"0x3de08b35c0e7f255f37a5dace9bd8eb78e99e985",
|
||||
"0x3dfe9f7af8864df0b7cc2a20430006fd1af8da1a",
|
||||
"0x3e2b9f6b67fc972419af5e9818281b745a6bf83b",
|
||||
"0x3f4d77cdabf58f5bfd2ef20784cafd58334542f8",
|
||||
"0x3fcb2d173389b7cd8079ef8b439dbd92e7e0ae28",
|
||||
"0x40b942240fec55473388cbf7dbcc5482e64b4367",
|
||||
"0x4114fb8b1879f61b18f7d2e623569a847a03e15a",
|
||||
"0x414bca672494b8f078112c52ae258f9e8de1a4a0",
|
||||
"0x41dfc5797c8043a2f4f7e7abf71291064e091fc6",
|
||||
"0x42a777e0f24390e7ed461a7af325526e53ac57d9",
|
||||
"0x42c5459911ae51d1d005cbe39749bd8d8e533c22",
|
||||
"0x42ff5fff0e369ef6880875efcacb3b7c77974abc",
|
||||
"0x434e9f6a1ac134fda3e7ceb3fd67c3d9b3518737",
|
||||
"0x439cb5628e64677c540a8635c86e41d83c1170d5",
|
||||
"0x43ac6aae3c5dd21855c3aabe3cadb7dfdcf2e5be",
|
||||
"0x43f030a549f780d1af59a782eaadce4dec7ba183",
|
||||
"0x4411db7be552b487e3681e890ed14de9a9b24f7b",
|
||||
"0x4423b4d5174dd6fca701c053bf48faeaaaee59f0",
|
||||
"0x44a7ff01f7d38c73530c279e19d31527bdcf8c78",
|
||||
"0x45029af827c652f47b1f678456b2cd009647c8ad",
|
||||
"0x4541c7745c82df8c10bd4a58e28161534b353064",
|
||||
"0x4566b8b849dcdbe04f64bf1909db313cf60d6e41",
|
||||
"0x45750cd6a3bb2206dbeb9cba5e68bf909ac945e3",
|
||||
"0x474aa9c6f46dc6379c638690fd8f5ade22df5205",
|
||||
"0x47dc6f08214f891cc910d6d2abfb504c2584f14f",
|
||||
"0x484aa220812a5ed3d1162686bc8592eb39276348",
|
||||
"0x4899f3371fb9f8e68a0b639bf1fd75220a089c42",
|
||||
"0x48d0a447b1d7b9a89112578db4536032d3047b2e",
|
||||
"0x49311a81f5f5df6c109599ed5d7413d85b8bca18",
|
||||
"0x495a8b1fdba38d726e514e95b4f1657c69fefa0a",
|
||||
"0x4999924fd714092fe92ca9a792a8549f798c1dc2",
|
||||
"0x4a0d27a1044dd871a93275de5109e5f5efc4d46e",
|
||||
"0x4a500bf6818ba31bb2b1dc90a354ab64ad3301dd",
|
||||
"0x4bb1c210e3921e0f051ed00e898b9605c902114e",
|
||||
"0x4bde78ead56a30055d33bf84f52e420b0f6070ba",
|
||||
"0x4bf722014e54aeab05fcf1519e6e4c0c3f742e43",
|
||||
"0x4c191dca6cecfa1e55caa3cf178eea52728b13e7",
|
||||
"0x4c569022016eba68ea10dee702670dc82d2749af",
|
||||
"0x4cdc1cba0aeb5539f2e0ba158281e67e0e54a9b1",
|
||||
"0x4da9608f15c504a2b5c46491d9ccf257c5e2ff71",
|
||||
"0x4de76b3dfd38292ba71cf2465ca3a1d526dcb567",
|
||||
"0x4e556877e96fdf3bef67c81ef72e20ab29a9aebf",
|
||||
"0x4e790c545478029d20816a7212c46adde058d63e",
|
||||
"0x4fafc9291a0112a0ee0928361c02848ff2fdf6bd",
|
||||
"0x501577cd4865d7d50664a1519a79f1c0e7069154",
|
||||
"0x504f1e10da987490db4f0d92931497f635e240e8",
|
||||
"0x508e1101755a4a6ae228b0e18002a816ce3418ce",
|
||||
"0x514e9ca5406f112aba902b0cba87395b914d861e",
|
||||
"0x5167052b83f36952d1a9901e0de2b2038c3dd1a3",
|
||||
"0x51dcd13361c5d921d2c4818e419011301e7be34e",
|
||||
"0x52005b77fbebf53cfd9527a388f58d0431aaaa3e",
|
||||
"0x52b34625687d6266a3b98fd5d214828f0670d7ed",
|
||||
"0x52b7c8857be3853fa68e09d9ba7a9adf075040ea",
|
||||
"0x545f5e5c54d8ac72af1c7de8c070387b73841a24",
|
||||
"0x55456cbd1f11298b80a53c896f4b1dc9bc16c731",
|
||||
"0x55c4dfc965b911afee18a7d2d94a245b867395b6",
|
||||
"0x560644f0980300b31dcde7b5f78d0d53e375371f",
|
||||
"0x560833296ef7a7f41b30c1065225fa9b33830c19",
|
||||
"0x56f0e244ea07e894170731285daf8cf4864e368e",
|
||||
"0x570ac27d1048dcba1a4c58fe80109b9cf9b0e2ef",
|
||||
"0x57b818a1070373e21fcedf48d4368e1703c75852",
|
||||
"0x57d02550f47dd932d2fb84e0aa883c9d8d53c313",
|
||||
"0x57d4e4ea0a207074d7e45fe60c939d2f4d3ed06b",
|
||||
"0x57dcf20135b0cb9d167e8ebfe13b84bfd67645ed",
|
||||
"0x581cc257051a34972641d008c3915a75771be274",
|
||||
"0x58a166f9a6ff996e2349eea90d42cc198529a037",
|
||||
"0x5b67a30108e1a5f3d5a809d57e76cd16fdfde7a7",
|
||||
"0x5b6d3d66e18dfd31ed9a753c406963c401f356c0",
|
||||
"0x5ba53eb789c7f017fe3cd8027fb01c5eeb81f697",
|
||||
"0x5bc989f38ec9b913b0fbd8b702858214d428a1bd",
|
||||
"0x5d1bcbde56db05bead0ff7c87c9dc85baf98ab32",
|
||||
"0x5d3f32f4b2e99fb79d2f6a1cbf3aa7390f8fc751",
|
||||
"0x5d82db63cf0c54d47006d416bdc7dab09ea2f3f1",
|
||||
"0x5d840db230c397acc22ab28053d1a1ff7f14583e",
|
||||
"0x5ddd20ac4bacff3f148af4c8c24194a1c102cfe5",
|
||||
"0x5e0f56a3e977318b69303c12369b2a797dfa2b0b",
|
||||
"0x5e5d6f6706420f932d796c0f28a83b26dabcfd8d",
|
||||
"0x5e6dcab4f8edbfc1537b804803d831e5f42d8f3b",
|
||||
"0x5efb7d2ab258b18c8166b0c74fc4117716e52515",
|
||||
"0x5f58fe612db5b3e97faf4a2e8f38b4ae295288aa",
|
||||
"0x5f6ab160206bc6a5d663ca5d0f237d82c572272b",
|
||||
"0x602026bd56bf9ca9d86926669e4353035aca2a88",
|
||||
"0x6122ee683dbee50fd432fe5bdd8ac055aa3d42b1",
|
||||
"0x619f18aec5f8eef483fbfe654a269f1186dda915",
|
||||
"0x62404bfb3e1c464f17e3af0c139e39d0703ae4a1",
|
||||
"0x629b2aa7fdda8f7527d6f8ea742c1d5774e567ea",
|
||||
"0x62e8aaffb7568cec94b0e15e7b4d859302d65ee9",
|
||||
"0x62ee4a3c690c9660762c52bf1a232365c86c879b",
|
||||
"0x636e5292882e2342aaf82d651322aea09a3e5392",
|
||||
"0x63cfa80bbbee233a4257857dcdc9d78cbc8efe37",
|
||||
"0x64c5971dd27ff063ee4bc5e4c231febd9fc228cd",
|
||||
"0x64f3502c4b6b7e7f106e7ab1f175a4e2c2fde45f",
|
||||
"0x6506827d91659e871fc7267160ba1c3407195270",
|
||||
"0x65af8c81291fb60a20235d9e4de80851f8f845d2",
|
||||
"0x660d30d1fbafdc88a1951370cbe2b3094dbf224b",
|
||||
"0x6642cec9d02e4e669103a3ed4f3505f437b8fe73",
|
||||
"0x6678bb2a94097f5046ff179a9dcec5be0985745b",
|
||||
"0x66817272d39da7fd4c552f430fc0b694e357c157",
|
||||
"0x66e88b42922916d01b9aec71dd334d7fa5fb526d",
|
||||
"0x6721b09f4fb6fe69a14add6d1f2c1b94562e7801",
|
||||
"0x6834d3096793d0731348f1581ae1b20c9b79e26a",
|
||||
"0x685445fe51b31790538890a6468851afbf7a0519",
|
||||
"0x68a91eba9c82b475091077e16357bb2dded479f4",
|
||||
"0x68b0e0db7918c0211ea1fb78292a879839137dd0",
|
||||
"0x68d41d0c11bf5536d6d8c186ccfaf3de1a022d67",
|
||||
"0x693d47204555361d4d51311f97102a1507559802",
|
||||
"0x6973c24fadd0bcab33ee5cb325c8a70e81c67c20",
|
||||
"0x6a14e385fff2f21abe425a07ce29842b7037a80d",
|
||||
"0x6a164122d5cf7c840d26e829b46dcc4ed6c0ae48",
|
||||
"0x6af114154a8850b1c54d48bd9103bdcdb420611b",
|
||||
"0x6b5c35d525d2d94c68ab5c5af9729092fc8771dd",
|
||||
"0x6cf481c7090d88ca6f1fa3c9ffd0911ef5359808",
|
||||
"0x6d0b90ea2951ca5ad3abdb606e4146e9765f1ee4",
|
||||
"0x6d64486a5c99139b53239297e0b653d196d05777",
|
||||
"0x6d8a1bc0ae09ae5cfd4a7a2a59194598734ceea5",
|
||||
"0x6ddfef85ecf643628254e5af7064e05b3c6b221e",
|
||||
"0x6e3e57e536aab05950774945d9cdbf506865c52f",
|
||||
"0x6fcf42fcd9c6a54b64d1b52083700142952a2805",
|
||||
"0x702d965332e3082dc976c3b4013cfe0e2c540bcb",
|
||||
"0x703f0116bf2ef4c80efac751a319f097fd2dfe6c",
|
||||
"0x7098c7113c2ecaf48baad07e598c74c2689a5795",
|
||||
"0x71108be9c232722dd6839a6fbad171cf44d3ffaf",
|
||||
"0x717d2041c516573a0e27f552cdb6c5c3ec7b4e09",
|
||||
"0x71b7767a9464ea1ad82db434f06d9330d34a9fe5",
|
||||
"0x72a66bba491f0c696c4c4e3154d07b241d371944",
|
||||
"0x72b59c7ff61130f4923e2da6af27d562dd64191c",
|
||||
"0x738a4daae8d4a640172fac4f45b41707bd2197be",
|
||||
"0x73bb594368ddb778972c6f30b41437c897419c37",
|
||||
"0x74104e8c7f546c7398d198f6678310ce9d1b8814",
|
||||
"0x75dbe6e21e38a05a82ad64281a9e28a627619273",
|
||||
"0x7613a475b7fe61775f579bc148300c8171eccae9",
|
||||
"0x76c88cd1ec2442c4f929b0f87280be006d7ba725",
|
||||
"0x774148e22f021972bfe082e1548e5d9dc6e1d76e",
|
||||
"0x7822b7b190604eaeb4dcaabd2dd5e1daafcb9d46",
|
||||
"0x784205acd9423a2044d09e41642a71e087162861",
|
||||
"0x785a751b2e36b2b0fdb80100b7ca14889a768a22",
|
||||
"0x788431a9fed9fae11871353cd6e92f3b724e7d63",
|
||||
"0x79a9e902cfbe87fa5065905293992c15de7f6095",
|
||||
"0x7a18cfc4a36e48163034e51a9ed19d4bdea05f6f",
|
||||
"0x7b3a132ad35b6138f2dd148bfac2e790e4869723",
|
||||
"0x7ba85e2e55c7598378fe927ad2aef2dac6c45218",
|
||||
"0x7bb386c33486fe345168d0af94bef03897e16022",
|
||||
"0x7c203685291149d5dad4308781f42a6b945df4e1",
|
||||
"0x7c57981af4cee87567774b53f9da1bfceb8b944b",
|
||||
"0x7cbc4e0d168744912c5afc081499145bbdc51e69",
|
||||
"0x7da6955457b72fdd0e80709d704520ff85d79e39",
|
||||
"0x7dc66f5b59b34df403b96fec7e749703c157ebf8",
|
||||
"0x7dcfd020ad161fbd4c45894ab352582018ea9197",
|
||||
"0x7e160f5cf87e44cbe6b1337bb883f453445391de",
|
||||
"0x7e3d7d8e31b3f472fcd3c1552a9f009131c50c6c",
|
||||
"0x7eb28833082eb28fd423ab30df20afde98c47dbf",
|
||||
"0x7f2b4801c338a7b3cc322bfaf151afea8708e8e0",
|
||||
"0x7f85a82a2da50540412f6e526f1d00a0690a77b8",
|
||||
"0x7f85d875eaf39525004e8ac2bc1e83786d7dfd77",
|
||||
"0x7fbf068316c5806a62d549441ae82b00fdb10de1",
|
||||
"0x801d03f4d053242afbc2949c141bab4270ca6707",
|
||||
"0x8202b590eca4662446102b3a97e3536aac8ab941",
|
||||
"0x82334551e71b48387a122ebb256b188bf5868f44",
|
||||
"0x8369f6723c4792b41d4af4190662269431ee5b0b",
|
||||
"0x8374aa52bf36eabca06e33c4163a081f53e39aa3",
|
||||
"0x8444173280e5fdc0525494638123b014cffcc521",
|
||||
"0x854b62b692ac8411c3a45463c44cbdabac2d6913",
|
||||
"0x85704b506e10b10d714ead28cde947baa58f0ea4",
|
||||
"0x858457daa7e087ad74cdeeceab8419079bc2ca03",
|
||||
"0x8645ec394f7af95316639dce6f99c01476b0d888",
|
||||
"0x8651e8e218597e781e9a6d8ceb53ec5a236ae75b",
|
||||
"0x8676f2dfd06ec780ea3f8fba65a29110a56dd830",
|
||||
"0x86d38dbde80fe6a947565967f41cf958cc271548",
|
||||
"0x86ddf5b305b9081fb5208e903edeb013510997cd",
|
||||
"0x86e00684ea92cb87f40d74c5ebde97ef9a17753e",
|
||||
"0x878cce43a3b9d8a872a33d2d6ccd561e033707f4",
|
||||
"0x88436c2a2f427f2aa641dfd8c6763facf2bad7bf",
|
||||
"0x88698a2ff09506596a88a1af9d103a420ba46a3e",
|
||||
"0x893676c4a94f6ecdc33a18438bbdcfe9e6c68a9a",
|
||||
"0x897757cba67eadb66d40e45c6748e27d0e67b52a",
|
||||
"0x89c98cc6d9917b615257e5704e83906402f0f91f",
|
||||
"0x89e5124b5162a0ceec512e8ba97cd5e6f065fd13",
|
||||
"0x8a46aa04725b2e2029bdcb924d671fd6a9c11dab",
|
||||
"0x8a6fcf189437928a6a07f6899b675d11f3d3f778",
|
||||
"0x8abe0a9b8a1c8a003354e61f3ed8befdeb7d2cec",
|
||||
"0x8b3b831d767fad1ab0ad52a38777acb98630f8a7",
|
||||
"0x8c38a4e5104a2925d9e23d5f269afd1c2e09b8f9",
|
||||
"0x8c9d299cc3c2111499416cf4e3677328486cfac2",
|
||||
"0x8cf893f11f6037b14f0f827b9ed6aeef38931fb4",
|
||||
"0x8dc2c980d25b586c5c110ec3c477e985b9acad8b",
|
||||
"0x8dcdb7fc9f541825ba57fb47d5204317fc609590",
|
||||
"0x8e6be4cc78557cf3087b15963bf7139b0154e0e0",
|
||||
"0x8ee2ba41ef59b5773e5909451e0b8bad4e59a81b",
|
||||
"0x8f0bca6b1b2b522082d46f3eec31340f113b029a",
|
||||
"0x902d0d5bb6a34307bbd1da33e61ae1654b6cbe1e",
|
||||
"0x905475cbca398e8fee6e35bcb2f530a3474b78ce",
|
||||
"0x90a16ebcf1bc0da0347134f707da93c40bb8a4c5",
|
||||
"0x915c95415d3449212fd0991ccf5eb42864118ec9",
|
||||
"0x91619a3456fdbac90bd93f51f2917981f0ed3097",
|
||||
"0x91ef0754d270e67f2ca92595f5a1c0459ec7df89",
|
||||
"0x920adc9a060cda345fdec2fdebad6ebf38edf83d",
|
||||
"0x9327cb34984c3992ec1ea0eae98ccf80a74f95b9",
|
||||
"0x93574d7c405717d80520944ee1cb72d327b01df5",
|
||||
"0x93aebe50a59dfd4c1f7ed56f5177c6ced54a33af",
|
||||
"0x93d8d5bb1f1e4822f3c614d9a09e7c5e3fabc13a",
|
||||
"0x943db403251105195f56ce69c7c48a6320475ab1",
|
||||
"0x9441a81fb67f44ce7b31c311317e136e7067f8ca",
|
||||
"0x944a3f5641d6faf7a6abde4ec31e40d0929955ed",
|
||||
"0x94763846b1fcb75fccfcb51a9636ddc26af281b1",
|
||||
"0x94b40ecc7b38f5a9316b03dac4fc663e4d10cd15",
|
||||
"0x94d55b6f8b53903026399ab45294140eafbfbf44",
|
||||
"0x9546748f47a77408dfbff0dc22505b2e9a75fda4",
|
||||
"0x955427a36b5c92edee90a1448bfc7e854e9caef5",
|
||||
"0x956729a9e9b2ff42a30c8bd8fbe380b2c714825b",
|
||||
"0x983bda798a24720bb4fe3dba287ec352e7b440fc",
|
||||
"0x99f93d05059f074e893ab369f71adb4569a3da12",
|
||||
"0x9a95f2ca24d1dcb01048e0623f63f9d174237deb",
|
||||
"0x9aa722c9b6e63325797e1b2f8ff6873e44dd17c1",
|
||||
"0x9b521529d04329c2b0a9f9b7db18ed775c6ca8fc",
|
||||
"0x9beb086842a6c61ad929995f7060e3ee2c4a94ce",
|
||||
"0x9cdfc5b0aca4527a0916412e9dbd6ad85556a494",
|
||||
"0x9d0b8447f16d6bc1de2b52d63e9c487bd778a91d",
|
||||
"0x9e009c57b36ac7e3382697b4c9d3e7092a1cdb42",
|
||||
"0x9eb041af96d44583110565abec79aedf22eb60b5",
|
||||
"0xa05554fa940043c7ba191b16cbbe404c3f898f48",
|
||||
"0xa0d67bf1ae91b6b705abd4f695cc13edaacd0d02",
|
||||
"0xa0db4acb24e92167341320fcd882bbfb641cd12d",
|
||||
"0xa153a6ef80fb5d60de18688bdc82684d48fc8de1",
|
||||
"0xa1b70dc70fb74767cd380985cf93c4fb132fc4f7",
|
||||
"0xa2896b45f8bd313b777f1027724da1b5f2205a58",
|
||||
"0xa2a3a8e4a1877da1c913df7591ac0d72f27d4510",
|
||||
"0xa2a6ceadcb7db611f618ebabfe6de5763953b7c7",
|
||||
"0xa35e3534917e471540345733b8f23e7bad54681b",
|
||||
"0xa3e52a23060ccdfe186822b52bf60a07451e094e",
|
||||
"0xa46311dc4d3bc52b66d45e74682042bb143a9aa3",
|
||||
"0xa490730ce2a97ec9f81f08fdb0eede36d6319bbd",
|
||||
"0xa4fcbc4a0508b11d5ef34d41d72020a5def1c712",
|
||||
"0xa5797ea738abf85db7b3f2e04a4a40a5180044a8",
|
||||
"0xa5b254aea2e59ab3ce3bec470fe1882403c41be0",
|
||||
"0xa62b5f23f4b3ddc20f879c3ff58d4b1ef2952d97",
|
||||
"0xa6b60dd3be491aafe5aba8622b35c0ead608d3fd",
|
||||
"0xa6ba0996684fcff6167128a13c8b0a1648310e6e",
|
||||
"0xa7a020ae798a0a026c57ed6cbe48b21d3dbbec5b",
|
||||
"0xa7ce02b8195fe8e3116a7e1248f2725eaac86fec",
|
||||
"0xa82c7c0ef05080463e4ac55db8b8531007f3a66c",
|
||||
"0xa8454096f4a18e0252b411f670b3dd9d21465e75",
|
||||
"0xa8eb559eeb38c6cb6a8bc6c39badd86b113cd875",
|
||||
"0xaa1886de3f70a3ef502ea1379a311c5b4e05f3fd",
|
||||
"0xaa2498d85ba7559006f90110a4bc33c10a06c1af",
|
||||
"0xab1c30cd2d9964b3b845ef744e2e4043739430e5",
|
||||
"0xab215957873deadd9512fbde29e6c12bea1dd1a1",
|
||||
"0xabc490af011c8e354b7d2112648883fe9e511695",
|
||||
"0xac1a4f4d49715e31f54cb8e0bf867bb9170c10cb",
|
||||
"0xac3800002e45ed2e1a55dedfa2aca137f6dba61e",
|
||||
"0xac513396ee50091972ee6fc07d120b6ad360b233",
|
||||
"0xac82537fae701b2046f62bb718c4857787b0b647",
|
||||
"0xac9093198de62e5289163e85bacb53ead596b9f3",
|
||||
"0xaca33c43bd8deac8e1d144baa51fe9c7dd7b010f",
|
||||
"0xad0c88f3313abbce2185597e87a15f764e948a46",
|
||||
"0xadf5b0c2103598fb66a61714152f1d1717d49fe0",
|
||||
"0xaf313b5cf52d7a2ad7785d008660ab68421db2d1",
|
||||
"0xaf31bf4fa017768e6426308cf17e16d633700265",
|
||||
"0xaf70168cdac454fa9ff94e5458d450ae7eeb7ae8",
|
||||
"0xaff9e40f9b245b15a1d1bb45516ca213e682fa81",
|
||||
"0xb0dba64e6a353a74cc78e8a98914c4dff517ba7d",
|
||||
"0xb113a2c20471ff1efdf2735206c630707263f41c",
|
||||
"0xb116d83c919d6661dc20246d312b702f0cdf297c",
|
||||
"0xb177fc9a7caaa9c50d82c5d2533750f1d72aac86",
|
||||
"0xb1f873cb2cd0c818ce8b0a7d971f062808a47425",
|
||||
"0xb2172027cb233198a3945a2c864f1ec5b7e3b3da",
|
||||
"0xb23fb6cc17026d1ce9cd543cb69a023569503e4c",
|
||||
"0xb2a3fcf38979898e695c88947e3373bf1c2e9b37",
|
||||
"0xb2ac8e363ea34201df532a01522a006fcaa389ee",
|
||||
"0xb3764761e297d6f121e79c32a65829cd1ddb4d32",
|
||||
"0xb37722e14fcfc78a56312a9a746c5474822002f2",
|
||||
"0xb37da5c9179345341fe50d37cfc961ba47c01f9a",
|
||||
"0xb4c9d8a5812a024bdb177991af256da144776033",
|
||||
"0xb5879bf4a37c6ed9d86a6f2d8aab7939b21687cd",
|
||||
"0xb65db9f684f3bfa45ad3fb4cdc4120618ad81f00",
|
||||
"0xb71df0dbbb9754c1aa9115253f4ebb7ef3f8a57c",
|
||||
"0xb76a4770587f76d6c644deec275a2c76069b5f14",
|
||||
"0xb7c4a4341c0a03460b6cec4fa997dd2d748ce281",
|
||||
"0xb82bfd79d5a8c0cc0d4c045633288b48a2beddcb",
|
||||
"0xb8689c29bdc7c84f33706ff0f96eafc222ba6d86",
|
||||
"0xb9ae74beafe4025cc0fcfb5e190169f2d7f0b563",
|
||||
"0xb9cfb36060785f6800b1ab9ccc9b4f341d097399",
|
||||
"0xba83e9ce38b10522e3d6061a12779b7526839eda",
|
||||
"0xbb53be0690ae7256833099a1f01125eb33445692",
|
||||
"0xbbdef8b12babd3ab6018566bc17ec3aa302b8348",
|
||||
"0xbbf84f9b823c42896c9723c0be4d5f5ede257b52",
|
||||
"0xbc210ec2efada9df4897dc9b23c58e96c01ef711",
|
||||
"0xbc66e774ce25000950786241b8c5ee3275311bd7",
|
||||
"0xbc83d48dd0cd9c3f47bab6436defeb334b563f4c",
|
||||
"0xbc85a12364c9e375801c00aad17b893fc4c8f5b6",
|
||||
"0xbc8b85b1515e45fb2d74333310a1d37b879732c0",
|
||||
"0xbca6294a6c80ee0f20173547b8d85d4948a0cb39",
|
||||
"0xbcc6c0fef89b87a12773db7a9a8ecbccccdb7aff",
|
||||
"0xbe11d0d38ef4857224032afc9c96647492f726ce",
|
||||
"0xbe3364dfb8158a3e452ff96aeb247bf3f5f019b5",
|
||||
"0xbeebe66b6a511dd2accbff7f258554dbe97f1294",
|
||||
"0xbf0c85867fcdd4064d22b0dfd91561a52134e035",
|
||||
"0xc0ded1d24c071ca13a905ec5e54a6ffdd0c4df68",
|
||||
"0xc0fed35b43f59c2bfb1eb544bd8921bfb18140c2",
|
||||
"0xc14777c94229582e5758c5a79b83dde876b9be98",
|
||||
"0xc169a0e826f22fc7d37a48aaa346ffb4521c35cd",
|
||||
"0xc1dd8ac971129c8c08333924f58ff40a50b8bb9a",
|
||||
"0xc28f50625cfe028ab1a3458c7cebcf9657ff1438",
|
||||
"0xc315f677f6a8f7b42d8fb32500e790cbf3dfbce8",
|
||||
"0xc31fb4c352cb68c847715d46d97c1fa2aa2d0f00",
|
||||
"0xc36454ed2b40adef7c75b9cef95b2f8010d3e0d2",
|
||||
"0xc38d14bf89e0001260bb349a006d7e491e5f43ad",
|
||||
"0xc4625787be3fccff6ac2971945806acc167c8cbd",
|
||||
"0xc4b51a247514901faf1b6b1da9f0836066e64407",
|
||||
"0xc4dca8f7d121ad79057a8382fc9fa9898727ddd8",
|
||||
"0xc57f1148855e67763a694f7f2c0e68230adc686e",
|
||||
"0xc593e92ed9fd3c2f9db972860047affd2df83ae7",
|
||||
"0xc5d65deff5d1373f8b19aa8be528e9c3599013ba",
|
||||
"0xc5f6690f7b60fe739908e02aec46ae07718cec5f",
|
||||
"0xc616d99df3bf92e3111090f94694bd662b72ab1d",
|
||||
"0xc7308d5a7220dc021ccf417445cb28cbb8fece66",
|
||||
"0xc8ac47854a3fed2b444beb83f4c95ba092a3a735",
|
||||
"0xc8c3234aea55a5f746b2ae585a849ba0bfa57785",
|
||||
"0xc8ce1c3ac9549d479a90a6a89155b9b94e54d9ba",
|
||||
"0xc915ec7f4cfd1c0a8aba090f03bfaab588aef9b4",
|
||||
"0xc94a6e7776bade5da316cf6fd8c751fb0d5c3c5e",
|
||||
"0xc9a08162b95915edf6570f0439757e1f655de576",
|
||||
"0xc9f32ce1127e44c51cbd182d6364f3d707fd0d47",
|
||||
"0xca4da1753336aa8340710e0e1a8c0bee2bfbf56c",
|
||||
"0xcae77a05ab518492144944fb50572ebfa3595870",
|
||||
"0xcb142defc16c9b409272447d87c74b722b767f1f",
|
||||
"0xcb89576d5e1627f4a0c64c2bb4d51798e92e8b8f",
|
||||
"0xcbcd4b518890429c0ffef3d782ed99b3adff009b",
|
||||
"0xcc02b920ae227f1be7d01fc241c27e5f74d40436",
|
||||
"0xcc49d1f23f01decd4e18b6aaacccb038c9648e30",
|
||||
"0xcc66ea7ef61a67df02afb18706c68a5f30b22300",
|
||||
"0xccdf72de615b5158bb37931bbce645aa69221520",
|
||||
"0xccffdb4bd0abb6fd105c1c4e03b4e919a5b57ab9",
|
||||
"0xcd63bb3586e871611cc60befcadf8e56bc7aeea3",
|
||||
"0xcda5dd8e13fdae006b270769b1a18fa6c5524ce0",
|
||||
"0xcdd1c19dcc3f473eaef6edb0a28e1e796d6e1767",
|
||||
"0xce52d38539e1e557f184f1073a59f69c9ba95f28",
|
||||
"0xce686d019e6f692af646a8df3bfe789da97a484f",
|
||||
"0xcec962222291e09a07c0bd0a110d2211e0358b28",
|
||||
"0xcee9ee01d48050415f1b104277bd493c5dbe645e",
|
||||
"0xcff5e190a3d92e480a8bc5b414362c0d1afeb54e",
|
||||
"0xd055610c2d5151adb3eaf994e08abe45dee936e0",
|
||||
"0xd06f80a4b932d7247aba4a85decce6c2458c0654",
|
||||
"0xd0ac32ab01d9d3ec145ef63f73bf4e222dbcc0fb",
|
||||
"0xd0b473271e9d38dfd11925d62ef8c0a2cf033a9c",
|
||||
"0xd0cc2b24980cbcca47ef755da88b220a82291407",
|
||||
"0xd1091f9c7bb48651edb17d255b2824de0ebd2074",
|
||||
"0xd1bdd067f5f1cbd358e2dde444f8d9f41de8ae76",
|
||||
"0xd22167d083a5cfc2f1ccadeb842a8093b2174c5f",
|
||||
"0xd2f4ea2f9d4c627c957305e28877081da9296d47",
|
||||
"0xd33441a44da0c1dcee596abb7ebdcdba77c7ddbd",
|
||||
"0xd354cf84a4cfd096120e2d4bf0a0cc8866d4efe1",
|
||||
"0xd362c189cb0056159d2fe22bd8203e26579f620d",
|
||||
"0xd3d26c61401924939bee67dffdb7aa7a0138844c",
|
||||
"0xd3d7c71e4c5b1ff2f42fbb9b4a21f3373aa8b3b0",
|
||||
"0xd43f2111e4055120196285ec094aa6e96998668b",
|
||||
"0xd50fcd07007255599a9a8cedd881f863bcd65eb1",
|
||||
"0xd5ce086a9d4987adf088889a520de98299e10bb5",
|
||||
"0xd5edb088b7382b397238f5010d14cf10dd395007",
|
||||
"0xd6f8668e96951e4108a79bc45ccef022544c73b3",
|
||||
"0xd72ebdf278c092d9cdaa12c58f8bd9438f96b83e",
|
||||
"0xd754b5bf41b84bcb49cb952f858318640abc60e0",
|
||||
"0xd77b95800f67deb52e0e0988dbf01a311a3ccd80",
|
||||
"0xd79eb898431ea62636f08f738d10eb00446403de",
|
||||
"0xd821dfb705333d2c16e0c0c0be75ce360cca566a",
|
||||
"0xd84af8766bd29b988f3426cc48cac5a6646f1f90",
|
||||
"0xd868a76a1dcc34854903376e734e3cfe461dd08c",
|
||||
"0xd912289c9f079b0655737a55fd5d745501ecefc7",
|
||||
"0xd9204db19a18e051e861576a462c0cf83a2b6237",
|
||||
"0xd92559e1f1d2e84f4964f45256693d03833d7d44",
|
||||
"0xd9932453997ddf36fe114c9f22bebb9ee77b2921",
|
||||
"0xd9cd7461f960e56364a294f124aac77b25e2b784",
|
||||
"0xd9d25dce4c3765855c76f3d2baccc0755c4d0bc5",
|
||||
"0xda1cc010259496d407a764c381d0e82182d68d89",
|
||||
"0xda657e9fa116900fab8178e5580a3a6cedd89f3c",
|
||||
"0xda917961872ae8e0c8b96f6925a4d7cc7b27aea3",
|
||||
"0xdaa29859836d97c810c7f9d350d4a1b3e8cafc9a",
|
||||
"0xdafc2a2d4e070f863cfcb5887bc2516e05e18877",
|
||||
"0xdb21509547c82b69e714bf3a761e6d64f0f91cd0",
|
||||
"0xdb82af76f9ccddfe9e8f7996492f4c5bd5f9d53d",
|
||||
"0xdbe6dba965afc52f5b3d51e4b15e372f45d4106a",
|
||||
"0xdc3fd5cce7b17e461c692ff34f22fbd4b780b151",
|
||||
"0xdcd2fb1c1b103c5a591e76798704cfaa27baf6b9",
|
||||
"0xddd6854a002a6fbcdf695385cd5ed630c9e27c3e",
|
||||
"0xddff022e4befa69cbb5262446a8ae564700bea24",
|
||||
"0xde5886e65cbf1a9d21267f5ef7d5ed444cc63938",
|
||||
"0xdef05b512d405f4fb930e252c6c11f054832c93b",
|
||||
"0xdefb014b9e2f3bd81cdb084821f99b681cfca695",
|
||||
"0xdf04e5007cb8fd65831310ceda40f5642c1b39c3",
|
||||
"0xdf1ec2e44a8b1774b068ecfc5ef1c937a86baf3e",
|
||||
"0xdf3e77cd5e563f93fc7eca905d3302e95f9bb150",
|
||||
"0xdf967ff95cd5ff35a27786dbd906fbb9ded116af",
|
||||
"0xe009321b26350c2cff5db607068c1f6813df40d7",
|
||||
"0xe02f2921742a6eec7ae44a18b1cca4277874bc74",
|
||||
"0xe051ee9bd6270ff52d85ffe09685ee3a9755f04a",
|
||||
"0xe06ae5c5ab3eda3a62918bf86532c7d87dbbc66d",
|
||||
"0xe0b13c073e8173b06062c69a160ebb54e2af86c3",
|
||||
"0xe10270bfb1ed82e120bfc392efb3c94a1604ded6",
|
||||
"0xe10c24ca7bf18640fcb35e059919348891922a3b",
|
||||
"0xe112784753273ebede055968ecb78dcad8ff6da2",
|
||||
"0xe1358ae1a9130bc458c8021fcbb7ef8aedd51487",
|
||||
"0xe1632e7e0dc135f3bc8991b917e8b682ab340fe1",
|
||||
"0xe276c66af8853cdd91c1a25608b4e8b599f4da0e",
|
||||
"0xe344e4b209e8eabaa2a6ddd1b0aa120b7599af25",
|
||||
"0xe350160e3c8a07e92ec58ddcb8df81a73aedc6f9",
|
||||
"0xe41031233301370491d47477e1171dab212d5352",
|
||||
"0xe4fa5149306b12d51dc0d04e5e95bc9704ccaad7",
|
||||
"0xe4faeef4002c200d9d4f1d381ccb5fb679d1c437",
|
||||
"0xe54659243f8542d6abfd15bbff69f62465786756",
|
||||
"0xe5b913f91f2b90c5cd04d711e1eb3214c56dba98",
|
||||
"0xe61df1f5b8dd4e4e2a874157c2c97daf7314b795",
|
||||
"0xe6b39dad6d7a50b233da23e510697422e9d6351a",
|
||||
"0xe6c51d563f92a23dee9a7093bb1be33bd35c05d8",
|
||||
"0xe6d3486a5fe2742e313a3266af8ff4f43e597d27",
|
||||
"0xe74b02131ed2184eb94fd357b4f303e6935367f5",
|
||||
"0xe7cc576611c2df800862db667140a90cf9ec4b72",
|
||||
"0xe824ec0f8384600f3187967f8b098014e31892f2",
|
||||
"0xe88379631857c0d8174efefdf8dca25b29610f08",
|
||||
"0xe977419ef28e71ed541ff6318cea9a6392709a48",
|
||||
"0xe9aa3a74e3d62274f221eca42736cadc14ccffaf",
|
||||
"0xe9ab68e4aa12bf290529626e5f32725cf5abfba8",
|
||||
"0xe9e837881c89e943c4c66e26031922bf8fc3f4f3",
|
||||
"0xe9ee84b3b818b2af67c4536248fbfc5c7a0ccfad",
|
||||
"0xea6e18fd3c301e4300bc99b6db792fcff376abb4",
|
||||
"0xea7380369af53598a55b4622909cd9633e0ae7fa",
|
||||
"0xeab1d82e96541718e645fdef4a5ee9df78605b76",
|
||||
"0xeaceb8e777dc8b1c11b8730904d13fbda070846f",
|
||||
"0xeb8b629df028f5353649460f1c06bf394de8641f",
|
||||
"0xec2d5cc008ba6d2237d33ca9cf74b684737fcae8",
|
||||
"0xec970bbf167beee6fb536f1839418f3d19ce2a69",
|
||||
"0xecb6ffac05d8b4660b99b475b359fe454c77d153",
|
||||
"0xed2d26eed06ecfbfd796d1ec551d2a649a31e576",
|
||||
"0xed44fc770eaa76db9ade24d86ce3b409f4aed009",
|
||||
"0xedf202629bb7e9f72d4c62c325d198513fa7a3d3",
|
||||
"0xee2c2c2dcd952429147b62fd4dcd7f565313e419",
|
||||
"0xeecc46a74cea6133a12672bd62d5167877b4d521",
|
||||
"0xeed48722238b98317d849fa591d96b7efbe9b06f",
|
||||
"0xeed7072fcf46733833249d1e979c44fe4f2a23a2",
|
||||
"0xeef2a09be2a136bba76f04cf056e36947dbf0b0c",
|
||||
"0xef0683bef79b7ad85573415c781edfde8bec65b1",
|
||||
"0xef57aa4b57587600fc209f345fe0a2687bc26985",
|
||||
"0xef6805af393c34fe772f8d827a5d26685215612a",
|
||||
"0xf021428381172ef96988b31f241f4000259488f7",
|
||||
"0xf0a924661b0263e5ce12756d07f45b8668c53380",
|
||||
"0xf0fc2d4d550253d8c165c599e0cccf221c7eb7b5",
|
||||
"0xf1014a29614b196d19d8ecd5ff05f3cef5efdbe2",
|
||||
"0xf193a9afb00715aacf7ca9ebffafe02c77517c2e",
|
||||
"0xf1e250f2a27ab7ec5b82b287f2799260448cd51d",
|
||||
"0xf23f9dda63ed0628609272dc0544c7a2f7189f51",
|
||||
"0xf245e09a1b42f847b120558f0c6e08f821be23f7",
|
||||
"0xf2601c5a063c6a6ca86d8b6bd0543158f83a10ce",
|
||||
"0xf2dcfa51f83e41c0e988477f939c7a5e5b9a6905",
|
||||
"0xf33068d5e798f6519349ce32669d1ec940db1193",
|
||||
"0xf394d807795ff57373f1fe903046596cb0f69acc",
|
||||
"0xf3ae45d5a0b8efb41444101267b56e6795051fac",
|
||||
"0xf4710562c804087af20b7396ce7cba38b43fb61f",
|
||||
"0xf4c87c851d772b0f9e72cca0690327f2bc505015",
|
||||
"0xf5c86c5ba5291da78a2b4119dd7aef99e3508362",
|
||||
"0xf689bb4b063683a9ffd7a9295106f1e47c095809",
|
||||
"0xf73ef0650415656b51be39bd5d37cf5ce03f4033",
|
||||
"0xf747726200c7fc14ef4ce267b1f070128c2260dd",
|
||||
"0xf855fee50a8915634a105385cb6cb9e442d15457",
|
||||
"0xf8e676094628776690dbf83fa31f08aa14fd3fb8",
|
||||
"0xf902aa3a62f9e28878129e1a8f1eb71e4fd7a88c",
|
||||
"0xf962933e44b1dfc771738a034c80d87bdcd96ecc",
|
||||
"0xf9a5139e42c3ca13d5b24d23ab2b18c278805dc4",
|
||||
"0xf9fe85e19bd533c69067c6e2ff02727d40a54100",
|
||||
"0xfa1f0b67b9b48b2e87467d72f09722e19ed601bd",
|
||||
"0xfa956f10da5e16f120eb299df963c1f7563bf66b",
|
||||
"0xfad17c9da2bebf758e3bc0c99d1abe8af7e96ed1",
|
||||
"0xfb1a7ccf5bcd436dcc0acb49ba1fb9f57bb4d064",
|
||||
"0xfb6e71e0800bccc0db8a9cf326fe3213ca1a0ea0",
|
||||
"0xfb990112f51931a2834fcf574a860b7419ecd76f",
|
||||
"0xfbc79a80ffa84253611e8f9a82f18e9e93ceee81",
|
||||
"0xfbf6f29c126382cf15795bb209ee506a174cc709",
|
||||
"0xfc74fb0d5df08ec546a3da18ccc7fbcbe031f5d8",
|
||||
"0xfcdd417b449c982fc9b6d7b117e417682e81b627",
|
||||
"0xfd477bf560e59941796b398cea662b393298abc0",
|
||||
"0xfddbfa1b0b93612b95e3296690b63b74d019370c",
|
||||
"0xfe68de56a07cd3af0ec40c22b0193115ecdd0501",
|
||||
"0xfe821f5509fdea3f72362d001697ad8c11f0c111",
|
||||
"0xfe9b7db8d9d57e9ad9341bcf51b110ba5d27b48b",
|
||||
"0xff35866acb80ce4b169d1460cd48108955c1c445",
|
||||
// Fake Uniswap phishing
|
||||
"0x0000000000000000000000000000000000000001",
|
||||
// Common address poisoning targets
|
||||
"0x0000000000000000000000000000000000000000",
|
||||
// Known drainer contracts (examples — expand as needed)
|
||||
"0x00000000a991c429ee2ec6df19d40fe0c80088b8",
|
||||
"0xae0ee0a63a2ce6baeeffe56e7714fb4efe48d419",
|
||||
"0x3ee18b2214aff97000d974cf647e7c347e8fa585",
|
||||
"0x55fe002aeff02f77364de339a1292923a15844b8",
|
||||
"0x7f268357a8c2552623316e2562d90e642bb538e5",
|
||||
]);
|
||||
|
||||
function isScamAddress(address) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()];
|
||||
|
||||
@@ -1,503 +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 isUnlimited = inputAmount >= maxUint160;
|
||||
const amountRaw = isUnlimited
|
||||
? "Unlimited"
|
||||
: formatAmount(inputAmount, inInfo.decimals);
|
||||
const amountStr = isUnlimited
|
||||
? "Unlimited"
|
||||
: amountRaw + (inSymbol ? " " + inSymbol : "");
|
||||
details.push({
|
||||
label: "Amount",
|
||||
value: amountStr,
|
||||
rawValue: amountRaw,
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -24,26 +24,6 @@ function hdWalletFromMnemonic(mnemonic) {
|
||||
return { xpub, firstAddress };
|
||||
}
|
||||
|
||||
function hdWalletFromXprv(xprv) {
|
||||
const root = HDNodeWallet.fromExtendedKey(xprv);
|
||||
if (!root.privateKey) {
|
||||
throw new Error("Not an extended private key (xprv).");
|
||||
}
|
||||
const node = root.derivePath("44'/60'/0'/0");
|
||||
const xpub = node.neuter().extendedKey;
|
||||
const firstAddress = node.deriveChild(0).address;
|
||||
return { xpub, firstAddress };
|
||||
}
|
||||
|
||||
function isValidXprv(key) {
|
||||
try {
|
||||
const node = HDNodeWallet.fromExtendedKey(key);
|
||||
return !!node.privateKey;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function addressFromPrivateKey(key) {
|
||||
const w = new Wallet(key);
|
||||
return w.address;
|
||||
@@ -58,11 +38,6 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
|
||||
);
|
||||
return node.deriveChild(addrIndex);
|
||||
}
|
||||
if (walletData.type === "xprv") {
|
||||
const root = HDNodeWallet.fromExtendedKey(decryptedSecret);
|
||||
const node = root.derivePath("44'/60'/0'/0");
|
||||
return node.deriveChild(addrIndex);
|
||||
}
|
||||
return new Wallet(decryptedSecret);
|
||||
}
|
||||
|
||||
@@ -74,8 +49,6 @@ module.exports = {
|
||||
generateMnemonic,
|
||||
deriveAddressFromXpub,
|
||||
hdWalletFromMnemonic,
|
||||
hdWalletFromXprv,
|
||||
isValidXprv,
|
||||
addressFromPrivateKey,
|
||||
getSignerForAddress,
|
||||
isValidMnemonic,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user