Compare commits
88 Commits
feat/messa
...
3b9d3957b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b9d3957b2 | ||
|
|
743d16c479 | ||
| bbf5945ff1 | |||
|
|
607d2349b0 | ||
|
|
3c2d553070 | ||
|
|
55346b484b | ||
|
|
93565c7196 | ||
|
|
71ef08fe85 | ||
|
|
8d230aceb6 | ||
| 6031c3e76c | |||
|
|
436fe22296 | ||
| 82a7db63b5 | |||
| 7c53c48cb1 | |||
| 4d9a8a49b9 | |||
|
|
996003fd79 | ||
|
|
2c9a34aff6 | ||
|
|
173d75c57a | ||
| fb67359b3f | |||
| 1986704569 | |||
| 49c29f6bb3 | |||
| cdb7f478e2 | |||
| cbe77d0224 | |||
|
|
2abb720d54 | ||
|
|
bf9a483031 | ||
| 79fec8551f | |||
|
|
da428a3815 | ||
| 171b21c5d8 | |||
| e7a960c601 | |||
| b69eec40ef | |||
| cacf2c683c | |||
|
|
15e856e63f | ||
| 43e10521ef | |||
| 04ba926d43 | |||
| 4fdbc5adae | |||
| 85427e1fd4 | |||
| 8226495994 | |||
| 2f57370155 | |||
| c6d5cf4e64 | |||
| 34e29d7659 | |||
| 6d0531f1b4 | |||
| 8893f5dce7 | |||
| 2bffa91045 | |||
| 2b0b889b01 | |||
| 5936199676 | |||
|
|
8824237db6 | ||
|
|
aaeb38d7c6 | ||
| f2e44ff4ab | |||
| 107c243f65 | |||
| 655b90c7df | |||
| 34cd72be88 | |||
|
|
689bcbf171 | ||
| 4eefe4c1af | |||
| 3b419c7517 | |||
| 8b7d73cc35 | |||
| 3fd3e30f44 | |||
|
|
76059c3674 | ||
| 8332570758 | |||
| 7b004ddda4 | |||
| 91eefa1667 | |||
| 0ed7b8e61d | |||
|
|
560065dd77 | ||
|
|
27f16191b4 | ||
| 909543e943 | |||
| 04a34d1a5e | |||
| 98f68adb11 | |||
| 0413c52229 | |||
| b01df0639b | |||
| 8beb3cd70c | |||
| 31b22c1325 | |||
| eec96f9054 | |||
| f13cd0fd47 | |||
| b478d9efa9 | |||
| d59ebfd461 | |||
| 13e2bdb0b0 | |||
| 95314ff229 | |||
| 1237cf8491 | |||
| afc4868001 | |||
| a6017ce32c | |||
| 9cceca8576 | |||
| 6a3be80379 | |||
| 3d8feb4c5a | |||
| aca8c4b2a7 | |||
|
|
2244b52f5f | ||
| 9365cd03a6 | |||
| 6b301dee28 | |||
| d67023e80d | |||
| b9250dab2e | |||
| 9e45c75d29 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,3 +25,4 @@ dist/
|
|||||||
|
|
||||||
# Yarn
|
# Yarn
|
||||||
.yarn-integrity
|
.yarn-integrity
|
||||||
|
package-lock.json
|
||||||
|
|||||||
372
README.md
372
README.md
@@ -6,6 +6,24 @@ 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
|
supports sending and receiving ETH and ERC-20 tokens, as well as web3 site
|
||||||
connection and authentication via the EIP-1193 provider API.
|
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
|
## Getting Started
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -24,9 +42,9 @@ Load the extension:
|
|||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
MetaMask has become bloated with swap UIs, portfolio dashboards, analytics,
|
Common popular EVM wallets have become bloated with swap UIs, portfolio
|
||||||
tracking, and advertisements. It is no longer a simple wallet. Most alternatives
|
dashboards, analytics, tracking, and advertisements. It is no longer a simple
|
||||||
(Rabby, Rainbow, etc.) only support Chromium browsers, leaving Firefox users
|
wallet. Most alternatives only support Chromium browsers, leaving Firefox users
|
||||||
without a usable option.
|
without a usable option.
|
||||||
|
|
||||||
AutistMask exists to provide the absolute minimum viable Ethereum wallet
|
AutistMask exists to provide the absolute minimum viable Ethereum wallet
|
||||||
@@ -46,47 +64,70 @@ separate output directories.
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
background/ — service worker / background script
|
background/ — service worker / background script
|
||||||
index.js — extension lifecycle, message routing
|
index.js — RPC routing, approval flows, message signing
|
||||||
wallet.js — wallet management (create, import, derive via ethers.js)
|
content/ — content script injected into web pages
|
||||||
provider.js — EIP-1193 JSON-RPC provider implementation
|
index.js — relay between inpage provider and background
|
||||||
popup/ — popup UI (the main wallet interface)
|
inpage.js — the window.ethereum provider object (EIP-1193)
|
||||||
|
popup/ — popup UI (the main wallet interface)
|
||||||
index.html
|
index.html
|
||||||
index.js
|
index.js — entry point, view routing, state restore
|
||||||
styles/ — CSS (Tailwind)
|
styles/main.css — Tailwind source
|
||||||
content/ — content script injected into web pages
|
views/ — one JS module per screen (home, send, approval, etc.)
|
||||||
index.js — injects the provider into page context
|
shared/ — modules used by both popup and background
|
||||||
inpage.js — the window.ethereum provider object
|
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
|
||||||
shared/ — shared utilities
|
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
|
||||||
vault.js — encrypted storage via libsodium
|
ens.js — ENS forward/reverse resolution
|
||||||
constants.js — chain IDs, default RPC endpoints, ERC-20 ABI
|
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
|
||||||
manifest/
|
manifest/
|
||||||
chrome.json — Manifest V3 for Chrome
|
chrome.json — Manifest V3 for Chrome
|
||||||
firefox.json — Manifest V2/V3 for Firefox
|
firefox.json — Manifest V2 for Firefox
|
||||||
```
|
```
|
||||||
|
|
||||||
### UI Design Philosophy
|
### UI Design Philosophy
|
||||||
|
|
||||||
The UI follows a "Universal Paperclips" aesthetic — a deliberately spartan,
|
The UI is inspired by _Universal Paperclips_. It's deliberately minimal,
|
||||||
almost brutalist approach. The guiding principle is that an unskilled,
|
monochrome, fast, and includes once-popular usability affordances that seem to
|
||||||
non-technical person should be able to figure out how to use it without any
|
have fallen out of fashion in modern UI design. Clickable things look clickable.
|
||||||
crypto knowledge.
|
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.
|
||||||
|
|
||||||
#### Visual Style
|
#### Visual Style
|
||||||
|
|
||||||
- **Monochrome**: Black text on white background. No brand colors, no gradients,
|
- **Monochrome**: Black text on white background. Color is only used when and
|
||||||
no color-coding. Color may be introduced later for specific semantic purposes
|
where it is semantically meaningful and explicitly useful, such as error
|
||||||
(e.g. error states) but the baseline is monochrome.
|
messages, critical warnings, or address disambiguation. (Notable exception: we
|
||||||
|
use color dots, and identicons, to help a user easily distinguish addresses.)
|
||||||
- **Text-first**: Every piece of information is presented as text. Balances are
|
- **Text-first**: Every piece of information is presented as text. Balances are
|
||||||
numbers. Addresses are hex strings. Status is a sentence. No progress spinners
|
numbers. Addresses are hex strings. Flash messages are sentences. All fiddly
|
||||||
with animations — a text status line is sufficient.
|
bits can be clicked to copy to the clipboard, and external links to Etherscan
|
||||||
|
are provided everywhere they might be useful.
|
||||||
- **Monospace font**: All text is rendered in the system monospace font.
|
- **Monospace font**: All text is rendered in the system monospace font.
|
||||||
Ethereum addresses, transaction hashes, and balances are inherently
|
Ethereum addresses, transaction hashes, and balances are inherently
|
||||||
fixed-width data. Rather than mixing proportional and monospace fonts, we use
|
fixed-width data. Rather than mixing proportional and monospace fonts, we use
|
||||||
monospace everywhere for visual consistency and alignment.
|
monospace everywhere for visual consistency and alignment.
|
||||||
- **No images**: Zero image assets in the entire extension. No logos, no
|
- **No images**: Zero image assets in the entire extension. No logos, no
|
||||||
illustrations, no token icons. Token identity is conveyed by symbol text (ETH,
|
illustrations, no token icons. Token identity is conveyed by symbol text (ETH,
|
||||||
USDC, etc.).
|
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.
|
||||||
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
|
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
|
||||||
styling. Tailwind is configured with a minimal monochrome palette. This keeps
|
styling. Tailwind is configured with a minimal monochrome palette. This keeps
|
||||||
the styling co-located with the markup and eliminates CSS file management.
|
the styling co-located with the markup and eliminates CSS file management.
|
||||||
@@ -95,16 +136,19 @@ crypto knowledge.
|
|||||||
would add bundle size, build complexity, and attack surface for no benefit at
|
would add bundle size, build complexity, and attack surface for no benefit at
|
||||||
this scale.
|
this scale.
|
||||||
- **360x600 popup**: Standard browser extension popup dimensions. The UI is
|
- **360x600 popup**: Standard browser extension popup dimensions. The UI is
|
||||||
designed for this fixed viewport — no responsive breakpoints needed.
|
designed for this fixed viewport.
|
||||||
|
|
||||||
#### No Layout Shift
|
#### No Layout Shift
|
||||||
|
|
||||||
Asynchronous state changes (clipboard confirmation, transaction status, error
|
Asynchronous state changes (clipboard confirmation, transaction status, error
|
||||||
messages, flash notifications) must never move existing UI elements. All dynamic
|
messages, flash notifications, API results returning) must _never_ move around
|
||||||
content areas reserve their space up front using `min-height` or always-present
|
the existing UI elements. All dynamic content areas must reserve their space up
|
||||||
wrapper elements. `visibility: hidden` is preferred over `display: none` when
|
front using `min-height` or always-present wrapper elements.
|
||||||
the element's space must be preserved. This prevents jarring content jumps that
|
`visibility: hidden` is preferred over `display: none` when the element's space
|
||||||
disorient users and avoids mis-clicks caused by shifting buttons.
|
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.
|
||||||
|
|
||||||
#### Clickable Affordance
|
#### Clickable Affordance
|
||||||
|
|
||||||
@@ -118,24 +162,40 @@ click it.
|
|||||||
#### Display Consistency
|
#### Display Consistency
|
||||||
|
|
||||||
The same data must be formatted identically everywhere it appears. Token and ETH
|
The same data must be formatted identically everywhere it appears. Token and ETH
|
||||||
amounts are always displayed with exactly 4 decimal places (e.g. "1.0500 ETH",
|
amounts are displayed with exactly 4 decimal places (e.g. "1.0500 ETH", "17.1900
|
||||||
"17.1900 USDT") — in balance lists, transaction lists, transaction details, send
|
USDT") in balance lists, transaction lists, send confirmations, and approval
|
||||||
confirmations, and any other context. Timestamps include both an ISO datetime
|
screens. Timestamps include both an ISO datetime and a humanized relative age
|
||||||
and a humanized relative age wherever shown. If a formatting rule applies in one
|
wherever shown. If a formatting rule applies in one place, it applies in every
|
||||||
place, it applies in every place. Users should never see the same value rendered
|
place. Users should never see the same value rendered differently on two
|
||||||
differently on two screens.
|
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.
|
||||||
|
|
||||||
#### Language & Labeling
|
#### Language & Labeling
|
||||||
|
|
||||||
All user-facing text avoids crypto jargon wherever possible:
|
All user-facing text avoids unnecessary jargon wherever possible:
|
||||||
|
|
||||||
- "Recovery phrase" instead of "seed phrase", "mnemonic", or "BIP-39 mnemonic"
|
- "Recovery phrase" instead of "seed phrase", "mnemonic", or "BIP-39 mnemonic"
|
||||||
- "Address" instead of "account", "derived key", or "HD child"
|
- "Address" instead of "account", "derived key", or "HD child"
|
||||||
- "Password" instead of "encryption key" or "vault passphrase"
|
- "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",
|
- Buttons use plain verbs: "Send", "Receive", "Copy address", "Add", "Back",
|
||||||
"Cancel", "Lock", "Unlock", "Allow", "Deny"
|
"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
|
- Helpful inline descriptions where needed (e.g. "This password locks the wallet
|
||||||
on this device. It is not the same as your recovery phrase.")
|
on this device. It is not the same as your recovery phrase.")
|
||||||
- Error messages are full sentences ("Please enter your password." not "password
|
- Error messages are full sentences ("Please enter your password." not "password
|
||||||
@@ -144,14 +204,14 @@ All user-facing text avoids crypto jargon wherever possible:
|
|||||||
#### Full Identifiers Policy
|
#### Full Identifiers Policy
|
||||||
|
|
||||||
Addresses, transaction hashes, contract addresses, and all other cryptographic
|
Addresses, transaction hashes, contract addresses, and all other cryptographic
|
||||||
identifiers are displayed in full whenever possible — never truncated. Address
|
identifiers are displayed in full whenever possible. We truncate only in
|
||||||
poisoning attacks exploit truncated displays by generating fraud addresses that
|
specific, limited, non-critical places and even then only a small amount that
|
||||||
share the same prefix and suffix as a legitimate address. If a user only sees
|
still prevents spoofing attacks. Address poisoning attacks exploit truncated
|
||||||
`0xAbCd...1234`, an attacker can create an address with the same visible
|
displays by generating fraud addresses that share the same prefix and suffix as
|
||||||
characters and trick the user into sending funds to it. Showing the complete
|
a legitimate address. If a user only sees `0xAbCd...1234`, an attacker can
|
||||||
identifier defeats this class of attack. Truncation is only acceptable in
|
create an address with the same visible characters and trick the user into
|
||||||
space-constrained contexts where the full identifier is accessible one tap away
|
sending funds to it. Showing the complete identifier defeats this class of
|
||||||
(e.g. a tooltip or copy action).
|
attack.
|
||||||
|
|
||||||
#### Data Model
|
#### Data Model
|
||||||
|
|
||||||
@@ -437,37 +497,71 @@ transitions.
|
|||||||
#### TxApproval
|
#### TxApproval
|
||||||
|
|
||||||
- **When**: A connected website requests a transaction via
|
- **When**: A connected website requests a transaction via
|
||||||
`eth_sendTransaction`. Opened in a separate popup by the background script.
|
`eth_sendTransaction`. Opened via the toolbar popup by the background script.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Transaction Request" heading
|
- "Transaction Request" heading
|
||||||
- Site hostname (bold) + "wants to send a transaction"
|
- Site hostname (bold) + "wants to send a transaction"
|
||||||
|
- Decoded action (if calldata is recognized): action name, token details,
|
||||||
|
amounts, steps, deadline (see Transaction Decoding)
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
- To: color dot + full address + etherscan link (or "contract creation")
|
- To/Contract: color dot + full address + etherscan link (or "contract
|
||||||
|
creation"), token symbol label if known
|
||||||
- Value: amount in ETH (4 decimal places)
|
- Value: amount in ETH (4 decimal places)
|
||||||
- Data: raw transaction data (shown if present)
|
- Raw data: full calldata displayed inline (shown if present)
|
||||||
- Password input
|
- Password input
|
||||||
- "Confirm" / "Reject" buttons
|
- "Confirm" / "Reject" buttons
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Confirm" (with password) → closes popup (returns result to background)
|
- "Confirm" (with password) → closes popup (returns result to background)
|
||||||
- "Reject" → closes popup (returns rejection 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
|
### External Services
|
||||||
|
|
||||||
AutistMask is not a fully self-contained offline tool. It necessarily
|
AutistMask is not a fully self-contained offline tool. It necessarily
|
||||||
communicates with external services to function as a wallet:
|
communicates with three external services to function as a wallet:
|
||||||
|
|
||||||
- **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query
|
- **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query
|
||||||
balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`),
|
balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`),
|
||||||
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
|
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
|
||||||
broadcast transactions (`eth_sendRawTransaction`), and check transaction
|
broadcast transactions (`eth_sendRawTransaction`), and check transaction
|
||||||
receipts. The default endpoint is a public RPC (configurable by the user to
|
receipts. The default endpoint is a public RPC (configurable by the user to
|
||||||
any endpoint they prefer, including a local node). This is the only external
|
any endpoint they prefer, including a local node). By default the extension
|
||||||
service the extension talks to.
|
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.
|
||||||
|
|
||||||
- **CoinDesk CADLI price API**: Used to fetch ETH/USD and token/USD prices for
|
- **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
|
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
|
requests. No API key required. No user data is sent — only a list of token
|
||||||
symbols.
|
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.
|
||||||
|
|
||||||
What the extension does NOT do:
|
What the extension does NOT do:
|
||||||
|
|
||||||
@@ -477,14 +571,15 @@ What the extension does NOT do:
|
|||||||
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
|
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
|
||||||
- No backend servers operated by the developer
|
- No backend servers operated by the developer
|
||||||
|
|
||||||
The user's RPC endpoint and the CoinDesk price API are the only external
|
These three services (RPC endpoint, CoinDesk price API, and Blockscout API) are
|
||||||
services. Users who want maximum privacy can point the RPC at their own node
|
the only external services. All three endpoints are user-configurable. Users who
|
||||||
(price fetching can be disabled in a future version).
|
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).
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
AutistMask uses two runtime libraries. All cryptographic operations are
|
AutistMask uses four runtime libraries. All cryptographic operations are
|
||||||
delegated to these libraries — see the Crypto Policy section below.
|
delegated to ethers and libsodium — see the Crypto Policy section below.
|
||||||
|
|
||||||
| Package | Version | License | Purpose |
|
| Package | Version | License | Purpose |
|
||||||
| -------------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -509,17 +604,11 @@ Dev dependencies (not shipped in extension):
|
|||||||
`pbkdf`, `hmac`, `encrypt`, `decrypt`, `hash`, `cipher`, `digest`, `sign`
|
`pbkdf`, `hmac`, `encrypt`, `decrypt`, `hash`, `cipher`, `digest`, `sign`
|
||||||
(case-insensitive) appear in our own source code (outside of `node_modules/`),
|
(case-insensitive) appear in our own source code (outside of `node_modules/`),
|
||||||
it is almost certainly a bug. All cryptographic operations must go through
|
it is almost certainly a bug. All cryptographic operations must go through
|
||||||
`ethers` or `libsodium-wrappers-sumo`. This policy exists because:
|
`ethers` or `libsodium-wrappers-sumo`. Both are widely audited and
|
||||||
|
battle-tested.
|
||||||
- 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
|
Exceptions require explicit authorization in a code comment referencing this
|
||||||
policy.
|
policy, but as of now there are none.
|
||||||
|
|
||||||
### DEBUG Mode Policy
|
### DEBUG Mode Policy
|
||||||
|
|
||||||
@@ -551,12 +640,12 @@ project owner.
|
|||||||
- **The password is NOT used in address derivation.** It exists solely to
|
- **The password is NOT used in address derivation.** It exists solely to
|
||||||
protect the recovery phrase / private key on disk. Anyone with the
|
protect the recovery phrase / private key on disk. Anyone with the
|
||||||
recovery phrase can restore the wallet on any device without this
|
recovery phrase can restore the wallet on any device without this
|
||||||
password. This matches MetaMask's behavior.
|
password. This matches standard EVM wallet behavior.
|
||||||
- **BIP-39 / BIP-44 via ethers.js**: Mnemonic generation, validation, and HD key
|
- **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
|
derivation (`m/44'/60'/0'/0/n`) are handled entirely by ethers.js. The BIP-39
|
||||||
passphrase is always empty (matching MetaMask and most wallet software). The
|
passphrase is always empty (matching most wallet software). The user's
|
||||||
user's password is completely separate and has no effect on which addresses
|
password is completely separate and has no effect on which addresses are
|
||||||
are generated.
|
generated.
|
||||||
- **ethers.js for everything Ethereum**: Transaction construction, signing, gas
|
- **ethers.js for everything Ethereum**: Transaction construction, signing, gas
|
||||||
estimation, RPC communication, ERC-20 contract calls, and address derivation
|
estimation, RPC communication, ERC-20 contract calls, and address derivation
|
||||||
are all handled by ethers.js. This means zero hand-rolled Ethereum logic.
|
are all handled by ethers.js. This means zero hand-rolled Ethereum logic.
|
||||||
@@ -578,13 +667,16 @@ project owner.
|
|||||||
- View ERC-20 token balances (user adds token by contract address)
|
- View ERC-20 token balances (user adds token by contract address)
|
||||||
- Send ETH to an address
|
- Send ETH to an address
|
||||||
- Send ERC-20 tokens to an address
|
- Send ERC-20 tokens to an address
|
||||||
- Receive ETH/tokens (display address, copy to clipboard)
|
- Receive ETH/tokens (display address, copy to clipboard, QR code)
|
||||||
- Connect to web3 sites (EIP-1193 `eth_requestAccounts`)
|
- Connect to web3 sites (EIP-1193 `eth_requestAccounts`)
|
||||||
- Sign transactions requested by connected sites
|
- Sign transactions requested by connected sites (`eth_sendTransaction`)
|
||||||
- Sign messages (`personal_sign`, `eth_sign`)
|
- Sign messages (`personal_sign`, `eth_sign`)
|
||||||
- Lock/unlock with password
|
- Sign typed data (`eth_signTypedData_v4`, `eth_signTypedData`)
|
||||||
- Configurable RPC endpoint
|
- Human-readable transaction decoding (ERC-20, Uniswap Universal Router)
|
||||||
- Future: USD value display (and other fiat currencies)
|
- ETH/USD and token/USD price display
|
||||||
|
- Configurable RPC endpoint and Blockscout API
|
||||||
|
- Address poisoning protection (spam token filtering, dust filtering, fraud
|
||||||
|
contract blocklist)
|
||||||
|
|
||||||
### Address Poisoning and Fake Token Transfer Attacks
|
### Address Poisoning and Fake Token Transfer Attacks
|
||||||
|
|
||||||
@@ -672,104 +764,84 @@ indexes it as a real token transfer.
|
|||||||
designed as a sharp tool — users who understand the risks can configure the
|
designed as a sharp tool — users who understand the risks can configure the
|
||||||
wallet to show everything unfiltered, unix-style.
|
wallet to show everything unfiltered, unix-style.
|
||||||
|
|
||||||
### Non-Goals
|
#### Transaction Decoding
|
||||||
|
|
||||||
- Token swaps (use a DEX in the browser)
|
When a dApp asks the user to approve a transaction, AutistMask attempts to
|
||||||
- NFT display or management
|
decode the calldata into a human-readable summary. This is purely a display
|
||||||
- Multi-chain support (Ethereum mainnet only, for now)
|
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)
|
||||||
- Analytics, telemetry, or tracking of any kind
|
- Analytics, telemetry, or tracking of any kind
|
||||||
- Advertisements or promotions
|
- Advertisements or promotions
|
||||||
- Phishing detection
|
- Obscure token list auto-discovery (user adds tokens manually)
|
||||||
- Hardware wallet support (maybe later)
|
- We detect common/popular ERC20s in the basic case
|
||||||
- Token list auto-discovery (user adds tokens manually)
|
|
||||||
- Fiat on/off ramps
|
- Fiat on/off ramps
|
||||||
- Browser notifications
|
- Extensive transaction decoding/parsing
|
||||||
- Transaction history (use Etherscan)
|
- For common ones we will do best-effort, but you should just use a block
|
||||||
|
explorer.
|
||||||
|
|
||||||
## TODO — 0.1.0 MVP
|
### Non-Goals for 1.0
|
||||||
|
|
||||||
Everything needed for a minimal working wallet that can send and receive ETH.
|
- Multi-chain support (Ethereum mainnet only)
|
||||||
|
- Hardware wallet support
|
||||||
|
|
||||||
### Done
|
## TODO
|
||||||
|
|
||||||
- [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
|
### 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 wallet (with confirmation)
|
||||||
- [ ] Delete address from HD wallet (with confirmation)
|
- [ ] Delete address from HD wallet (with confirmation)
|
||||||
- [ ] Show wallet's recovery phrase (requires password, from Settings or wallet
|
- [ ] Show wallet's recovery phrase (requires password)
|
||||||
context menu)
|
|
||||||
|
|
||||||
### Sending
|
### Transactions
|
||||||
|
|
||||||
- [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
|
- [ ] 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
|
### Testing
|
||||||
|
|
||||||
- [ ] Tests for mnemonic generation and address derivation
|
- [ ] Tests for mnemonic generation and address derivation
|
||||||
- [ ] Tests for xpub derivation and child address generation
|
- [ ] 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)
|
- [ ] Test on Firefox (Manifest V2)
|
||||||
|
|
||||||
### Scam List
|
### Scam List
|
||||||
|
|
||||||
- [ ] Research and document each address in scamlist.js (what it is, why it's on
|
- [ ] Research and document each address in scamlist.js
|
||||||
the list, source)
|
- [ ] Add more known fraud addresses from Etherscan labels
|
||||||
- [ ] Add more known fraud addresses from Etherscan labels (drainers, phishing,
|
|
||||||
address poisoning deployers)
|
|
||||||
|
|
||||||
### Post-MVP
|
### Future
|
||||||
|
|
||||||
- [ ] 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.)
|
- [ ] Multi-currency fiat display (EUR, GBP, etc.)
|
||||||
- [ ] Security audit of key management
|
- [ ] 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
|
## License
|
||||||
|
|
||||||
GPL-3.0. See [LICENSE](LICENSE).
|
GPL-3.0. See [LICENSE](LICENSE).
|
||||||
|
|||||||
121
RULES.md
Normal file
121
RULES.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
> **⚠️ 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,7 +30,6 @@ const connectedSites = {};
|
|||||||
|
|
||||||
// Pending approval requests: { id: { origin, hostname, resolve } }
|
// Pending approval requests: { id: { origin, hostname, resolve } }
|
||||||
const pendingApprovals = {};
|
const pendingApprovals = {};
|
||||||
let nextApprovalId = 1;
|
|
||||||
|
|
||||||
async function getState() {
|
async function getState() {
|
||||||
const result = await storageApi.get("autistmask");
|
const result = await storageApi.get("autistmask");
|
||||||
@@ -94,11 +93,13 @@ function resetPopupUrl() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: open approval in a separate window (used when openPopup is unavailable)
|
// 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.
|
||||||
function openApprovalWindow(id) {
|
function openApprovalWindow(id) {
|
||||||
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
|
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
|
||||||
const popupWidth = 400;
|
const popupWidth = 360;
|
||||||
const popupHeight = 500;
|
const popupHeight = 600;
|
||||||
|
|
||||||
windowsApi.getLastFocused((currentWin) => {
|
windowsApi.getLastFocused((currentWin) => {
|
||||||
const opts = {
|
const opts = {
|
||||||
@@ -127,7 +128,7 @@ function openApprovalWindow(id) {
|
|||||||
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
|
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
|
||||||
function requestApproval(origin, hostname) {
|
function requestApproval(origin, hostname) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = nextApprovalId++;
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = { origin, hostname, resolve };
|
pendingApprovals[id] = { origin, hostname, resolve };
|
||||||
|
|
||||||
if (actionApi && typeof actionApi.openPopup === "function") {
|
if (actionApi && typeof actionApi.openPopup === "function") {
|
||||||
@@ -149,9 +150,12 @@ function requestApproval(origin, hostname) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Open a tx-approval popup and return a promise that resolves with txHash or error.
|
// 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) {
|
function requestTxApproval(origin, hostname, txParams) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = nextApprovalId++;
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = {
|
pendingApprovals[id] = {
|
||||||
origin,
|
origin,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -160,30 +164,17 @@ function requestTxApproval(origin, hostname, txParams) {
|
|||||||
type: "tx",
|
type: "tx",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (actionApi && typeof actionApi.openPopup === "function") {
|
openApprovalWindow(id);
|
||||||
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 }.
|
// Open a sign-approval popup and return a promise that resolves with { signature } or { error }.
|
||||||
// Uses the toolbar popup only — no fallback window. If openPopup() fails the
|
// Uses windows.create() directly because sign approvals are triggered programmatically
|
||||||
// popup URL is still set, so the user can click the toolbar icon to respond.
|
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
||||||
|
// unreliable in this context.
|
||||||
function requestSignApproval(origin, hostname, signParams) {
|
function requestSignApproval(origin, hostname, signParams) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = nextApprovalId++;
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = {
|
pendingApprovals[id] = {
|
||||||
origin,
|
origin,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -192,47 +183,25 @@ function requestSignApproval(origin, hostname, signParams) {
|
|||||||
type: "sign",
|
type: "sign",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (actionApi && typeof actionApi.setPopup === "function") {
|
openApprovalWindow(id);
|
||||||
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.
|
// Detect when an approval popup (browser-action) closes without a response.
|
||||||
// Sign approvals are NOT auto-rejected on disconnect because toolbar popups
|
// TX and sign approvals now use windows.create() and are handled by the
|
||||||
// naturally close on focus loss and the user can reopen them.
|
// windowsApi.onRemoved listener below, but we still handle site-connection
|
||||||
|
// approval disconnects here.
|
||||||
runtime.onConnect.addListener((port) => {
|
runtime.onConnect.addListener((port) => {
|
||||||
if (port.name.startsWith("approval:")) {
|
if (port.name.startsWith("approval:")) {
|
||||||
const id = parseInt(port.name.split(":")[1], 10);
|
const id = port.name.split(":")[1];
|
||||||
port.onDisconnect.addListener(() => {
|
port.onDisconnect.addListener(() => {
|
||||||
const approval = pendingApprovals[id];
|
const approval = pendingApprovals[id];
|
||||||
if (approval) {
|
if (approval) {
|
||||||
if (approval.type === "sign") {
|
if (approval.type === "tx" || approval.type === "sign") {
|
||||||
// Keep pending — user can reopen the toolbar popup
|
// Keep pending — user can reopen the toolbar popup
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (approval.type === "tx") {
|
approval.resolve({ approved: false, remember: false });
|
||||||
approval.resolve({
|
|
||||||
error: {
|
|
||||||
code: 4001,
|
|
||||||
message: "User rejected the request.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
approval.resolve({ approved: false, remember: false });
|
|
||||||
}
|
|
||||||
delete pendingApprovals[id];
|
delete pendingApprovals[id];
|
||||||
}
|
}
|
||||||
resetPopupUrl();
|
resetPopupUrl();
|
||||||
@@ -450,6 +419,13 @@ async function handleRpc(method, params, origin) {
|
|||||||
? { method, message: params[0], from: params[1] }
|
? { method, message: params[0], from: params[1] }
|
||||||
: { method, message: params[1], from: params[0] };
|
: { 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(
|
const decision = await requestSignApproval(
|
||||||
origin,
|
origin,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -526,7 +502,13 @@ async function broadcastAccountsChanged() {
|
|||||||
}
|
}
|
||||||
// Reject and close any pending approval popups so they don't hang
|
// Reject and close any pending approval popups so they don't hang
|
||||||
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
||||||
approval.resolve({ approved: false, remember: false });
|
if (approval.type === "tx" || approval.type === "sign") {
|
||||||
|
approval.resolve({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
approval.resolve({ approved: false, remember: false });
|
||||||
|
}
|
||||||
if (approval.windowId) {
|
if (approval.windowId) {
|
||||||
windowsApi.remove(approval.windowId, () => {
|
windowsApi.remove(approval.windowId, () => {
|
||||||
if (runtime.lastError) {
|
if (runtime.lastError) {
|
||||||
@@ -613,12 +595,39 @@ if (windowsApi && windowsApi.onRemoved) {
|
|||||||
// Listen for messages from content scripts and popup
|
// Listen for messages from content scripts and popup
|
||||||
runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
if (msg.type === "AUTISTMASK_RPC") {
|
if (msg.type === "AUTISTMASK_RPC") {
|
||||||
handleRpc(msg.method, msg.params, msg.origin).then((response) => {
|
// 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) => {
|
||||||
sendResponse(response);
|
sendResponse(response);
|
||||||
});
|
});
|
||||||
return true;
|
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") {
|
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
||||||
const approval = pendingApprovals[msg.id];
|
const approval = pendingApprovals[msg.id];
|
||||||
if (approval) {
|
if (approval) {
|
||||||
@@ -683,7 +692,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
if (wallet) break;
|
if (wallet) break;
|
||||||
}
|
}
|
||||||
if (!wallet) throw new Error("Wallet not found");
|
if (!wallet) throw new Error("Wallet not found");
|
||||||
const decrypted = await decryptWithPassword(
|
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
|
||||||
|
let decrypted = await decryptWithPassword(
|
||||||
wallet.encryptedSecret,
|
wallet.encryptedSecret,
|
||||||
msg.password,
|
msg.password,
|
||||||
);
|
);
|
||||||
@@ -692,6 +702,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
addrIndex,
|
addrIndex,
|
||||||
decrypted,
|
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 provider = getProvider(state.rpcUrl);
|
||||||
const connected = signer.connect(provider);
|
const connected = signer.connect(provider);
|
||||||
const tx = await connected.sendTransaction(approval.txParams);
|
const tx = await connected.sendTransaction(approval.txParams);
|
||||||
@@ -737,7 +751,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
if (wallet) break;
|
if (wallet) break;
|
||||||
}
|
}
|
||||||
if (!wallet) throw new Error("Wallet not found");
|
if (!wallet) throw new Error("Wallet not found");
|
||||||
const decrypted = await decryptWithPassword(
|
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
|
||||||
|
let decrypted = await decryptWithPassword(
|
||||||
wallet.encryptedSecret,
|
wallet.encryptedSecret,
|
||||||
msg.password,
|
msg.password,
|
||||||
);
|
);
|
||||||
@@ -746,6 +761,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
addrIndex,
|
addrIndex,
|
||||||
decrypted,
|
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;
|
const sp = approval.signParams;
|
||||||
let signature;
|
let signature;
|
||||||
|
|||||||
@@ -13,6 +13,26 @@ if (typeof browser !== "undefined") {
|
|||||||
(document.head || document.documentElement).appendChild(script);
|
(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
|
// Relay requests from the page to the background script
|
||||||
window.addEventListener("message", (event) => {
|
window.addEventListener("message", (event) => {
|
||||||
if (event.source !== window) return;
|
if (event.source !== window) return;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
const pending = {};
|
const pending = {};
|
||||||
|
|
||||||
// Listen for responses from the content script
|
// Listen for responses from the content script
|
||||||
window.addEventListener("message", (event) => {
|
window.addEventListener("message", function onUuid(event) {
|
||||||
if (event.source !== window) return;
|
if (event.source !== window) return;
|
||||||
if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
|
if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
|
||||||
const { id, result, error } = event.data;
|
const { id, result, error } = event.data;
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Listen for events pushed from the extension
|
// Listen for events pushed from the extension
|
||||||
window.addEventListener("message", (event) => {
|
window.addEventListener("message", function onUuid(event) {
|
||||||
if (event.source !== window) return;
|
if (event.source !== window) return;
|
||||||
if (event.data?.type !== "AUTISTMASK_EVENT") return;
|
if (event.data?.type !== "AUTISTMASK_EVENT") return;
|
||||||
const { eventName, data } = event.data;
|
const { eventName, data } = event.data;
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
// Some dApps (wagmi) check this to confirm MetaMask-like behavior
|
// Some dApps (wagmi) check this to confirm MetaMask-like behavior
|
||||||
_metamask: {
|
_metamask: {
|
||||||
isUnlocked() {
|
isUnlocked() {
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(provider.selectedAddress !== null);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -155,21 +155,38 @@
|
|||||||
"</svg>",
|
"</svg>",
|
||||||
);
|
);
|
||||||
|
|
||||||
const providerInfo = {
|
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
|
||||||
uuid: "f3c5b2a1-8d4e-4f6a-9c7b-1e2d3a4b5c6d",
|
|
||||||
name: "AutistMask",
|
function buildProviderInfo() {
|
||||||
icon: ICON_SVG,
|
return {
|
||||||
rdns: "berlin.sneak.autistmask",
|
uuid: providerUuid,
|
||||||
};
|
name: "AutistMask",
|
||||||
|
icon: ICON_SVG,
|
||||||
|
rdns: "berlin.sneak.autistmask",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function announceProvider() {
|
function announceProvider() {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("eip6963:announceProvider", {
|
new CustomEvent("eip6963:announceProvider", {
|
||||||
detail: Object.freeze({ info: providerInfo, provider }),
|
detail: Object.freeze({
|
||||||
|
info: buildProviderInfo(),
|
||||||
|
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);
|
window.addEventListener("eip6963:requestProvider", announceProvider);
|
||||||
announceProvider();
|
announceProvider();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
<button
|
<button
|
||||||
id="btn-settings"
|
id="btn-settings"
|
||||||
class="bg-transparent border-none text-fg cursor-pointer text-2xl p-0 leading-none"
|
class="border border-border text-fg cursor-pointer text-xl px-1 leading-none hover:bg-fg hover:text-bg"
|
||||||
title="Settings"
|
title="Settings"
|
||||||
>
|
>
|
||||||
⚙
|
⚙
|
||||||
@@ -119,6 +119,15 @@
|
|||||||
Import private key
|
Import private key
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-muted">
|
||||||
|
Have an extended private key (xprv)?
|
||||||
|
<button
|
||||||
|
id="btn-add-wallet-import-xprv"
|
||||||
|
class="underline cursor-pointer bg-transparent border-none text-fg text-xs font-mono p-0"
|
||||||
|
>
|
||||||
|
Import xprv
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============ IMPORT PRIVATE KEY ============ -->
|
<!-- ============ IMPORT PRIVATE KEY ============ -->
|
||||||
@@ -170,6 +179,55 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ IMPORT XPRV ============ -->
|
||||||
|
<div id="view-import-xprv" class="view hidden">
|
||||||
|
<button
|
||||||
|
id="btn-import-xprv-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 Extended Private Key</h2>
|
||||||
|
<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 class="mb-2" id="import-xprv-password-section">
|
||||||
|
<label class="block mb-1">Choose a password</label>
|
||||||
|
<p class="text-xs text-muted mb-1">
|
||||||
|
This password encrypts your key on this device. You will
|
||||||
|
need it to send funds.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="import-xprv-password"
|
||||||
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2" id="import-xprv-password-confirm-section">
|
||||||
|
<label class="block mb-1">Confirm password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="import-xprv-password-confirm"
|
||||||
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="btn-import-xprv-confirm"
|
||||||
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
|
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
|
||||||
<div id="view-main" class="view hidden">
|
<div id="view-main" class="view hidden">
|
||||||
<!-- active address headline -->
|
<!-- active address headline -->
|
||||||
@@ -274,11 +332,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="address-usd-total"
|
id="address-usd-total"
|
||||||
class="text-xs text-muted mb-3 text-right"
|
class="text-xs text-muted mb-3 text-right min-h-[1rem]"
|
||||||
></div>
|
>
|
||||||
|
|
||||||
|
</div>
|
||||||
<!-- balances -->
|
<!-- balances -->
|
||||||
<div class="border-b border-border-light pb-2 mb-2">
|
<div class="border-b border-border-light pb-2 mb-2">
|
||||||
<div id="address-balances"></div>
|
<div id="address-balances" class="min-h-[1.25rem]">
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- actions -->
|
<!-- actions -->
|
||||||
@@ -343,11 +405,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="address-token-usd-total"
|
id="address-token-usd-total"
|
||||||
class="text-xs text-muted mb-3 text-right"
|
class="text-xs text-muted mb-3 text-right min-h-[1rem]"
|
||||||
></div>
|
>
|
||||||
|
|
||||||
|
</div>
|
||||||
<!-- single token balance -->
|
<!-- single token balance -->
|
||||||
<div class="border-b border-border-light pb-2 mb-2">
|
<div class="border-b border-border-light pb-2 mb-2">
|
||||||
<div id="address-token-balance"></div>
|
<div id="address-token-balance" class="min-h-[1.25rem]">
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- actions -->
|
<!-- actions -->
|
||||||
@@ -366,6 +432,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- token-filtered transactions -->
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div class="border-b border-border pb-1 mb-1">
|
<div class="border-b border-border pb-1 mb-1">
|
||||||
@@ -694,9 +766,7 @@
|
|||||||
|
|
||||||
<div class="bg-well p-3 mx-1 mb-3">
|
<div class="bg-well p-3 mx-1 mb-3">
|
||||||
<h3 class="font-bold mb-1">Wallets</h3>
|
<h3 class="font-bold mb-1">Wallets</h3>
|
||||||
<p class="text-xs text-muted mb-2">
|
<div id="settings-wallet-list" class="mb-2"></div>
|
||||||
Add a new wallet from a recovery phrase or private key.
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
id="btn-main-add-wallet"
|
id="btn-main-add-wallet"
|
||||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
@@ -705,6 +775,21 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="bg-well p-3 mx-1 mb-3">
|
||||||
<h3 class="font-bold mb-1">Display</h3>
|
<h3 class="font-bold mb-1">Display</h3>
|
||||||
<label
|
<label
|
||||||
@@ -816,6 +901,108 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ============ -->
|
<!-- ============ TRANSACTION DETAIL ============ -->
|
||||||
<div id="view-transaction" class="view hidden">
|
<div id="view-transaction" class="view hidden">
|
||||||
<button
|
<button
|
||||||
@@ -824,7 +1011,13 @@
|
|||||||
>
|
>
|
||||||
< Back
|
< Back
|
||||||
</button>
|
</button>
|
||||||
<h2 class="font-bold mb-2">Transaction</h2>
|
<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>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="text-xs text-muted mb-1">Status</div>
|
<div class="text-xs text-muted mb-1">Status</div>
|
||||||
<div id="tx-detail-status" class="text-xs"></div>
|
<div id="tx-detail-status" class="text-xs"></div>
|
||||||
@@ -835,7 +1028,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="text-xs text-muted mb-1">Amount</div>
|
<div class="text-xs text-muted mb-1">Amount</div>
|
||||||
<div id="tx-detail-value" class="text-xs font-bold"></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>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="text-xs text-muted mb-1">From</div>
|
<div class="text-xs text-muted mb-1">From</div>
|
||||||
@@ -845,10 +1042,33 @@
|
|||||||
<div class="text-xs text-muted mb-1">To</div>
|
<div class="text-xs text-muted mb-1">To</div>
|
||||||
<div id="tx-detail-to" class="text-xs break-all"></div>
|
<div id="tx-detail-to" class="text-xs break-all"></div>
|
||||||
</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="mb-4">
|
||||||
<div class="text-xs text-muted mb-1">Transaction hash</div>
|
<div class="text-xs text-muted mb-1">Transaction hash</div>
|
||||||
<div id="tx-detail-hash" class="text-xs break-all"></div>
|
<div id="tx-detail-hash" class="text-xs break-all"></div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ============ TRANSACTION APPROVAL ============ -->
|
<!-- ============ TRANSACTION APPROVAL ============ -->
|
||||||
@@ -886,11 +1106,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="approve-tx-data-section" class="mb-3 hidden">
|
<div id="approve-tx-data-section" class="mb-3 hidden">
|
||||||
<div class="text-xs text-muted mb-1">Raw data</div>
|
<div class="text-xs text-muted mb-1">Raw data</div>
|
||||||
<div
|
<div id="approve-tx-data" class="text-xs break-all"></div>
|
||||||
id="approve-tx-data"
|
|
||||||
class="text-xs break-all"
|
|
||||||
style="max-height: 4rem; overflow-y: auto"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
@@ -925,6 +1141,17 @@
|
|||||||
wants you to sign a message.
|
wants you to sign a message.
|
||||||
</p>
|
</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="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Type</div>
|
<div class="text-xs text-muted mb-1">Type</div>
|
||||||
<div id="approve-sign-type" class="text-xs font-bold"></div>
|
<div id="approve-sign-type" class="text-xs font-bold"></div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const home = require("./views/home");
|
|||||||
const welcome = require("./views/welcome");
|
const welcome = require("./views/welcome");
|
||||||
const addWallet = require("./views/addWallet");
|
const addWallet = require("./views/addWallet");
|
||||||
const importKey = require("./views/importKey");
|
const importKey = require("./views/importKey");
|
||||||
|
const importXprv = require("./views/importXprv");
|
||||||
const addressDetail = require("./views/addressDetail");
|
const addressDetail = require("./views/addressDetail");
|
||||||
const addressToken = require("./views/addressToken");
|
const addressToken = require("./views/addressToken");
|
||||||
const send = require("./views/send");
|
const send = require("./views/send");
|
||||||
@@ -20,6 +21,7 @@ const transactionDetail = require("./views/transactionDetail");
|
|||||||
const receive = require("./views/receive");
|
const receive = require("./views/receive");
|
||||||
const addToken = require("./views/addToken");
|
const addToken = require("./views/addToken");
|
||||||
const settings = require("./views/settings");
|
const settings = require("./views/settings");
|
||||||
|
const settingsAddToken = require("./views/settingsAddToken");
|
||||||
const approval = require("./views/approval");
|
const approval = require("./views/approval");
|
||||||
|
|
||||||
function renderWalletList() {
|
function renderWalletList() {
|
||||||
@@ -54,12 +56,15 @@ const ctx = {
|
|||||||
doRefreshAndRender,
|
doRefreshAndRender,
|
||||||
showAddWalletView: () => addWallet.show(),
|
showAddWalletView: () => addWallet.show(),
|
||||||
showImportKeyView: () => importKey.show(),
|
showImportKeyView: () => importKey.show(),
|
||||||
|
showImportXprvView: () => importXprv.show(),
|
||||||
showAddressDetail: () => addressDetail.show(),
|
showAddressDetail: () => addressDetail.show(),
|
||||||
showAddressToken: () => addressToken.show(),
|
showAddressToken: () => addressToken.show(),
|
||||||
showAddTokenView: () => addToken.show(),
|
showAddTokenView: () => addToken.show(),
|
||||||
showConfirmTx: (txInfo) => confirmTx.show(txInfo),
|
showConfirmTx: (txInfo) => confirmTx.show(txInfo),
|
||||||
showReceive: () => receive.show(),
|
showReceive: () => receive.show(),
|
||||||
showTransactionDetail: (tx) => transactionDetail.show(tx),
|
showTransactionDetail: (tx) => transactionDetail.show(tx),
|
||||||
|
showSettingsView: () => settings.show(),
|
||||||
|
showSettingsAddTokenView: () => settingsAddToken.show(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Views that can be fully re-rendered from persisted state.
|
// Views that can be fully re-rendered from persisted state.
|
||||||
@@ -70,6 +75,7 @@ const RESTORABLE_VIEWS = new Set([
|
|||||||
"address-token",
|
"address-token",
|
||||||
"receive",
|
"receive",
|
||||||
"settings",
|
"settings",
|
||||||
|
"settings-addtoken",
|
||||||
"transaction",
|
"transaction",
|
||||||
"success-tx",
|
"success-tx",
|
||||||
"error-tx",
|
"error-tx",
|
||||||
@@ -120,6 +126,9 @@ function restoreView() {
|
|||||||
case "settings":
|
case "settings":
|
||||||
settings.show();
|
settings.show();
|
||||||
break;
|
break;
|
||||||
|
case "settings-addtoken":
|
||||||
|
settingsAddToken.show();
|
||||||
|
break;
|
||||||
case "transaction":
|
case "transaction":
|
||||||
if (state.viewData && state.viewData.tx) {
|
if (state.viewData && state.viewData.tx) {
|
||||||
transactionDetail.render();
|
transactionDetail.render();
|
||||||
@@ -182,7 +191,7 @@ async function init() {
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const approvalId = params.get("approval");
|
const approvalId = params.get("approval");
|
||||||
if (approvalId) {
|
if (approvalId) {
|
||||||
approval.show(parseInt(approvalId, 10));
|
approval.show(approvalId);
|
||||||
showView("approve-site");
|
showView("approve-site");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -203,6 +212,7 @@ async function init() {
|
|||||||
welcome.init(ctx);
|
welcome.init(ctx);
|
||||||
addWallet.init(ctx);
|
addWallet.init(ctx);
|
||||||
importKey.init(ctx);
|
importKey.init(ctx);
|
||||||
|
importXprv.init(ctx);
|
||||||
home.init(ctx);
|
home.init(ctx);
|
||||||
addressDetail.init(ctx);
|
addressDetail.init(ctx);
|
||||||
addressToken.init(ctx);
|
addressToken.init(ctx);
|
||||||
@@ -212,6 +222,7 @@ async function init() {
|
|||||||
receive.init(ctx);
|
receive.init(ctx);
|
||||||
addToken.init(ctx);
|
addToken.init(ctx);
|
||||||
settings.init(ctx);
|
settings.init(ctx);
|
||||||
|
settingsAddToken.init(ctx);
|
||||||
|
|
||||||
if (!state.hasWallet) {
|
if (!state.hasWallet) {
|
||||||
showView("welcome");
|
showView("welcome");
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ function init(ctx) {
|
|||||||
showFlash("Please choose a password.");
|
showFlash("Please choose a password.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pw.length < 8) {
|
if (pw.length < 12) {
|
||||||
showFlash("Password must be at least 8 characters.");
|
showFlash("Password must be at least 12 characters.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pw !== pw2) {
|
if (pw !== pw2) {
|
||||||
@@ -124,6 +124,11 @@ function init(ctx) {
|
|||||||
"click",
|
"click",
|
||||||
ctx.showImportKeyView,
|
ctx.showImportKeyView,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$("btn-add-wallet-import-xprv").addEventListener(
|
||||||
|
"click",
|
||||||
|
ctx.showImportXprvView,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show };
|
module.exports = { init, show };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const {
|
|||||||
showFlash,
|
showFlash,
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
truncateMiddle,
|
truncateMiddle,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
@@ -54,7 +55,8 @@ function show() {
|
|||||||
const addrLink = etherscanAddressLink(addr.address);
|
const addrLink = etherscanAddressLink(addr.address);
|
||||||
$("address-etherscan-link").innerHTML =
|
$("address-etherscan-link").innerHTML =
|
||||||
`<a href="${addrLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
|
`<a href="${addrLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
|
||||||
$("address-usd-total").textContent = formatUsd(getAddressValueUsd(addr));
|
const usdTotal = formatUsd(getAddressValueUsd(addr));
|
||||||
|
$("address-usd-total").innerHTML = usdTotal || " ";
|
||||||
const ensEl = $("address-ens");
|
const ensEl = $("address-ens");
|
||||||
if (addr.ensName) {
|
if (addr.ensName) {
|
||||||
ensEl.innerHTML =
|
ensEl.innerHTML =
|
||||||
@@ -149,11 +151,11 @@ async function loadTransactions(address) {
|
|||||||
|
|
||||||
loadedTxs = txs;
|
loadedTxs = txs;
|
||||||
|
|
||||||
// Collect unique counterparty addresses for ENS resolution.
|
// 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.
|
||||||
const counterparties = [
|
const counterparties = [
|
||||||
...new Set(
|
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
||||||
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
if (counterparties.length > 0) {
|
if (counterparties.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -184,14 +186,23 @@ function renderTransactions(txs) {
|
|||||||
let html = "";
|
let html = "";
|
||||||
let i = 0;
|
let i = 0;
|
||||||
for (const tx of txs) {
|
for (const tx of txs) {
|
||||||
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
|
// 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 ensName = ensNameMap.get(counterparty) || null;
|
const ensName = ensNameMap.get(counterparty) || null;
|
||||||
|
const title = addressTitle(counterparty, state.wallets);
|
||||||
const dirLabel = tx.directionLabel;
|
const dirLabel = tx.directionLabel;
|
||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + tx.symbol)
|
? escapeHtml(tx.value + " " + tx.symbol)
|
||||||
: escapeHtml(tx.symbol);
|
: escapeHtml(tx.symbol);
|
||||||
const maxAddr = Math.max(10, 36 - Math.max(0, amountStr.length - 10));
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||||
const displayAddr = ensName || truncateMiddle(counterparty, maxAddr);
|
const displayAddr =
|
||||||
|
title || ensName || truncateMiddle(counterparty, maxAddr);
|
||||||
const addrStr = escapeHtml(displayAddr);
|
const addrStr = escapeHtml(displayAddr);
|
||||||
const dot = addressDotHtml(counterparty);
|
const dot = addressDotHtml(counterparty);
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
truncateMiddle,
|
truncateMiddle,
|
||||||
balanceLine,
|
balanceLine,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentAddress, saveState } = require("../../shared/state");
|
const { state, currentAddress, saveState } = require("../../shared/state");
|
||||||
|
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||||
const {
|
const {
|
||||||
formatUsd,
|
formatUsd,
|
||||||
getPrice,
|
getPrice,
|
||||||
@@ -85,6 +87,7 @@ function show() {
|
|||||||
|
|
||||||
// Determine token symbol and balance
|
// Determine token symbol and balance
|
||||||
let symbol, amount, price;
|
let symbol, amount, price;
|
||||||
|
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
|
||||||
if (tokenId === "ETH") {
|
if (tokenId === "ETH") {
|
||||||
symbol = "ETH";
|
symbol = "ETH";
|
||||||
amount = parseFloat(addr.balance || "0");
|
amount = parseFloat(addr.balance || "0");
|
||||||
@@ -93,7 +96,14 @@ function show() {
|
|||||||
const tb = (addr.tokenBalances || []).find(
|
const tb = (addr.tokenBalances || []).find(
|
||||||
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
|
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
|
||||||
);
|
);
|
||||||
symbol = tb ? tb.symbol : "?";
|
const tracked = (state.trackedTokens || []).find(
|
||||||
|
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
|
||||||
|
);
|
||||||
|
symbol =
|
||||||
|
(tb && tb.symbol) ||
|
||||||
|
(tracked && tracked.symbol) ||
|
||||||
|
(knownToken && knownToken.symbol) ||
|
||||||
|
"?";
|
||||||
amount = tb ? parseFloat(tb.balance || "0") : 0;
|
amount = tb ? parseFloat(tb.balance || "0") : 0;
|
||||||
price = getPrice(symbol);
|
price = getPrice(symbol);
|
||||||
}
|
}
|
||||||
@@ -124,11 +134,68 @@ function show() {
|
|||||||
|
|
||||||
// USD total for this token only
|
// USD total for this token only
|
||||||
const usdVal = price ? amount * price : 0;
|
const usdVal = price ? amount * price : 0;
|
||||||
$("address-token-usd-total").textContent = formatUsd(usdVal);
|
const usdStr = formatUsd(usdVal);
|
||||||
|
$("address-token-usd-total").innerHTML = usdStr || " ";
|
||||||
|
|
||||||
// Single token balance line (no tokenId — not clickable here)
|
// Single token balance line (no tokenId — not clickable here)
|
||||||
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
|
$("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
|
// Transactions
|
||||||
$("address-token-tx-list").innerHTML =
|
$("address-token-tx-list").innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">Loading...</div>';
|
'<div class="text-muted text-xs py-1">Loading...</div>';
|
||||||
@@ -174,11 +241,10 @@ async function loadTransactions(address, tokenId) {
|
|||||||
|
|
||||||
loadedTxs = txs;
|
loadedTxs = txs;
|
||||||
|
|
||||||
// Collect unique counterparty addresses for ENS resolution
|
// Collect ALL unique addresses for ENS resolution so reverse
|
||||||
|
// lookups work for every displayed address.
|
||||||
const counterparties = [
|
const counterparties = [
|
||||||
...new Set(
|
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
||||||
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
if (counterparties.length > 0) {
|
if (counterparties.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -211,12 +277,14 @@ function renderTransactions(txs) {
|
|||||||
for (const tx of txs) {
|
for (const tx of txs) {
|
||||||
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
|
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
|
||||||
const ensName = ensNameMap.get(counterparty) || null;
|
const ensName = ensNameMap.get(counterparty) || null;
|
||||||
|
const title = addressTitle(counterparty, state.wallets);
|
||||||
const dirLabel = tx.directionLabel;
|
const dirLabel = tx.directionLabel;
|
||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + tx.symbol)
|
? escapeHtml(tx.value + " " + tx.symbol)
|
||||||
: escapeHtml(tx.symbol);
|
: escapeHtml(tx.symbol);
|
||||||
const maxAddr = Math.max(10, 36 - Math.max(0, amountStr.length - 10));
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||||
const displayAddr = ensName || truncateMiddle(counterparty, maxAddr);
|
const displayAddr =
|
||||||
|
title || ensName || truncateMiddle(counterparty, maxAddr);
|
||||||
const addrStr = escapeHtml(displayAddr);
|
const addrStr = escapeHtml(displayAddr);
|
||||||
const dot = addressDotHtml(counterparty);
|
const dot = addressDotHtml(counterparty);
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
@@ -251,6 +319,14 @@ 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", () => {
|
$("btn-address-token-back").addEventListener("click", () => {
|
||||||
ctx.showAddressDetail();
|
ctx.showAddressDetail();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
const { $, addressDotHtml, escapeHtml, showView } = require("./helpers");
|
const {
|
||||||
|
$,
|
||||||
|
addressDotHtml,
|
||||||
|
addressTitle,
|
||||||
|
escapeHtml,
|
||||||
|
showView,
|
||||||
|
} = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
|
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
|
||||||
const { ERC20_ABI } = require("../../shared/constants");
|
const { ERC20_ABI } = require("../../shared/constants");
|
||||||
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||||
const txStatus = require("./txStatus");
|
const txStatus = require("./txStatus");
|
||||||
|
const uniswap = require("../../shared/uniswap");
|
||||||
|
|
||||||
const runtime =
|
const runtime =
|
||||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
||||||
@@ -21,7 +28,15 @@ function approvalAddressHtml(address) {
|
|||||||
const dot = addressDotHtml(address);
|
const dot = addressDotHtml(address);
|
||||||
const link = `https://etherscan.io/address/${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 extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
|
||||||
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTxValue(val) {
|
function formatTxValue(val) {
|
||||||
@@ -40,81 +55,102 @@ function etherscanTokenLink(address) {
|
|||||||
return `https://etherscan.io/token/${address}`;
|
return `https://etherscan.io/token/${address}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to decode calldata using the ERC-20 ABI.
|
// Try to decode calldata using known ABIs.
|
||||||
// Returns { name, description, details } or null.
|
// Returns { name, description, details } or null.
|
||||||
function decodeCalldata(data, toAddress) {
|
function decodeCalldata(data, toAddress) {
|
||||||
if (!data || data === "0x" || data.length < 10) return null;
|
if (!data || data === "0x" || data.length < 10) return null;
|
||||||
|
|
||||||
|
// Try ERC-20 (approve / transfer)
|
||||||
try {
|
try {
|
||||||
const parsed = erc20Iface.parseTransaction({ data });
|
const parsed = erc20Iface.parseTransaction({ data });
|
||||||
if (!parsed) return null;
|
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;
|
||||||
|
|
||||||
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
|
if (parsed.name === "approve") {
|
||||||
const tokenSymbol = token ? token.symbol : null;
|
const spender = parsed.args[0];
|
||||||
const tokenDecimals = token ? token.decimals : 18;
|
const rawAmount = parsed.args[1];
|
||||||
const contractLabel = tokenSymbol
|
const maxUint = BigInt(
|
||||||
? tokenSymbol + " (" + toAddress + ")"
|
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||||
: toAddress;
|
);
|
||||||
|
const isUnlimited = rawAmount === maxUint;
|
||||||
|
const amountRaw = isUnlimited
|
||||||
|
? "Unlimited"
|
||||||
|
: formatTxValue(formatUnits(rawAmount, tokenDecimals));
|
||||||
|
const amountStr = isUnlimited
|
||||||
|
? "Unlimited"
|
||||||
|
: amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
|
||||||
|
|
||||||
if (parsed.name === "approve") {
|
return {
|
||||||
const spender = parsed.args[0];
|
name: "Token Approval",
|
||||||
const rawAmount = parsed.args[1];
|
description: tokenSymbol
|
||||||
const maxUint = BigInt(
|
? "Approve spending of your " + tokenSymbol
|
||||||
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
: "Approve spending of an ERC-20 token",
|
||||||
);
|
details: [
|
||||||
const isUnlimited = rawAmount === maxUint;
|
{
|
||||||
const amountStr = isUnlimited
|
label: "Token",
|
||||||
? "Unlimited"
|
value: contractLabel,
|
||||||
: formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
|
address: toAddress,
|
||||||
(tokenSymbol ? " " + tokenSymbol : "");
|
isToken: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Spender",
|
||||||
|
value: spender,
|
||||||
|
address: spender,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Amount",
|
||||||
|
value: amountStr,
|
||||||
|
rawValue: amountRaw,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
if (parsed.name === "transfer") {
|
||||||
name: "Token Approval",
|
const to = parsed.args[0];
|
||||||
description: tokenSymbol
|
const rawAmount = parsed.args[1];
|
||||||
? "Approve spending of your " + tokenSymbol
|
const amountRaw = formatTxValue(
|
||||||
: "Approve spending of an ERC-20 token",
|
formatUnits(rawAmount, tokenDecimals),
|
||||||
details: [
|
);
|
||||||
{
|
const amountStr =
|
||||||
label: "Token",
|
amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
|
||||||
value: contractLabel,
|
|
||||||
address: toAddress,
|
return {
|
||||||
isToken: true,
|
name: "Token Transfer",
|
||||||
},
|
description: tokenSymbol
|
||||||
{ label: "Spender", value: spender, address: spender },
|
? "Transfer " + tokenSymbol
|
||||||
{ label: "Amount", value: amountStr },
|
: "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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
} catch {
|
||||||
return null;
|
// Not ERC-20 — fall through
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try Uniswap Universal Router
|
||||||
|
const routerResult = uniswap.decode(data, toAddress);
|
||||||
|
if (routerResult) return routerResult;
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTxApproval(details) {
|
function showTxApproval(details) {
|
||||||
@@ -139,7 +175,7 @@ function showTxApproval(details) {
|
|||||||
pendingTxDetails.to = d.address;
|
pendingTxDetails.to = d.address;
|
||||||
}
|
}
|
||||||
if (d.label === "Amount") {
|
if (d.label === "Amount") {
|
||||||
pendingTxDetails.amount = d.value;
|
pendingTxDetails.amount = d.rawValue || d.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -284,6 +320,18 @@ function showSignApproval(details) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display danger warning for eth_sign (raw hash signing)
|
||||||
|
const warningEl = $("approve-sign-danger-warning");
|
||||||
|
if (warningEl) {
|
||||||
|
if (sp.dangerWarning) {
|
||||||
|
warningEl.textContent = sp.dangerWarning;
|
||||||
|
warningEl.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
warningEl.textContent = "";
|
||||||
|
warningEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$("approve-sign-password").value = "";
|
$("approve-sign-password").value = "";
|
||||||
$("approve-sign-error").classList.add("hidden");
|
$("approve-sign-error").classList.add("hidden");
|
||||||
$("btn-approve-sign").disabled = false;
|
$("btn-approve-sign").disabled = false;
|
||||||
@@ -363,6 +411,7 @@ function init(ctx) {
|
|||||||
type: "AUTISTMASK_TX_RESPONSE",
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
id: approvalId,
|
id: approvalId,
|
||||||
approved: true,
|
approved: true,
|
||||||
|
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
|
||||||
password: password,
|
password: password,
|
||||||
},
|
},
|
||||||
(response) => {
|
(response) => {
|
||||||
@@ -402,6 +451,7 @@ function init(ctx) {
|
|||||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||||
id: approvalId,
|
id: approvalId,
|
||||||
approved: true,
|
approved: true,
|
||||||
|
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
|
||||||
password: password,
|
password: password,
|
||||||
},
|
},
|
||||||
(response) => {
|
(response) => {
|
||||||
@@ -429,4 +479,4 @@ function init(ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show };
|
module.exports = { init, show, decodeCalldata };
|
||||||
|
|||||||
@@ -334,8 +334,13 @@ function init(ctx) {
|
|||||||
tx = await contract.transfer(pendingTx.to, amount);
|
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);
|
txStatus.showWait(pendingTx, tx.hash);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
decryptedSecret = null;
|
||||||
const hash = tx ? tx.hash : null;
|
const hash = tx ? tx.hash : null;
|
||||||
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
|
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
90
src/popup/views/deleteWallet.js
Normal file
90
src/popup/views/deleteWallet.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
const { $, showView, showFlash } = require("./helpers");
|
||||||
|
const { state, saveState } = require("../../shared/state");
|
||||||
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
|
|
||||||
|
let deleteWalletIndex = null;
|
||||||
|
let ctx = null;
|
||||||
|
|
||||||
|
function show(walletIdx) {
|
||||||
|
deleteWalletIndex = walletIdx;
|
||||||
|
const wallet = state.wallets[walletIdx];
|
||||||
|
$("delete-wallet-name").textContent =
|
||||||
|
wallet.name || "Wallet " + (walletIdx + 1);
|
||||||
|
$("delete-wallet-password").value = "";
|
||||||
|
$("delete-wallet-flash").textContent = "";
|
||||||
|
$("delete-wallet-flash").classList.add("hidden");
|
||||||
|
showView("delete-wallet-confirm");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(_ctx) {
|
||||||
|
ctx = _ctx;
|
||||||
|
|
||||||
|
$("btn-delete-wallet-back").addEventListener("click", () => {
|
||||||
|
deleteWalletIndex = null;
|
||||||
|
ctx.showSettingsView();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
|
||||||
|
const pw = $("delete-wallet-password").value;
|
||||||
|
if (!pw) {
|
||||||
|
$("delete-wallet-flash").textContent =
|
||||||
|
"Please enter your password.";
|
||||||
|
$("delete-wallet-flash").classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteWalletIndex === null) {
|
||||||
|
$("delete-wallet-flash").textContent =
|
||||||
|
"No wallet selected for deletion.";
|
||||||
|
$("delete-wallet-flash").classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const walletIdx = deleteWalletIndex;
|
||||||
|
const wallet = state.wallets[walletIdx];
|
||||||
|
|
||||||
|
// Verify password against the wallet's encrypted data
|
||||||
|
try {
|
||||||
|
await decryptWithPassword(wallet.encryptedSecret, pw);
|
||||||
|
} catch (_e) {
|
||||||
|
$("delete-wallet-flash").textContent = "Wrong password.";
|
||||||
|
$("delete-wallet-flash").classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect addresses to clean up from allowedSites/deniedSites
|
||||||
|
const addresses = (wallet.addresses || []).map((a) => a.address);
|
||||||
|
|
||||||
|
// Remove wallet
|
||||||
|
state.wallets.splice(walletIdx, 1);
|
||||||
|
|
||||||
|
// Clean up site permissions for deleted addresses
|
||||||
|
for (const addr of addresses) {
|
||||||
|
delete state.allowedSites[addr];
|
||||||
|
delete state.deniedSites[addr];
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteWalletIndex = null;
|
||||||
|
|
||||||
|
if (state.wallets.length === 0) {
|
||||||
|
// No wallets left — reset selection and show welcome
|
||||||
|
state.selectedWallet = null;
|
||||||
|
state.selectedAddress = null;
|
||||||
|
state.activeAddress = null;
|
||||||
|
await saveState();
|
||||||
|
showView("welcome");
|
||||||
|
} else {
|
||||||
|
// Switch to first wallet if deleted wallet was active
|
||||||
|
state.selectedWallet = 0;
|
||||||
|
state.selectedAddress = 0;
|
||||||
|
state.activeAddress =
|
||||||
|
state.wallets[0].addresses[0]?.address || null;
|
||||||
|
await saveState();
|
||||||
|
ctx.renderWalletList();
|
||||||
|
ctx.showSettingsView();
|
||||||
|
showFlash("Wallet deleted.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, show };
|
||||||
@@ -8,6 +8,8 @@ const {
|
|||||||
} = require("../../shared/prices");
|
} = require("../../shared/prices");
|
||||||
const { state, saveState } = require("../../shared/state");
|
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 = [
|
const VIEWS = [
|
||||||
"welcome",
|
"welcome",
|
||||||
"add-wallet",
|
"add-wallet",
|
||||||
@@ -23,6 +25,8 @@ const VIEWS = [
|
|||||||
"receive",
|
"receive",
|
||||||
"add-token",
|
"add-token",
|
||||||
"settings",
|
"settings",
|
||||||
|
"delete-wallet-confirm",
|
||||||
|
"settings-addtoken",
|
||||||
"transaction",
|
"transaction",
|
||||||
"approve-site",
|
"approve-site",
|
||||||
"approve-tx",
|
"approve-tx",
|
||||||
@@ -82,7 +86,7 @@ function showFlash(msg, duration = 2000) {
|
|||||||
|
|
||||||
function balanceLine(symbol, amount, price, tokenId) {
|
function balanceLine(symbol, amount, price, tokenId) {
|
||||||
const qty = amount.toFixed(4);
|
const qty = amount.toFixed(4);
|
||||||
const usd = price ? formatUsd(amount * price) : "";
|
const usd = price ? formatUsd(amount * price) || " " : " ";
|
||||||
const tokenAttr = tokenId ? ` data-token="${tokenId}"` : "";
|
const tokenAttr = tokenId ? ` data-token="${tokenId}"` : "";
|
||||||
const clickClass = tokenId
|
const clickClass = tokenId
|
||||||
? " cursor-pointer hover:bg-hover balance-row"
|
? " cursor-pointer hover:bg-hover balance-row"
|
||||||
@@ -131,9 +135,18 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
|||||||
return html;
|
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) {
|
function truncateMiddle(str, maxLen) {
|
||||||
if (str.length <= maxLen) return str;
|
if (str.length <= maxLen) return str;
|
||||||
if (maxLen < 5) return str.slice(0, maxLen);
|
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;
|
||||||
const half = Math.floor((maxLen - 1) / 2);
|
const half = Math.floor((maxLen - 1) / 2);
|
||||||
return str.slice(0, half) + "\u2026" + str.slice(-(maxLen - 1 - half));
|
return str.slice(0, half) + "\u2026" + str.slice(-(maxLen - 1 - half));
|
||||||
}
|
}
|
||||||
@@ -210,6 +223,41 @@ function formatAddressHtml(address, ensName, maxLen, title) {
|
|||||||
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(displayAddr)}</span></div>`;
|
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 = {
|
module.exports = {
|
||||||
$,
|
$,
|
||||||
showError,
|
showError,
|
||||||
@@ -224,4 +272,6 @@ module.exports = {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
formatAddressHtml,
|
formatAddressHtml,
|
||||||
truncateMiddle,
|
truncateMiddle,
|
||||||
|
isoDate,
|
||||||
|
timeAgo,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
|
isoDate,
|
||||||
|
timeAgo,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
truncateMiddle,
|
truncateMiddle,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
@@ -38,13 +41,15 @@ function renderTotalValue() {
|
|||||||
|
|
||||||
const ethPrice = getPrice("ETH");
|
const ethPrice = getPrice("ETH");
|
||||||
if (priceEl) {
|
if (priceEl) {
|
||||||
priceEl.textContent = ethPrice ? formatUsd(ethPrice) + " USD/ETH" : "";
|
priceEl.innerHTML = ethPrice
|
||||||
|
? formatUsd(ethPrice) + " USD/ETH"
|
||||||
|
: " ";
|
||||||
}
|
}
|
||||||
|
|
||||||
const addr = findActiveAddr();
|
const addr = findActiveAddr();
|
||||||
if (!addr) {
|
if (!addr) {
|
||||||
el.textContent = "";
|
el.innerHTML = " ";
|
||||||
if (subEl) subEl.textContent = "";
|
if (subEl) subEl.innerHTML = " ";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ethBal = parseFloat(addr.balance || "0");
|
const ethBal = parseFloat(addr.balance || "0");
|
||||||
@@ -54,8 +59,8 @@ function renderTotalValue() {
|
|||||||
|
|
||||||
if (subEl) {
|
if (subEl) {
|
||||||
const totalUsd = getAddressValueUsd(addr);
|
const totalUsd = getAddressValueUsd(addr);
|
||||||
subEl.textContent =
|
subEl.innerHTML =
|
||||||
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : "";
|
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : " ";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,41 +90,6 @@ 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 = [];
|
let homeTxs = [];
|
||||||
|
|
||||||
function renderHomeTxList(ctx) {
|
function renderHomeTxList(ctx) {
|
||||||
@@ -133,13 +103,22 @@ function renderHomeTxList(ctx) {
|
|||||||
let html = "";
|
let html = "";
|
||||||
let i = 0;
|
let i = 0;
|
||||||
for (const tx of homeTxs) {
|
for (const tx of homeTxs) {
|
||||||
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
|
// 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 dirLabel = tx.directionLabel;
|
const dirLabel = tx.directionLabel;
|
||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + tx.symbol)
|
? escapeHtml(tx.value + " " + tx.symbol)
|
||||||
: escapeHtml(tx.symbol);
|
: escapeHtml(tx.symbol);
|
||||||
const maxAddr = Math.max(10, 36 - Math.max(0, amountStr.length - 10));
|
const title = addressTitle(counterparty, state.wallets);
|
||||||
const displayAddr = truncateMiddle(counterparty, maxAddr);
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||||
|
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
|
||||||
const addrStr = escapeHtml(displayAddr);
|
const addrStr = escapeHtml(displayAddr);
|
||||||
const dot = addressDotHtml(counterparty);
|
const dot = addressDotHtml(counterparty);
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
@@ -256,7 +235,7 @@ function render(ctx) {
|
|||||||
html += `<div>`;
|
html += `<div>`;
|
||||||
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
|
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>`;
|
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
|
||||||
if (wallet.type === "hd") {
|
if (wallet.type === "hd" || wallet.type === "xprv") {
|
||||||
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 += `<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>`;
|
html += `</div>`;
|
||||||
@@ -276,7 +255,7 @@ function render(ctx) {
|
|||||||
html += `<span class="flex-shrink-0 ml-1">${infoBtn}</span>`;
|
html += `<span class="flex-shrink-0 ml-1">${infoBtn}</span>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
const addrUsd = formatUsd(getAddressValueUsd(addr));
|
const addrUsd = formatUsd(getAddressValueUsd(addr));
|
||||||
html += `<div class="text-xs text-muted text-right">${addrUsd}</div>`;
|
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || " "}</div>`;
|
||||||
html += balanceLinesForAddress(
|
html += balanceLinesForAddress(
|
||||||
addr,
|
addr,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ function init(ctx) {
|
|||||||
showFlash("Please choose a password.");
|
showFlash("Please choose a password.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pw.length < 8) {
|
if (pw.length < 12) {
|
||||||
showFlash("Password must be at least 8 characters.");
|
showFlash("Password must be at least 12 characters.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pw !== pw2) {
|
if (pw !== pw2) {
|
||||||
|
|||||||
106
src/popup/views/importXprv.js
Normal file
106
src/popup/views/importXprv.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const { $, showView, showFlash } = require("./helpers");
|
||||||
|
const { hdWalletFromXprv, isValidXprv } = require("../../shared/wallet");
|
||||||
|
const { encryptWithPassword } = require("../../shared/vault");
|
||||||
|
const { state, saveState } = require("../../shared/state");
|
||||||
|
const { scanForAddresses } = require("../../shared/balances");
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
$("import-xprv-key").value = "";
|
||||||
|
$("import-xprv-password").value = "";
|
||||||
|
$("import-xprv-password-confirm").value = "";
|
||||||
|
showView("import-xprv");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(ctx) {
|
||||||
|
$("btn-import-xprv-confirm").addEventListener("click", async () => {
|
||||||
|
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 = $("import-xprv-password").value;
|
||||||
|
const pw2 = $("import-xprv-password-confirm").value;
|
||||||
|
if (!pw) {
|
||||||
|
showFlash("Please choose a password.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pw.length < 12) {
|
||||||
|
showFlash("Password must be at least 12 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pw !== pw2) {
|
||||||
|
showFlash("Passwords do not match.");
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-import-xprv-back").addEventListener("click", () => {
|
||||||
|
if (!state.hasWallet) {
|
||||||
|
showView("welcome");
|
||||||
|
} else {
|
||||||
|
ctx.renderWalletList();
|
||||||
|
showView("main");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, show };
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
// Send view: collect To, Amount, Token. Then go to confirmation.
|
// Send view: collect To, Amount, Token. Then go to confirmation.
|
||||||
|
|
||||||
const { $, showFlash, addressDotHtml, escapeHtml } = require("./helpers");
|
const {
|
||||||
|
$,
|
||||||
|
showFlash,
|
||||||
|
addressDotHtml,
|
||||||
|
addressTitle,
|
||||||
|
escapeHtml,
|
||||||
|
} = require("./helpers");
|
||||||
const { state, currentAddress } = require("../../shared/state");
|
const { state, currentAddress } = require("../../shared/state");
|
||||||
let ctx;
|
let ctx;
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
const { KNOWN_SYMBOLS } = require("../../shared/tokenList");
|
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||||
|
|
||||||
const EXT_ICON =
|
const EXT_ICON =
|
||||||
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
||||||
@@ -44,8 +50,15 @@ function updateSendBalance() {
|
|||||||
const dot = addressDotHtml(addr.address);
|
const dot = addressDotHtml(addr.address);
|
||||||
const link = `https://etherscan.io/address/${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 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 = "";
|
let fromHtml = "";
|
||||||
if (addr.ensName) {
|
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) {
|
||||||
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(addr.ensName)}</div>`;
|
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(addr.ensName)}</div>`;
|
||||||
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
|
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
|
||||||
} else {
|
} else {
|
||||||
@@ -60,7 +73,15 @@ function updateSendBalance() {
|
|||||||
const tb = (addr.tokenBalances || []).find(
|
const tb = (addr.tokenBalances || []).find(
|
||||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||||
);
|
);
|
||||||
const symbol = tb ? tb.symbol : "?";
|
const knownToken = TOKEN_BY_ADDRESS.get(token.toLowerCase());
|
||||||
|
const tracked = (state.trackedTokens || []).find(
|
||||||
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||||
|
);
|
||||||
|
const symbol =
|
||||||
|
(tb && tb.symbol) ||
|
||||||
|
(tracked && tracked.symbol) ||
|
||||||
|
(knownToken && knownToken.symbol) ||
|
||||||
|
"?";
|
||||||
const bal = tb ? tb.balance || "0" : "0";
|
const bal = tb ? tb.balance || "0" : "0";
|
||||||
$("send-balance").textContent =
|
$("send-balance").textContent =
|
||||||
"Current balance: " + bal + " " + symbol;
|
"Current balance: " + bal + " " + symbol;
|
||||||
@@ -111,7 +132,15 @@ function init(_ctx) {
|
|||||||
const tb = (addr.tokenBalances || []).find(
|
const tb = (addr.tokenBalances || []).find(
|
||||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||||
);
|
);
|
||||||
tokenSymbol = tb ? tb.symbol : "?";
|
const knownTk = TOKEN_BY_ADDRESS.get(token.toLowerCase());
|
||||||
|
const trackedTk = (state.trackedTokens || []).find(
|
||||||
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||||
|
);
|
||||||
|
tokenSymbol =
|
||||||
|
(tb && tb.symbol) ||
|
||||||
|
(trackedTk && trackedTk.symbol) ||
|
||||||
|
(knownTk && knownTk.symbol) ||
|
||||||
|
"?";
|
||||||
tokenBalance = tb ? tb.balance || "0" : "0";
|
tokenBalance = tb ? tb.balance || "0" : "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const { $, showView, showFlash } = require("./helpers");
|
const { $, showView, showFlash, escapeHtml } = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
|
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
|
||||||
const { log, debugFetch } = require("../../shared/log");
|
const { log, debugFetch } = require("../../shared/log");
|
||||||
|
const deleteWallet = require("./deleteWallet");
|
||||||
|
|
||||||
const runtime =
|
const runtime =
|
||||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
||||||
@@ -38,10 +39,95 @@ 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() {
|
function show() {
|
||||||
$("settings-rpc").value = state.rpcUrl;
|
$("settings-rpc").value = state.rpcUrl;
|
||||||
$("settings-blockscout").value = state.blockscoutUrl;
|
$("settings-blockscout").value = state.blockscoutUrl;
|
||||||
|
renderTrackedTokens();
|
||||||
renderSiteLists();
|
renderSiteLists();
|
||||||
|
renderWalletListSettings();
|
||||||
|
|
||||||
showView("settings");
|
showView("settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +141,8 @@ function renderSiteLists() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
function init(ctx) {
|
||||||
|
deleteWallet.init(ctx);
|
||||||
|
|
||||||
$("btn-save-rpc").addEventListener("click", async () => {
|
$("btn-save-rpc").addEventListener("click", async () => {
|
||||||
const url = $("settings-rpc").value.trim();
|
const url = $("settings-rpc").value.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -155,6 +243,11 @@ function init(ctx) {
|
|||||||
|
|
||||||
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
|
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
|
||||||
|
|
||||||
|
$("btn-settings-add-token").addEventListener(
|
||||||
|
"click",
|
||||||
|
ctx.showSettingsAddTokenView,
|
||||||
|
);
|
||||||
|
|
||||||
$("btn-settings-back").addEventListener("click", () => {
|
$("btn-settings-back").addEventListener("click", () => {
|
||||||
ctx.renderWalletList();
|
ctx.renderWalletList();
|
||||||
showView("main");
|
showView("main");
|
||||||
|
|||||||
159
src/popup/views/settingsAddToken.js
Normal file
159
src/popup/views/settingsAddToken.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
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,10 +6,15 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
|
isoDate,
|
||||||
|
timeAgo,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state } = require("../../shared/state");
|
const { state } = require("../../shared/state");
|
||||||
const makeBlockie = require("ethereum-blockies-base64");
|
const makeBlockie = require("ethereum-blockies-base64");
|
||||||
|
const { log, debugFetch } = require("../../shared/log");
|
||||||
|
const { decodeCalldata } = require("./approval");
|
||||||
|
|
||||||
const EXT_ICON =
|
const EXT_ICON =
|
||||||
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
||||||
@@ -20,41 +25,6 @@ const EXT_ICON =
|
|||||||
|
|
||||||
let ctx;
|
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) {
|
function copyableHtml(text, extraClass) {
|
||||||
const cls =
|
const cls =
|
||||||
"underline decoration-dashed cursor-pointer" +
|
"underline decoration-dashed cursor-pointer" +
|
||||||
@@ -67,20 +37,31 @@ function blockieHtml(address) {
|
|||||||
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
|
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function txAddressHtml(address, ensName) {
|
function etherscanLinkHtml(url) {
|
||||||
|
return (
|
||||||
|
`<a href="${url}" target="_blank" rel="noopener" ` +
|
||||||
|
`class="inline-flex items-center"` +
|
||||||
|
`>${EXT_ICON}</a>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function txAddressHtml(address, ensName, title) {
|
||||||
const blockie = blockieHtml(address);
|
const blockie = blockieHtml(address);
|
||||||
const dot = addressDotHtml(address);
|
const dot = addressDotHtml(address);
|
||||||
const link = `https://etherscan.io/address/${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 extLink = etherscanLinkHtml(link);
|
||||||
let html = `<div class="mb-1">${blockie}</div>`;
|
let html = `<div class="mb-1">${blockie}</div>`;
|
||||||
|
if (title) {
|
||||||
|
html += `<div class="font-bold">${escapeHtml(title)}</div>`;
|
||||||
|
}
|
||||||
if (ensName) {
|
if (ensName) {
|
||||||
html +=
|
html +=
|
||||||
`<div class="flex items-center">${dot}` +
|
`<div class="flex items-center">${dot}` +
|
||||||
copyableHtml(ensName, "") +
|
copyableHtml(ensName, "") +
|
||||||
extLink +
|
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="break-all">` +
|
`<div class="flex items-center">${dot}` +
|
||||||
copyableHtml(address, "break-all") +
|
copyableHtml(address, "break-all") +
|
||||||
|
extLink +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
} else {
|
} else {
|
||||||
html +=
|
html +=
|
||||||
@@ -94,7 +75,7 @@ function txAddressHtml(address, ensName) {
|
|||||||
|
|
||||||
function txHashHtml(hash) {
|
function txHashHtml(hash) {
|
||||||
const link = `https://etherscan.io/tx/${hash}`;
|
const link = `https://etherscan.io/tx/${hash}`;
|
||||||
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
|
const extLink = etherscanLinkHtml(link);
|
||||||
return copyableHtml(hash, "break-all") + extLink;
|
return copyableHtml(hash, "break-all") + extLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +86,18 @@ function show(tx) {
|
|||||||
from: tx.from,
|
from: tx.from,
|
||||||
to: tx.to,
|
to: tx.to,
|
||||||
value: tx.value,
|
value: tx.value,
|
||||||
|
exactValue: tx.exactValue || tx.value,
|
||||||
|
rawAmount: tx.rawAmount || "",
|
||||||
|
rawUnit: tx.rawUnit || "",
|
||||||
symbol: tx.symbol,
|
symbol: tx.symbol,
|
||||||
timestamp: tx.timestamp,
|
timestamp: tx.timestamp,
|
||||||
isError: tx.isError,
|
isError: tx.isError,
|
||||||
fromEns: tx.fromEns || null,
|
fromEns: tx.fromEns || null,
|
||||||
toEns: tx.toEns || null,
|
toEns: tx.toEns || null,
|
||||||
directionLabel: tx.directionLabel || null,
|
directionLabel: tx.directionLabel || null,
|
||||||
|
direction: tx.direction || null,
|
||||||
|
isContractCall: tx.isContractCall || false,
|
||||||
|
method: tx.method || null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
render();
|
render();
|
||||||
@@ -120,11 +107,58 @@ function render() {
|
|||||||
const tx = state.viewData.tx;
|
const tx = state.viewData.tx;
|
||||||
if (!tx) return;
|
if (!tx) return;
|
||||||
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
||||||
$("tx-detail-from").innerHTML = txAddressHtml(tx.from, tx.fromEns);
|
|
||||||
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns);
|
const fromTitle = addressTitle(tx.from, state.wallets);
|
||||||
$("tx-detail-value").textContent = tx.value
|
const toTitle = addressTitle(tx.to, state.wallets);
|
||||||
? tx.value + " " + tx.symbol
|
$("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.directionLabel + " " + 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");
|
||||||
|
}
|
||||||
|
if (headingEl) headingEl.textContent = tx.directionLabel;
|
||||||
|
} else {
|
||||||
|
if (typeSection) typeSection.classList.add("hidden");
|
||||||
|
if (headingEl) headingEl.textContent = "Transaction";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide calldata and raw data sections; re-fetch if this is a contract call
|
||||||
|
const calldataSection = $("tx-detail-calldata-section");
|
||||||
|
if (calldataSection) calldataSection.classList.add("hidden");
|
||||||
|
const rawDataSection = $("tx-detail-rawdata-section");
|
||||||
|
if (rawDataSection) rawDataSection.classList.add("hidden");
|
||||||
|
|
||||||
|
if (tx.isContractCall || tx.direction === "contract") {
|
||||||
|
loadCalldata(tx.hash, tx.to);
|
||||||
|
}
|
||||||
|
|
||||||
$("tx-detail-time").textContent =
|
$("tx-detail-time").textContent =
|
||||||
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
|
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
|
||||||
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
|
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
|
||||||
@@ -141,6 +175,87 @@ 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) {
|
function init(_ctx) {
|
||||||
ctx = _ctx;
|
ctx = _ctx;
|
||||||
$("btn-tx-back").addEventListener("click", () => {
|
$("btn-tx-back").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
@@ -37,6 +38,13 @@ function toAddressHtml(address) {
|
|||||||
const dot = addressDotHtml(address);
|
const dot = addressDotHtml(address);
|
||||||
const link = `https://etherscan.io/address/${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 extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
|
||||||
|
const title = addressTitle(address, state.wallets);
|
||||||
|
if (title) {
|
||||||
|
return (
|
||||||
|
`<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` +
|
||||||
|
`<div class="break-all">${escapeHtml(address)}${extLink}</div>`
|
||||||
|
);
|
||||||
|
}
|
||||||
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
|
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
|||||||
|
|
||||||
balances.push({
|
balances.push({
|
||||||
address: item.token.address_hash,
|
address: item.token.address_hash,
|
||||||
|
name: item.token.name || "",
|
||||||
symbol: item.token.symbol || "???",
|
symbol: item.token.symbol || "???",
|
||||||
decimals: decimals,
|
decimals: decimals,
|
||||||
balance: bal,
|
balance: bal,
|
||||||
@@ -123,15 +124,27 @@ async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ENS reverse lookup
|
// ENS reverse lookup — only overwrite on success so that
|
||||||
|
// transient RPC errors don't wipe a previously resolved name.
|
||||||
updates.push(
|
updates.push(
|
||||||
provider
|
provider
|
||||||
.lookupAddress(addr.address)
|
.lookupAddress(addr.address)
|
||||||
.then((name) => {
|
.then((name) => {
|
||||||
addr.ensName = name || null;
|
addr.ensName = name || null;
|
||||||
|
log.debugf(
|
||||||
|
"ENS reverse",
|
||||||
|
addr.address,
|
||||||
|
"->",
|
||||||
|
addr.ensName,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((e) => {
|
||||||
addr.ensName = null;
|
log.errorf(
|
||||||
|
"ENS reverse failed",
|
||||||
|
addr.address,
|
||||||
|
e.message,
|
||||||
|
);
|
||||||
|
// Keep existing addr.ensName if we had one
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -192,6 +205,10 @@ async function lookupTokenInfo(contractAddress, rpcUrl) {
|
|||||||
name = symbol;
|
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));
|
log.infof("Token resolved:", symbol, "decimals", Number(decimals));
|
||||||
return { name, symbol, decimals: Number(decimals) };
|
return { name, symbol, decimals: Number(decimals) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async function resolveEnsName(address, rpcUrl) {
|
|||||||
return name;
|
return name;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("ENS reverse lookup failed", address, e.message);
|
log.errorf("ENS reverse lookup failed", address, e.message);
|
||||||
setCache(address, null);
|
// Don't cache failures — let subsequent lookups retry
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,9 @@ function parseTx(tx, addrLower) {
|
|||||||
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
|
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
|
||||||
let symbol = "ETH";
|
let symbol = "ETH";
|
||||||
let value = formatTxValue(formatEther(rawWei));
|
let value = formatTxValue(formatEther(rawWei));
|
||||||
|
let exactValue = formatEther(rawWei);
|
||||||
|
let rawAmount = rawWei;
|
||||||
|
let rawUnit = "wei";
|
||||||
let direction = from.toLowerCase() === addrLower ? "sent" : "received";
|
let direction = from.toLowerCase() === addrLower ? "sent" : "received";
|
||||||
let directionLabel = direction === "sent" ? "Sent" : "Received";
|
let directionLabel = direction === "sent" ? "Sent" : "Received";
|
||||||
if (toIsContract && method && method !== "transfer") {
|
if (toIsContract && method && method !== "transfer") {
|
||||||
@@ -34,10 +37,27 @@ function parseTx(tx, addrLower) {
|
|||||||
if (token) {
|
if (token) {
|
||||||
symbol = token.symbol;
|
symbol = token.symbol;
|
||||||
}
|
}
|
||||||
const label = method.charAt(0).toUpperCase() + method.slice(1);
|
// 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);
|
||||||
direction = "contract";
|
direction = "contract";
|
||||||
directionLabel = label;
|
directionLabel = label;
|
||||||
value = "";
|
value = "";
|
||||||
|
exactValue = "";
|
||||||
|
rawAmount = "";
|
||||||
|
rawUnit = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -47,6 +67,9 @@ function parseTx(tx, addrLower) {
|
|||||||
from: from,
|
from: from,
|
||||||
to: to,
|
to: to,
|
||||||
value: value,
|
value: value,
|
||||||
|
exactValue: exactValue,
|
||||||
|
rawAmount: rawAmount,
|
||||||
|
rawUnit: rawUnit,
|
||||||
valueGwei: Math.floor(Number(BigInt(rawWei) / BigInt(1000000000))),
|
valueGwei: Math.floor(Number(BigInt(rawWei) / BigInt(1000000000))),
|
||||||
symbol: symbol,
|
symbol: symbol,
|
||||||
direction: direction,
|
direction: direction,
|
||||||
@@ -63,17 +86,21 @@ function parseTokenTransfer(tt, addrLower) {
|
|||||||
const from = tt.from?.hash || "";
|
const from = tt.from?.hash || "";
|
||||||
const to = tt.to?.hash || "";
|
const to = tt.to?.hash || "";
|
||||||
const decimals = parseInt(tt.total?.decimals || "18", 10);
|
const decimals = parseInt(tt.total?.decimals || "18", 10);
|
||||||
const rawValue = tt.total?.value || "0";
|
const rawVal = tt.total?.value || "0";
|
||||||
const direction = from.toLowerCase() === addrLower ? "sent" : "received";
|
const direction = from.toLowerCase() === addrLower ? "sent" : "received";
|
||||||
|
const sym = tt.token?.symbol || "?";
|
||||||
return {
|
return {
|
||||||
hash: tt.transaction_hash,
|
hash: tt.transaction_hash,
|
||||||
blockNumber: tt.block_number,
|
blockNumber: tt.block_number,
|
||||||
timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
|
timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
|
||||||
from: from,
|
from: from,
|
||||||
to: to,
|
to: to,
|
||||||
value: formatTxValue(formatUnits(rawValue, decimals)),
|
value: formatTxValue(formatUnits(rawVal, decimals)),
|
||||||
|
exactValue: formatUnits(rawVal, decimals),
|
||||||
|
rawAmount: rawVal,
|
||||||
|
rawUnit: sym + " base units (10^-" + decimals + ")",
|
||||||
valueGwei: null,
|
valueGwei: null,
|
||||||
symbol: tt.token?.symbol || "?",
|
symbol: sym,
|
||||||
direction: direction,
|
direction: direction,
|
||||||
directionLabel: direction === "sent" ? "Sent" : "Received",
|
directionLabel: direction === "sent" ? "Sent" : "Received",
|
||||||
isError: false,
|
isError: false,
|
||||||
@@ -126,9 +153,18 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
|||||||
|
|
||||||
// When a token transfer shares a hash with a normal tx, the normal tx
|
// 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
|
// is the contract call (0 ETH) and the token transfer has the real
|
||||||
// amount and symbol. Replace the normal tx with the token transfer.
|
// amount and symbol. Replace the normal tx with the token transfer,
|
||||||
|
// but preserve contract call metadata (direction, label, method) so
|
||||||
|
// swaps and other contract interactions display correctly.
|
||||||
for (const tt of ttJson.items || []) {
|
for (const tt of ttJson.items || []) {
|
||||||
const parsed = parseTokenTransfer(tt, addrLower);
|
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;
|
||||||
|
}
|
||||||
txsByHash.set(parsed.hash, parsed);
|
txsByHash.set(parsed.hash, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
497
src/shared/uniswap.js
Normal file
497
src/shared/uniswap.js
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
// Decode Uniswap Universal Router execute() calldata into human-readable
|
||||||
|
// swap details. Designed to be extended with other DEX decoders later.
|
||||||
|
|
||||||
|
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
|
||||||
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
|
|
||||||
|
const coder = AbiCoder.defaultAbiCoder();
|
||||||
|
|
||||||
|
const ROUTER_IFACE = new Interface([
|
||||||
|
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Universal Router command IDs (lower 5 bits of each command byte)
|
||||||
|
const COMMAND_NAMES = {
|
||||||
|
0x00: "V3 Swap (Exact In)",
|
||||||
|
0x01: "V3 Swap (Exact Out)",
|
||||||
|
0x02: "Permit2 Transfer",
|
||||||
|
0x03: "Permit2 Permit Batch",
|
||||||
|
0x04: "Sweep",
|
||||||
|
0x05: "Transfer",
|
||||||
|
0x06: "Pay Portion",
|
||||||
|
0x08: "V2 Swap (Exact In)",
|
||||||
|
0x09: "V2 Swap (Exact Out)",
|
||||||
|
0x0a: "Permit2 Permit",
|
||||||
|
0x0b: "Wrap ETH",
|
||||||
|
0x0c: "Unwrap WETH",
|
||||||
|
0x0d: "Permit2 Transfer Batch",
|
||||||
|
0x0e: "Balance Check",
|
||||||
|
0x10: "V4 Swap",
|
||||||
|
0x11: "V3 Position Mgr Permit",
|
||||||
|
0x12: "V3 Position Mgr Call",
|
||||||
|
0x13: "V4 Initialize Pool",
|
||||||
|
0x14: "V4 Position Mgr Call",
|
||||||
|
0x21: "Execute Sub-Plan",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAmount(raw, decimals) {
|
||||||
|
const parts = formatUnits(raw, decimals).split(".");
|
||||||
|
if (parts.length === 1) return parts[0] + ".0000";
|
||||||
|
const dec = (parts[1] + "0000").slice(0, 4);
|
||||||
|
return parts[0] + "." + dec;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenInfo(address) {
|
||||||
|
if (!address || address === "0x0000000000000000000000000000000000000000") {
|
||||||
|
return { symbol: "ETH", decimals: 18, address: null };
|
||||||
|
}
|
||||||
|
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
||||||
|
if (t) return { symbol: t.symbol, decimals: t.decimals, address };
|
||||||
|
return { symbol: null, decimals: 18, address };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode PERMIT2_PERMIT (command 0x0a) input bytes.
|
||||||
|
// ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce),
|
||||||
|
// address spender, uint256 sigDeadline), bytes signature
|
||||||
|
function decodePermit2(input) {
|
||||||
|
try {
|
||||||
|
const d = coder.decode(
|
||||||
|
[
|
||||||
|
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
|
||||||
|
"bytes",
|
||||||
|
],
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
return { token: d[0][0][0], amount: d[0][0][1], spender: d[0][1] };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes.
|
||||||
|
// ABI: (address owner, address token, uint256 minBalance)
|
||||||
|
function decodeBalanceCheck(input) {
|
||||||
|
try {
|
||||||
|
const d = coder.decode(["address", "address", "uint256"], input);
|
||||||
|
return { owner: d[0], token: d[1], minBalance: d[2] };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
|
||||||
|
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
||||||
|
// address[] path, bool payerIsUser)
|
||||||
|
function decodeV2SwapExactIn(input) {
|
||||||
|
try {
|
||||||
|
const d = coder.decode(
|
||||||
|
["address", "uint256", "uint256", "address[]", "bool"],
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
amountIn: d[1],
|
||||||
|
amountOutMin: d[2],
|
||||||
|
tokenIn: d[3][0],
|
||||||
|
tokenOut: d[3][d[3].length - 1],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
|
||||||
|
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
|
||||||
|
// address[] path, bool payerIsUser)
|
||||||
|
function decodeV2SwapExactOut(input) {
|
||||||
|
try {
|
||||||
|
const d = coder.decode(
|
||||||
|
["address", "uint256", "uint256", "address[]", "bool"],
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
amountOut: d[1],
|
||||||
|
amountInMax: d[2],
|
||||||
|
tokenIn: d[3][0],
|
||||||
|
tokenOut: d[3][d[3].length - 1],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode V3 swap path (packed: token(20) + fee(3) + token(20) ...)
|
||||||
|
function decodeV3Path(pathHex) {
|
||||||
|
const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex;
|
||||||
|
if (hex.length < 40) return null;
|
||||||
|
const tokenIn = "0x" + hex.slice(0, 40);
|
||||||
|
const tokenOut = "0x" + hex.slice(-40);
|
||||||
|
return { tokenIn, tokenOut };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode V3_SWAP_EXACT_IN (command 0x00) input bytes.
|
||||||
|
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
||||||
|
// bytes path, bool payerIsUser)
|
||||||
|
function decodeV3SwapExactIn(input) {
|
||||||
|
try {
|
||||||
|
const d = coder.decode(
|
||||||
|
["address", "uint256", "uint256", "bytes", "bool"],
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
const path = decodeV3Path(d[3]);
|
||||||
|
if (!path) return null;
|
||||||
|
return {
|
||||||
|
amountIn: d[1],
|
||||||
|
amountOutMin: d[2],
|
||||||
|
tokenIn: path.tokenIn,
|
||||||
|
tokenOut: path.tokenOut,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode WRAP_ETH (command 0x0b) input bytes.
|
||||||
|
// ABI: (address recipient, uint256 amount)
|
||||||
|
function decodeWrapEth(input) {
|
||||||
|
try {
|
||||||
|
const d = coder.decode(["address", "uint256"], input);
|
||||||
|
return { amount: d[1] };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// V4 inner action IDs
|
||||||
|
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
|
||||||
|
const V4_SWAP_EXACT_IN = 0x07;
|
||||||
|
const V4_SWAP_EXACT_OUT_SINGLE = 0x08;
|
||||||
|
const V4_SWAP_EXACT_OUT = 0x09;
|
||||||
|
const V4_SETTLE = 0x0b;
|
||||||
|
const V4_TAKE = 0x0e;
|
||||||
|
|
||||||
|
// Decode V4_SWAP (command 0x10) input bytes.
|
||||||
|
// The input is ABI-encoded as (bytes actions, bytes[] params).
|
||||||
|
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
|
||||||
|
// and swap amounts from the swap sub-actions.
|
||||||
|
function decodeV4Swap(input) {
|
||||||
|
try {
|
||||||
|
const d = coder.decode(["bytes", "bytes[]"], input);
|
||||||
|
const actions = getBytes(d[0]);
|
||||||
|
const params = d[1];
|
||||||
|
|
||||||
|
let settleToken = null;
|
||||||
|
let takeToken = null;
|
||||||
|
let amountIn = null;
|
||||||
|
let amountOutMin = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < actions.length; i++) {
|
||||||
|
const actionId = actions[i];
|
||||||
|
try {
|
||||||
|
if (actionId === V4_SETTLE) {
|
||||||
|
// SETTLE: (address currency, uint256 maxAmount, bool payerIsUser)
|
||||||
|
const s = coder.decode(
|
||||||
|
["address", "uint256", "bool"],
|
||||||
|
params[i],
|
||||||
|
);
|
||||||
|
settleToken = s[0];
|
||||||
|
} else if (actionId === V4_TAKE) {
|
||||||
|
// TAKE: (address currency, address recipient, uint256 amount)
|
||||||
|
const t = coder.decode(
|
||||||
|
["address", "address", "uint256"],
|
||||||
|
params[i],
|
||||||
|
);
|
||||||
|
takeToken = t[0];
|
||||||
|
} else if (
|
||||||
|
actionId === V4_SWAP_EXACT_IN ||
|
||||||
|
actionId === V4_SWAP_EXACT_IN_SINGLE
|
||||||
|
) {
|
||||||
|
// Extract amounts from exact-in swap actions
|
||||||
|
if (actionId === V4_SWAP_EXACT_IN) {
|
||||||
|
// ExactInputParams: (address currencyIn,
|
||||||
|
// tuple(address,uint24,int24,address,bytes)[] path,
|
||||||
|
// uint128 amountIn, uint128 amountOutMin)
|
||||||
|
try {
|
||||||
|
const s = coder.decode(
|
||||||
|
[
|
||||||
|
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
||||||
|
],
|
||||||
|
params[i],
|
||||||
|
);
|
||||||
|
if (!settleToken) settleToken = s[0][0];
|
||||||
|
const path = s[0][1];
|
||||||
|
if (path.length > 0 && !takeToken) {
|
||||||
|
takeToken = path[path.length - 1][0];
|
||||||
|
}
|
||||||
|
if (!amountIn) amountIn = s[0][2];
|
||||||
|
if (!amountOutMin) amountOutMin = s[0][3];
|
||||||
|
} catch {
|
||||||
|
// Fall through — SETTLE/TAKE will provide tokens
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
|
||||||
|
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
|
||||||
|
try {
|
||||||
|
const s = coder.decode(
|
||||||
|
[
|
||||||
|
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
||||||
|
],
|
||||||
|
params[i],
|
||||||
|
);
|
||||||
|
const poolKey = s[0][0];
|
||||||
|
const zeroForOne = s[0][1];
|
||||||
|
if (!settleToken)
|
||||||
|
settleToken = zeroForOne
|
||||||
|
? poolKey[0]
|
||||||
|
: poolKey[1];
|
||||||
|
if (!takeToken)
|
||||||
|
takeToken = zeroForOne
|
||||||
|
? poolKey[1]
|
||||||
|
: poolKey[0];
|
||||||
|
if (!amountIn) amountIn = s[0][2];
|
||||||
|
if (!amountOutMin) amountOutMin = s[0][3];
|
||||||
|
} catch {
|
||||||
|
// Fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
actionId === V4_SWAP_EXACT_OUT ||
|
||||||
|
actionId === V4_SWAP_EXACT_OUT_SINGLE
|
||||||
|
) {
|
||||||
|
if (actionId === V4_SWAP_EXACT_OUT) {
|
||||||
|
try {
|
||||||
|
const s = coder.decode(
|
||||||
|
[
|
||||||
|
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
||||||
|
],
|
||||||
|
params[i],
|
||||||
|
);
|
||||||
|
if (!takeToken) takeToken = s[0][0];
|
||||||
|
const path = s[0][1];
|
||||||
|
if (path.length > 0 && !settleToken) {
|
||||||
|
settleToken = path[path.length - 1][0];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const s = coder.decode(
|
||||||
|
[
|
||||||
|
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
||||||
|
],
|
||||||
|
params[i],
|
||||||
|
);
|
||||||
|
const poolKey = s[0][0];
|
||||||
|
const zeroForOne = s[0][1];
|
||||||
|
if (!settleToken)
|
||||||
|
settleToken = zeroForOne
|
||||||
|
? poolKey[0]
|
||||||
|
: poolKey[1];
|
||||||
|
if (!takeToken)
|
||||||
|
takeToken = zeroForOne
|
||||||
|
? poolKey[1]
|
||||||
|
: poolKey[0];
|
||||||
|
} catch {
|
||||||
|
// Fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip sub-actions we can't decode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokenIn: settleToken,
|
||||||
|
tokenOut: takeToken,
|
||||||
|
amountIn,
|
||||||
|
amountOutMin,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to decode a Universal Router execute() call.
|
||||||
|
// Returns { name, description, details } matching the format used by
|
||||||
|
// the approval UI, or null if the calldata is not a recognised execute().
|
||||||
|
function decode(data, toAddress) {
|
||||||
|
try {
|
||||||
|
const parsed = ROUTER_IFACE.parseTransaction({ data });
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
const commandsBytes = getBytes(parsed.args[0]);
|
||||||
|
const inputs = parsed.args[1];
|
||||||
|
const deadline = parsed.args[2];
|
||||||
|
|
||||||
|
let inputToken = null;
|
||||||
|
let inputAmount = null;
|
||||||
|
let outputToken = null;
|
||||||
|
let minOutput = null;
|
||||||
|
let hasUnwrapWeth = false;
|
||||||
|
const commandNames = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < commandsBytes.length; i++) {
|
||||||
|
const cmdId = commandsBytes[i] & 0x1f;
|
||||||
|
commandNames.push(
|
||||||
|
COMMAND_NAMES[cmdId] ||
|
||||||
|
"Command 0x" + cmdId.toString(16).padStart(2, "0"),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cmdId === 0x0a) {
|
||||||
|
const p = decodePermit2(inputs[i]);
|
||||||
|
if (p) {
|
||||||
|
inputToken = p.token;
|
||||||
|
inputAmount = p.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmdId === 0x0e) {
|
||||||
|
const b = decodeBalanceCheck(inputs[i]);
|
||||||
|
if (b) {
|
||||||
|
outputToken = b.token;
|
||||||
|
minOutput = b.minBalance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmdId === 0x00) {
|
||||||
|
const s = decodeV3SwapExactIn(inputs[i]);
|
||||||
|
if (s) {
|
||||||
|
if (!inputToken) inputToken = s.tokenIn;
|
||||||
|
if (!outputToken) outputToken = s.tokenOut;
|
||||||
|
if (!inputAmount) inputAmount = s.amountIn;
|
||||||
|
if (!minOutput) minOutput = s.amountOutMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmdId === 0x08) {
|
||||||
|
const s = decodeV2SwapExactIn(inputs[i]);
|
||||||
|
if (s) {
|
||||||
|
if (!inputToken) inputToken = s.tokenIn;
|
||||||
|
if (!outputToken) outputToken = s.tokenOut;
|
||||||
|
if (!inputAmount) inputAmount = s.amountIn;
|
||||||
|
if (!minOutput) minOutput = s.amountOutMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmdId === 0x0b) {
|
||||||
|
const w = decodeWrapEth(inputs[i]);
|
||||||
|
if (w && !inputToken) {
|
||||||
|
inputToken =
|
||||||
|
"0x0000000000000000000000000000000000000000";
|
||||||
|
inputAmount = w.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmdId === 0x10) {
|
||||||
|
const v4 = decodeV4Swap(inputs[i]);
|
||||||
|
if (v4) {
|
||||||
|
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
|
||||||
|
if (!outputToken && v4.tokenOut)
|
||||||
|
outputToken = v4.tokenOut;
|
||||||
|
if (!inputAmount && v4.amountIn)
|
||||||
|
inputAmount = v4.amountIn;
|
||||||
|
if (!minOutput && v4.amountOutMin)
|
||||||
|
minOutput = v4.amountOutMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmdId === 0x0c) {
|
||||||
|
hasUnwrapWeth = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip commands we can't decode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve token info
|
||||||
|
const inInfo = tokenInfo(inputToken);
|
||||||
|
const outInfo = hasUnwrapWeth
|
||||||
|
? { symbol: "ETH", decimals: 18, address: null }
|
||||||
|
: tokenInfo(outputToken);
|
||||||
|
|
||||||
|
const inSymbol = inInfo.symbol;
|
||||||
|
const outSymbol = outInfo.symbol;
|
||||||
|
|
||||||
|
const name =
|
||||||
|
inSymbol && outSymbol
|
||||||
|
? "Swap " + inSymbol + " \u2192 " + outSymbol
|
||||||
|
: "Uniswap Swap";
|
||||||
|
|
||||||
|
const details = [];
|
||||||
|
|
||||||
|
details.push({
|
||||||
|
label: "Protocol",
|
||||||
|
value: "Uniswap Universal Router",
|
||||||
|
address: toAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inputToken && inInfo.address) {
|
||||||
|
const label = inSymbol
|
||||||
|
? inSymbol + " (" + inputToken + ")"
|
||||||
|
: inputToken;
|
||||||
|
details.push({
|
||||||
|
label: "Token In",
|
||||||
|
value: label,
|
||||||
|
address: inputToken,
|
||||||
|
isToken: true,
|
||||||
|
});
|
||||||
|
} else if (inSymbol === "ETH") {
|
||||||
|
details.push({ label: "Token In", value: "ETH (native)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputAmount !== null && inputAmount !== undefined) {
|
||||||
|
const maxUint160 = BigInt(
|
||||||
|
"0xffffffffffffffffffffffffffffffffffffffff",
|
||||||
|
);
|
||||||
|
const amountStr =
|
||||||
|
inputAmount >= maxUint160
|
||||||
|
? "Unlimited"
|
||||||
|
: formatAmount(inputAmount, inInfo.decimals) +
|
||||||
|
(inSymbol ? " " + inSymbol : "");
|
||||||
|
details.push({ label: "Amount", value: amountStr });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outSymbol) {
|
||||||
|
if (outInfo.address) {
|
||||||
|
const label = outSymbol
|
||||||
|
? outSymbol + " (" + outputToken + ")"
|
||||||
|
: outputToken;
|
||||||
|
details.push({
|
||||||
|
label: "Token Out",
|
||||||
|
value: label,
|
||||||
|
address: outputToken,
|
||||||
|
isToken: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
details.push({ label: "Token Out", value: outSymbol });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minOutput !== null && minOutput !== undefined) {
|
||||||
|
const minStr =
|
||||||
|
formatAmount(minOutput, outInfo.decimals) +
|
||||||
|
(outSymbol ? " " + outSymbol : "");
|
||||||
|
details.push({ label: "Min. received", value: minStr });
|
||||||
|
}
|
||||||
|
|
||||||
|
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
|
||||||
|
|
||||||
|
const deadlineDate = new Date(Number(deadline) * 1000);
|
||||||
|
details.push({
|
||||||
|
label: "Deadline",
|
||||||
|
value: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description: "Swap via Uniswap Universal Router",
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { decode };
|
||||||
@@ -24,6 +24,26 @@ function hdWalletFromMnemonic(mnemonic) {
|
|||||||
return { xpub, firstAddress };
|
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) {
|
function addressFromPrivateKey(key) {
|
||||||
const w = new Wallet(key);
|
const w = new Wallet(key);
|
||||||
return w.address;
|
return w.address;
|
||||||
@@ -38,6 +58,11 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
|
|||||||
);
|
);
|
||||||
return node.deriveChild(addrIndex);
|
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);
|
return new Wallet(decryptedSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +74,8 @@ module.exports = {
|
|||||||
generateMnemonic,
|
generateMnemonic,
|
||||||
deriveAddressFromXpub,
|
deriveAddressFromXpub,
|
||||||
hdWalletFromMnemonic,
|
hdWalletFromMnemonic,
|
||||||
|
hdWalletFromXprv,
|
||||||
|
isValidXprv,
|
||||||
addressFromPrivateKey,
|
addressFromPrivateKey,
|
||||||
getSignerForAddress,
|
getSignerForAddress,
|
||||||
isValidMnemonic,
|
isValidMnemonic,
|
||||||
|
|||||||
356
tests/uniswap.test.js
Normal file
356
tests/uniswap.test.js
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
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