Compare commits

..
Author SHA1 Message Date
user 868f7a6ff8 fix: show user's own address for swap transactions in tx list
check / check (push) Successful in 22s
For swap/contract interactions, display the user's own (labelled)
address instead of the contract address, so users can see which
of their addresses initiated the swap.

Fixes #55
2026-02-28 08:57:08 -08:00
sneak fb67359b3f Merge pull request 'fix: add reverse ENS lookups for all displayed addresses (closes #22)' (#25) from fix/reverse-ens-lookups into main
check / check (push) Successful in 9s
Reviewed-on: #25
2026-02-28 10:06:45 +01:00
sneak 1986704569 Merge branch 'main' into fix/reverse-ens-lookups
check / check (push) Successful in 21s
2026-02-28 10:05:16 +01:00
sneak 49c29f6bb3 Merge pull request 'fix: preserve ENS names on lookup failure, add debug logging (closes #22)' (#24) from fix/ens-reverse-lookup into main
check / check (push) Successful in 8s
Reviewed-on: #24
2026-02-28 10:05:03 +01:00
sneak cdb7f478e2 Merge branch 'main' into fix/ens-reverse-lookup
check / check (push) Successful in 22s
2026-02-28 10:04:26 +01:00
sneak cbe77d0224 Merge pull request 'fix: show wallet/address titles across all views (closes #26, closes #27, closes #28, closes #29)' (#30) from fix/address-title-consistency into main
check / check (push) Successful in 9s
Reviewed-on: #30
2026-02-28 10:03:40 +01:00
user 2abb720d54 fix: show wallet/address titles in addressDetail and addressToken tx lists (closes #29)
check / check (push) Successful in 21s
2026-02-27 14:30:09 -08:00
user bf9a483031 fix: show wallet/address titles in send, txStatus, and home tx list (closes #26, closes #27, closes #28)
check / check (push) Successful in 22s
- send.js: show addressTitle() above ENS name and address in From field
- txStatus.js: show addressTitle() in To address when it's a local wallet
- home.js: show addressTitle() for counterparties in tx list when they
  are local wallet addresses
2026-02-27 14:28:20 -08:00
clawbot 79fec8551f fix: add reverse ENS lookups for all displayed addresses (closes #22)
check / check (push) Successful in 22s
Previously, ENS reverse lookups were only performed for the single
counterparty address (from or to depending on direction). This meant
contract interaction targets and the non-counterparty side of
transactions never got ENS names resolved.

Now both from and to addresses are collected for ENS resolution,
ensuring all displayed addresses show their ENS names when available.
2026-02-27 14:26:04 -08:00
user da428a3815 fix: preserve ENS names on lookup failure, add debug logging (closes #22)
check / check (push) Successful in 22s
Two issues that could cause ENS names to disappear:

1. refreshBalances: on ENS lookup error, addr.ensName was set to null,
   wiping any previously resolved name. Now keeps the existing value
   on error — only overwrites on successful lookup.

2. ens.js cache: failed lookups were cached as null for 12 hours,
   preventing retries even after transient errors resolved. Now skips
   caching on failure so subsequent lookups retry immediately.

Added debug logging to ENS reverse lookups in refreshBalances.
2026-02-27 14:24:32 -08:00
sneak 171b21c5d8 Merge pull request 'fix: show wallet name for own addresses on approve-tx view (closes #21)' (#23) from fix/approval-address-title into main
check / check (push) Successful in 9s
Reviewed-on: #23
2026-02-27 23:20:26 +01:00
sneak e7a960c601 Merge branch 'main' into fix/approval-address-title
check / check (push) Successful in 22s
2026-02-27 23:20:14 +01:00
sneak b69eec40ef Merge pull request 'fix: low-severity security findings L3, L4, L5 (closes #6)' (#8) from fix/low-severity-security into main
check / check (push) Successful in 9s
Reviewed-on: #8
2026-02-27 23:19:08 +01:00
sneak cacf2c683c Merge branch 'main' into fix/low-severity-security
check / check (push) Successful in 22s
2026-02-27 23:18:53 +01:00
user 15e856e63f fix: show wallet name for own addresses on approve-tx view (closes #21)
check / check (push) Successful in 21s
The approve-tx view was showing raw addresses for From/To even when they
belonged to the user's wallet. Now uses addressTitle() to display the
wallet name (e.g. 'My Wallet — Address 1') consistently with other views.
2026-02-27 14:18:29 -08:00
sneak 43e10521ef Merge pull request 'fix: add fallback popup window for tx/sign approval requests (closes #4)' (#17) from fix/tx-approval-popup into main
check / check (push) Successful in 8s
Reviewed-on: #17
2026-02-27 23:15:02 +01:00
sneak 04ba926d43 Merge branch 'main' into fix/tx-approval-popup
check / check (push) Successful in 22s
2026-02-27 23:10:58 +01:00
clawbot 4fdbc5adae fmt: prettier format content/index.js
check / check (push) Successful in 21s
2026-02-27 14:10:37 -08:00
sneak 85427e1fd4 Merge branch 'main' into fix/low-severity-security
check / check (push) Failing after 13s
2026-02-27 23:08:40 +01:00
sneak 8226495994 Merge pull request 'fix: display swaps and contract calls correctly in tx history (closes #3)' (#10) from fix/swap-display into main
check / check (push) Successful in 9s
Reviewed-on: #10
2026-02-27 23:08:16 +01:00
sneak 2f57370155 Merge branch 'main' into fix/swap-display
check / check (push) Successful in 22s
2026-02-27 23:07:31 +01:00
sneak c6d5cf4e64 Merge pull request 'feat: add wallet deletion from settings (closes #13)' (#14) from feat/delete-wallet into main
check / check (push) Successful in 22s
Reviewed-on: #14
2026-02-27 23:04:45 +01:00
clawbot 34e29d7659 fix: re-render wallet list after deletion by calling showSettingsView
check / check (push) Successful in 21s
2026-02-27 14:02:44 -08:00
sneak 6d0531f1b4 Merge pull request 'fix: use grey well for contract address in address-token view (closes #9)' (#15) from fix/address-token-grey-well into main
check / check (push) Successful in 21s
Reviewed-on: #15
2026-02-27 23:00:00 +01:00
clawbot 8893f5dce7 refactor: delete-wallet-confirm as standalone full view
check / check (push) Successful in 22s
Replace the inline confirmation div at the bottom of Settings with a
proper full-screen view (view-delete-wallet-confirm). This fixes the
issue where the confirmation was offscreen on the 360x600 popup.

- New view with back button, title, warning text, password input,
  and red-text Confirm Delete button
- Dedicated flash area for password errors
- New deleteWallet.js module with init/show pattern
- Added delete-wallet-confirm to VIEWS array in helpers.js
- Removed old inline confirmation HTML and logic from settings
2026-02-27 13:58:58 -08:00
clawbot 2bffa91045 fix: reduce contract info well margins to prevent address wrapping
check / check (push) Successful in 22s
2026-02-27 13:54:19 -08:00
clawbot 2b0b889b01 fix: use wallet.encryptedSecret not wallet.encrypted for password verify
check / check (push) Successful in 22s
2026-02-27 13:52:08 -08:00
clawbot 5936199676 fix: place color dot next to address, not title, matching convention
check / check (push) Successful in 22s
2026-02-27 13:03:43 -08:00
user 8824237db6 fix: match approval view display consistency for decoded calldata
check / check (push) Successful in 21s
- Restructured calldata section to use same well layout as approval view:
  Action label + bold name + structured details
- Always show raw data section below decoded well
- Unknown contract calls show method name in well instead of inline
2026-02-27 13:01:53 -08:00
user aaeb38d7c6 fix: show Swap type label and heading on transaction detail page
check / check (push) Successful in 21s
2026-02-27 13:00:07 -08:00
clawbot f2e44ff4ab fix: use windows.create() for tx/sign approval popups instead of openPopup()
check / check (push) Successful in 22s
action.openPopup() is unreliable when called from the background script
during an async message handler — it requires a user gesture context.
tx and sign approvals are triggered programmatically by dApp RPC calls,
not by user clicking the toolbar icon, so openPopup() fails silently.

Use windows.create() directly for tx/sign approvals, matching the
standard extension pattern (used by MetaMask and others). Site-connection
approvals retain openPopup() since they can fall back to the user
clicking the toolbar icon.

Also updates popup window dimensions to 360x600 to match the standard
popup viewport specified in README.

Closes #4
2026-02-27 12:57:55 -08:00
clawbot 107c243f65 fix: use consistent [x] delete buttons, add inline rename
check / check (push) Successful in 8s
- Delete buttons now use [x] with border, matching token and site
  removal patterns in settings
- Wallet names are click-to-rename (inline input), matching the
  home view rename UX
2026-02-27 12:53:46 -08:00
clawbot 655b90c7df feat: add wallet deletion from settings (closes #13)
- Per-wallet [delete] links in settings wallet list
- Monochrome styling throughout, no red/danger colors
- Password confirmation modal with warning text
- Cleans up site permissions for deleted addresses
- Switches to first remaining wallet or shows welcome if none left
2026-02-27 12:53:46 -08:00
clawbot 34cd72be88 fix: rework wallet deletion per review feedback
- Remove all red/danger styling, use standard monochrome colors
- Add wallet picker dropdown instead of relying on selectedWallet
- Fix encryptedSecret field name (was wallet.encrypted)
- Populate dropdown when settings view opens
- Confirmation modal uses standard border styling
2026-02-27 12:53:46 -08:00
user 689bcbf171 feat: add wallet deletion from settings (closes #13) 2026-02-27 12:53:46 -08:00
sneak 4eefe4c1af Merge pull request 'fix: pass UUID approval ID as string, not parseInt (closes #4)' (#18) from fix/approval-popup-uuid into main
check / check (push) Successful in 8s
Reviewed-on: #18
2026-02-27 21:50:27 +01:00
clawbot 3b419c7517 fix: add missing TOKEN_BY_ADDRESS import in addressToken view
check / check (push) Successful in 22s
2026-02-27 12:50:26 -08:00
clawbot 8b7d73cc35 fix: pass UUID approval ID as string, not parseInt (closes #4)
check / check (push) Successful in 22s
The approval ID was changed from sequential integers to crypto.randomUUID()
strings for security, but the popup still called parseInt() on it, which
converted the UUID to NaN. This caused every approval lookup to fail,
preventing the confirmation popup from displaying pending tx/sign requests.
2026-02-27 12:34:23 -08:00
clawbot 3fd3e30f44 fix: label swap methods as "Swap" in tx lists, remove unused variable
check / check (push) Successful in 23s
- Map known DEX methods (execute, swap, multicall, etc.) to "Swap"
  label instead of raw method name like "Execute"
- Remove unused displayData variable in transactionDetail.js

Addresses review feedback on PR #10.
2026-02-27 12:31:25 -08:00
clawbot 76059c3674 fix: display swaps and contract calls correctly in tx history (closes #3)
- Preserve contract call metadata (direction, label, method) when token
  transfers merge with normal txs in fetchRecentTransactions
- Handle 'contract' direction in counterparty display for home and
  address detail list views
- Add decoded calldata display to transaction detail view, fetching
  raw input from Blockscout and using decodeCalldata from approval.js
- Show 'Unknown contract call' with raw hex for unrecognized calldata
- Export decodeCalldata from approval.js for reuse
2026-02-27 12:31:13 -08:00
clawbot 8332570758 fix: increase well horizontal margin to mx-4 per review
check / check (push) Successful in 22s
2026-02-27 12:27:23 -08:00
clawbot 7b004ddda4 fix: rework contract info well per review feedback
check / check (push) Successful in 22s
- Remove border, add rounded corners and horizontal margin
- Each attribute on its own line (key: value format)
- Move well below send/receive buttons
- Add project/token URL from tokenlist when available
- Import TOKEN_BY_ADDRESS for URL lookup
2026-02-27 12:26:24 -08:00
clawbot 91eefa1667 fix: use grey well for contract address display in address-token view
check / check (push) Successful in 22s
- Replace border-b styling with bg-hover + dashed border for visual
  distinction from wallet address
- Rename label from "Token Contract" to "Contract Address"
- Addresses feedback on #9
2026-02-27 12:15:35 -08:00
sneak 0ed7b8e61d Merge pull request 'fix: show ERC-20 contract details in address-token view (closes #9)' (#11) from fix/address-token-details into main
check / check (push) Successful in 8s
Reviewed-on: #11
2026-02-27 21:09:37 +01:00
user 560065dd77 fix: show ERC-20 contract details in address-token view (closes #9)
check / check (push) Successful in 22s
2026-02-27 12:06:22 -08:00
user 27f16191b4 fix(L4): use location.origin for postMessage, one-shot UUID listener
check / check (push) Failing after 13s
- Content script sends UUID via location.origin instead of "*"
- Inpage UUID listener removes itself after first message to prevent
  malicious pages from overriding the persisted UUID
2026-02-27 11:58:57 -08:00
clawbot 909543e943 fix(L5): truncate token name/symbol from RPC responses
Limits token name to 64 chars and symbol to 12 chars to prevent
storage of excessively long values from malicious contracts.
2026-02-27 11:58:19 -08:00
clawbot 04a34d1a5e fix(L4): generate EIP-6963 provider UUID at install time
UUID is generated once via crypto.randomUUID(), persisted in
chrome.storage.local, and sent from the content script to the
inpage script via postMessage.
2026-02-27 11:58:19 -08:00
clawbot 98f68adb11 fix(L3): isUnlocked() returns false when no accounts exposed
_metamask.isUnlocked() now checks provider.selectedAddress instead of
always returning true.
2026-02-27 11:58:19 -08:00
sneak 0413c52229 Merge pull request 'security: fix high-severity findings from audit (closes #6)' (#7) from fix/high-severity-security into main
check / check (push) Successful in 9s
Reviewed-on: #7
2026-02-27 20:56:43 +01:00
sneak b01df0639b Merge branch 'main' into fix/high-severity-security
check / check (push) Successful in 21s
2026-02-27 20:56:09 +01:00
sneak 8beb3cd70c Merge pull request 'Fix all RULES.md divergences' (#2) from fix/rules-audit-divergences into main
check / check (push) Successful in 8s
Reviewed-on: #2
2026-02-27 20:55:25 +01:00
clawbot 31b22c1325 style: format README.md and RULES.md with Prettier
check / check (push) Successful in 21s
2026-02-27 11:39:44 -08:00
clawbot eec96f9054 security: clear decrypted secrets after use (best-effort)
check / check (push) Successful in 21s
2026-02-27 11:36:56 -08:00
clawbot f13cd0fd47 security: add TODO comments for password plaintext over runtime.sendMessage 2026-02-27 11:36:19 -08:00
clawbot b478d9efa9 security: validate sender URL for popup-only messages 2026-02-27 11:35:42 -08:00
clawbot d59ebfd461 security: derive RPC origin from sender instead of trusting msg.origin 2026-02-27 11:35:31 -08:00
clawbot 13e2bdb0b0 security: add prominent danger warning for eth_sign requests 2026-02-27 11:35:21 -08:00
clawbot 95314ff229 security: replace predictable sequential approval IDs with crypto.randomUUID() 2026-02-27 11:34:48 -08:00
clawbot 1237cf8491 security: increase minimum password length from 8 to 12 characters 2026-02-27 11:34:32 -08:00
clawbot afc4868001 docs: document Blockscout as third external service in README
check / check (push) Failing after 13s
2026-02-27 03:25:02 -08:00
clawbot a6017ce32c docs: add agent-protection notice to RULES.md 2026-02-27 03:25:01 -08:00
sneak 9cceca8576 Merge branch 'main' into fix/rules-audit-divergences
check / check (push) Successful in 22s
2026-02-27 11:55:51 +01:00
sneak 6a3be80379 Merge pull request 'add tracked token list management to settings' (#5) from feature/settings-token-management into main
check / check (push) Successful in 9s
Reviewed-on: #5
2026-02-27 11:55:32 +01:00
sneak 3d8feb4c5a Add token management to Settings
check / check (push) Successful in 22s
- Tracked Tokens well in settings with [x] remove buttons
- New settings-addtoken view with:
  - Top-10 quick-pick buttons (tracked ones dimmed+disabled)
  - Top-100 dropdown showing "Token Name (SYMBOL)", tracked disabled
  - Manual contract address entry with RPC lookup
- View comment in helpers.js about keeping README in sync
2026-02-27 17:52:30 +07:00
sneak aca8c4b2a7 Add name and url fields to all 512 tokens in tokenList.js
Names fetched from CoinGecko bulk API (100% coverage).
URLs sourced from ethereum-lists GitHub repo + manual curation
for major tokens. Some lesser-known tokens have empty URLs which
can be populated incrementally.
2026-02-27 17:51:08 +07:00
25 changed files with 1965 additions and 100 deletions
+22 -7
View File
@@ -15,9 +15,10 @@ 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 precisely two external services: the configured RPC node for
blockchain interactions, and a public CoinDesk API (no API key) to get realtime
price information.
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
@@ -534,7 +535,7 @@ transitions.
### External Services
AutistMask is not a fully self-contained offline tool. It necessarily
communicates with two 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
balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`),
@@ -543,11 +544,24 @@ communicates with two external services to function as a wallet:
receipts. The default endpoint is a public RPC (configurable by the user to
any endpoint they prefer, including a local node). By default the extension
talks to `https://ethereum-rpc.publicnode.com`.
- **Data sent**: Ethereum addresses, transaction data, contract call
parameters. The RPC endpoint can see all on-chain queries and submitted
transactions.
- **CoinDesk CADLI price API**: Used to fetch ETH/USD and token/USD prices for
displaying fiat values. The price is cached for 5 minutes to avoid excessive
requests. No API key required. No user data is sent — only a list of token
symbols. Note that CoinDesk will receive your client IP.
- **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No
addresses or user-specific data.
- **Blockscout block-explorer API**: Used to fetch transaction history (normal
transactions and ERC-20 token transfers), ERC-20 token balances, and token
holder counts (for spam filtering). The default endpoint is
`https://eth.blockscout.com/api/v2` (configurable by the user in Settings).
- **Data sent**: Ethereum addresses. Blockscout receives the user's
addresses to query their transaction history and token balances. No
private keys, passwords, or signing operations are sent.
What the extension does NOT do:
@@ -557,9 +571,10 @@ What the extension does NOT do:
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer
The user's RPC endpoint and the CoinDesk price API are the only external
services. Users who want maximum privacy can point the RPC at their own node
(price fetching can be disabled in a future version).
These three services (RPC endpoint, CoinDesk price API, and Blockscout API) are
the only external services. All three endpoints are user-configurable. Users who
want maximum privacy can point the RPC and Blockscout URLs at their own
self-hosted instances (price fetching can be disabled in a future version).
### Dependencies
+5
View File
@@ -1,3 +1,8 @@
> **⚠️ 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
+67 -46
View File
@@ -30,7 +30,6 @@ const connectedSites = {};
// Pending approval requests: { id: { origin, hostname, resolve } }
const pendingApprovals = {};
let nextApprovalId = 1;
async function getState() {
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) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 400;
const popupHeight = 500;
const popupWidth = 360;
const popupHeight = 600;
windowsApi.getLastFocused((currentWin) => {
const opts = {
@@ -127,7 +128,7 @@ function openApprovalWindow(id) {
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
function requestApproval(origin, hostname) {
return new Promise((resolve) => {
const id = nextApprovalId++;
const id = crypto.randomUUID();
pendingApprovals[id] = { origin, hostname, resolve };
if (actionApi && typeof actionApi.openPopup === "function") {
@@ -149,10 +150,12 @@ function requestApproval(origin, hostname) {
}
// Open a tx-approval popup and return a promise that resolves with txHash or error.
// Uses the toolbar popup only — no fallback window.
// Uses windows.create() directly because tx approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
function requestTxApproval(origin, hostname, txParams) {
return new Promise((resolve) => {
const id = nextApprovalId++;
const id = crypto.randomUUID();
pendingApprovals[id] = {
origin,
hostname,
@@ -161,30 +164,17 @@ function requestTxApproval(origin, hostname, txParams) {
type: "tx",
};
if (actionApi && typeof actionApi.setPopup === "function") {
actionApi.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
}
if (actionApi && typeof actionApi.openPopup === "function") {
try {
const result = actionApi.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => {});
}
} catch {
// openPopup unsupported — user clicks toolbar icon
}
}
openApprovalWindow(id);
});
}
// 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
// popup URL is still set, so the user can click the toolbar icon to respond.
// Uses windows.create() directly because sign approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
function requestSignApproval(origin, hostname, signParams) {
return new Promise((resolve) => {
const id = nextApprovalId++;
const id = crypto.randomUUID();
pendingApprovals[id] = {
origin,
hostname,
@@ -193,30 +183,17 @@ function requestSignApproval(origin, hostname, signParams) {
type: "sign",
};
if (actionApi && typeof actionApi.setPopup === "function") {
actionApi.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
}
if (actionApi && typeof actionApi.openPopup === "function") {
try {
const result = actionApi.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => {});
}
} catch {
// openPopup unsupported — user clicks toolbar icon
}
}
openApprovalWindow(id);
});
}
// Detect when an approval popup (browser-action) closes without a response.
// TX and sign approvals are NOT auto-rejected on disconnect because toolbar
// popups naturally close on focus loss and the user can reopen them.
// TX and sign approvals now use windows.create() and are handled by the
// windowsApi.onRemoved listener below, but we still handle site-connection
// approval disconnects here.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
const id = parseInt(port.name.split(":")[1], 10);
const id = port.name.split(":")[1];
port.onDisconnect.addListener(() => {
const approval = pendingApprovals[id];
if (approval) {
@@ -442,6 +419,13 @@ async function handleRpc(method, params, origin) {
? { method, message: params[0], from: params[1] }
: { method, message: params[1], from: params[0] };
if (method === "eth_sign") {
signParams.dangerWarning =
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
"This can be used to sign transactions that drain your funds. " +
"Only proceed if you fully understand what you are signing.";
}
const decision = await requestSignApproval(
origin,
hostname,
@@ -611,12 +595,39 @@ if (windowsApi && windowsApi.onRemoved) {
// Listen for messages from content scripts and popup
runtime.onMessage.addListener((msg, sender, sendResponse) => {
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);
});
return true;
}
// Validate that popup-only messages originate from the extension itself.
const POPUP_ONLY_TYPES = [
"AUTISTMASK_GET_APPROVAL",
"AUTISTMASK_APPROVAL_RESPONSE",
"AUTISTMASK_TX_RESPONSE",
"AUTISTMASK_SIGN_RESPONSE",
];
if (POPUP_ONLY_TYPES.includes(msg.type)) {
const extUrl = runtime.getURL("");
if (!sender.url || !sender.url.startsWith(extUrl)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
}
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
const approval = pendingApprovals[msg.id];
if (approval) {
@@ -681,7 +692,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (wallet) break;
}
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,
msg.password,
);
@@ -690,6 +702,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
addrIndex,
decrypted,
);
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decrypted = null;
const provider = getProvider(state.rpcUrl);
const connected = signer.connect(provider);
const tx = await connected.sendTransaction(approval.txParams);
@@ -735,7 +751,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (wallet) break;
}
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,
msg.password,
);
@@ -744,6 +761,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
addrIndex,
decrypted,
);
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decrypted = null;
const sp = approval.signParams;
let signature;
+20
View File
@@ -13,6 +13,26 @@ if (typeof browser !== "undefined") {
(document.head || document.documentElement).appendChild(script);
}
// Send the persisted EIP-6963 provider UUID to the inpage script.
// Generated once at install time and stored in chrome.storage.local.
(function sendProviderUuid() {
const storage =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
storage.get("eip6963Uuid", (items) => {
let uuid = items?.eip6963Uuid;
if (!uuid) {
uuid = crypto.randomUUID();
storage.set({ eip6963Uuid: uuid });
}
window.postMessage(
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
location.origin,
);
});
})();
// Relay requests from the page to the background script
window.addEventListener("message", (event) => {
if (event.source !== window) return;
+23 -6
View File
@@ -9,7 +9,7 @@
const pending = {};
// Listen for responses from the content script
window.addEventListener("message", (event) => {
window.addEventListener("message", function onUuid(event) {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
const { id, result, error } = event.data;
@@ -24,7 +24,7 @@
});
// Listen for events pushed from the extension
window.addEventListener("message", (event) => {
window.addEventListener("message", function onUuid(event) {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_EVENT") return;
const { eventName, data } = event.data;
@@ -134,7 +134,7 @@
// Some dApps (wagmi) check this to confirm MetaMask-like behavior
_metamask: {
isUnlocked() {
return Promise.resolve(true);
return Promise.resolve(provider.selectedAddress !== null);
},
},
};
@@ -155,21 +155,38 @@
"</svg>",
);
const providerInfo = {
uuid: "f3c5b2a1-8d4e-4f6a-9c7b-1e2d3a4b5c6d",
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
function buildProviderInfo() {
return {
uuid: providerUuid,
name: "AutistMask",
icon: ICON_SVG,
rdns: "berlin.sneak.autistmask",
};
}
function announceProvider() {
window.dispatchEvent(
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);
announceProvider();
})();
+165 -4
View File
@@ -374,6 +374,12 @@
</button>
</div>
<!-- token contract details (ERC-20 only) -->
<div
id="address-token-contract-info"
class="bg-hover rounded-md mx-1 p-3 mb-3 text-xs hidden"
></div>
<!-- token-filtered transactions -->
<div class="mt-3">
<div class="border-b border-border pb-1 mb-1">
@@ -702,9 +708,7 @@
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Wallets</h3>
<p class="text-xs text-muted mb-2">
Add a new wallet from a recovery phrase or private key.
</p>
<div id="settings-wallet-list" class="mb-2"></div>
<button
id="btn-main-add-wallet"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
@@ -713,6 +717,21 @@
</button>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Tracked Tokens</h3>
<p class="text-xs text-muted mb-2">
ERC-20 tokens whose balances are tracked across all
addresses.
</p>
<div id="settings-tracked-tokens"></div>
<button
id="btn-settings-add-token"
class="border border-border px-2 py-1 mt-2 hover:bg-fg hover:text-bg cursor-pointer"
>
+ Add token
</button>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Display</h3>
<label
@@ -824,6 +843,108 @@
</div>
</div>
<!-- ============ DELETE WALLET CONFIRM ============ -->
<div id="view-delete-wallet-confirm" class="view hidden">
<button
id="btn-delete-wallet-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-3">Delete Wallet</h2>
<p class="text-xs mb-3">
Deleting
<strong id="delete-wallet-name"></strong> is permanent. Any
funds will be unrecoverable without your recovery phrase.
</p>
<div
id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 hidden"
></div>
<div class="mb-2">
<label class="block mb-1">Password</label>
<input
type="password"
id="delete-wallet-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to confirm"
/>
</div>
<button
id="btn-delete-wallet-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Confirm Delete
</button>
</div>
<!-- ============ SETTINGS: ADD TOKEN ============ -->
<div id="view-settings-addtoken" class="view hidden">
<button
id="btn-settings-addtoken-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Add Token</h2>
<p class="text-xs text-muted mb-3">
Pick a common token or enter a contract address manually.
</p>
<!-- top 10 quick-pick buttons -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Top tokens:</label
>
<div
id="settings-addtoken-top10"
class="flex flex-wrap gap-1"
></div>
</div>
<!-- top 100 dropdown -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Or pick from top 100:</label
>
<select
id="settings-addtoken-select"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
>
<option value="">-- select --</option>
</select>
<button
id="btn-settings-addtoken-select"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add selected
</button>
</div>
<!-- manual contract address -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Or enter contract address:</label
>
<input
type="text"
id="settings-addtoken-address"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
<div
id="settings-addtoken-info"
class="text-xs text-muted mt-1 hidden"
></div>
<button
id="btn-settings-addtoken-manual"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
</div>
</div>
<!-- ============ TRANSACTION DETAIL ============ -->
<div id="view-transaction" class="view hidden">
<button
@@ -832,7 +953,13 @@
>
&lt; Back
</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="text-xs text-muted mb-1">Status</div>
<div id="tx-detail-status" class="text-xs"></div>
@@ -857,6 +984,29 @@
<div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div>
</div>
<div id="tx-detail-calldata-section" class="mb-4 hidden">
<div
id="tx-detail-calldata-well"
class="mb-3 border border-border border-dashed p-2"
>
<div class="text-xs text-muted mb-1">Action</div>
<div
id="tx-detail-calldata-action"
class="text-xs font-bold mb-2"
></div>
<div
id="tx-detail-calldata-details"
class="text-xs"
></div>
</div>
<div id="tx-detail-rawdata-section" class="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 class="mb-4">
<div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="tx-detail-hash" class="text-xs break-all"></div>
@@ -933,6 +1083,17 @@
wants you to sign a message.
</p>
<div
id="approve-sign-danger-warning"
class="hidden mb-3 p-2 text-xs font-bold"
style="
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
border-radius: 6px;
"
></div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Type</div>
<div id="approve-sign-type" class="text-xs font-bold"></div>
+9 -1
View File
@@ -20,6 +20,7 @@ const transactionDetail = require("./views/transactionDetail");
const receive = require("./views/receive");
const addToken = require("./views/addToken");
const settings = require("./views/settings");
const settingsAddToken = require("./views/settingsAddToken");
const approval = require("./views/approval");
function renderWalletList() {
@@ -60,6 +61,8 @@ const ctx = {
showConfirmTx: (txInfo) => confirmTx.show(txInfo),
showReceive: () => receive.show(),
showTransactionDetail: (tx) => transactionDetail.show(tx),
showSettingsView: () => settings.show(),
showSettingsAddTokenView: () => settingsAddToken.show(),
};
// Views that can be fully re-rendered from persisted state.
@@ -70,6 +73,7 @@ const RESTORABLE_VIEWS = new Set([
"address-token",
"receive",
"settings",
"settings-addtoken",
"transaction",
"success-tx",
"error-tx",
@@ -120,6 +124,9 @@ function restoreView() {
case "settings":
settings.show();
break;
case "settings-addtoken":
settingsAddToken.show();
break;
case "transaction":
if (state.viewData && state.viewData.tx) {
transactionDetail.render();
@@ -182,7 +189,7 @@ async function init() {
const params = new URLSearchParams(window.location.search);
const approvalId = params.get("approval");
if (approvalId) {
approval.show(parseInt(approvalId, 10));
approval.show(approvalId);
showView("approve-site");
return;
}
@@ -212,6 +219,7 @@ async function init() {
receive.init(ctx);
addToken.init(ctx);
settings.init(ctx);
settingsAddToken.init(ctx);
if (!state.hasWallet) {
showView("welcome");
+2 -2
View File
@@ -49,8 +49,8 @@ function init(ctx) {
showFlash("Please choose a password.");
return;
}
if (pw.length < 8) {
showFlash("Password must be at least 8 characters.");
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return;
}
if (pw !== pw2) {
+14 -6
View File
@@ -4,6 +4,7 @@ const {
showFlash,
balanceLinesForAddress,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
} = require("./helpers");
@@ -150,11 +151,11 @@ async function loadTransactions(address) {
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 = [
...new Set(
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
),
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
];
if (counterparties.length > 0) {
try {
@@ -185,14 +186,21 @@ function renderTransactions(txs) {
let html = "";
let i = 0;
for (const tx of txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const counterparty =
tx.direction === "contract"
? tx.from
: tx.direction === "sent"
? tx.to
: tx.from;
const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets);
const dirLabel = tx.directionLabel;
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = ensName || truncateMiddle(counterparty, maxAddr);
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
+53 -5
View File
@@ -6,11 +6,13 @@ const {
showView,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
balanceLine,
} = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const {
formatUsd,
getPrice,
@@ -130,6 +132,43 @@ function show() {
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
// Token contract details (ERC-20 only)
const contractInfo = $("address-token-contract-info");
if (tokenId !== "ETH") {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
const tokenName = tb && tb.name ? escapeHtml(tb.name) : null;
const tokenSymbol = tb && tb.symbol ? escapeHtml(tb.symbol) : null;
const tokenDecimals = tb && tb.decimals != null ? tb.decimals : null;
const tokenHolders = tb && tb.holders != null ? tb.holders : null;
const dot = addressDotHtml(tokenId);
const tokenLink = `https://etherscan.io/token/${escapeHtml(tokenId)}`;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
const projectUrl = knownToken && knownToken.url ? knownToken.url : null;
let infoHtml = `<div class="font-bold mb-2">Contract Address</div>`;
infoHtml +=
`<div class="flex items-center mb-2">${dot}` +
`<span class="break-all underline decoration-dashed cursor-pointer" id="address-token-contract-copy" data-copy="${escapeHtml(tokenId)}">${escapeHtml(tokenId)}</span>` +
`<a href="${tokenLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
`</div>`;
if (tokenName)
infoHtml += `<div class="mb-1"><span class="text-muted">Name:</span> ${tokenName}</div>`;
if (tokenSymbol)
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
if (tokenDecimals != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
if (tokenHolders != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`;
if (projectUrl)
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
contractInfo.innerHTML = infoHtml;
contractInfo.classList.remove("hidden");
} else {
contractInfo.innerHTML = "";
contractInfo.classList.add("hidden");
}
// Transactions
$("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
@@ -175,11 +214,10 @@ async function loadTransactions(address, tokenId) {
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 = [
...new Set(
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
),
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
];
if (counterparties.length > 0) {
try {
@@ -212,12 +250,14 @@ function renderTransactions(txs) {
for (const tx of txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets);
const dirLabel = tx.directionLabel;
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = ensName || truncateMiddle(counterparty, maxAddr);
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
@@ -252,6 +292,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", () => {
ctx.showAddressDetail();
});
+31 -3
View File
@@ -1,4 +1,10 @@
const { $, addressDotHtml, escapeHtml, showView } = require("./helpers");
const {
$,
addressDotHtml,
addressTitle,
escapeHtml,
showView,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
const { ERC20_ABI } = require("../../shared/constants");
@@ -22,7 +28,15 @@ function approvalAddressHtml(address) {
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
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) {
@@ -294,6 +308,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-error").classList.add("hidden");
$("btn-approve-sign").disabled = false;
@@ -373,6 +399,7 @@ function init(ctx) {
type: "AUTISTMASK_TX_RESPONSE",
id: approvalId,
approved: true,
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
password: password,
},
(response) => {
@@ -412,6 +439,7 @@ function init(ctx) {
type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId,
approved: true,
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
password: password,
},
(response) => {
@@ -439,4 +467,4 @@ function init(ctx) {
});
}
module.exports = { init, show };
module.exports = { init, show, decodeCalldata };
+5
View File
@@ -334,8 +334,13 @@ function init(ctx) {
tx = await contract.transfer(pendingTx.to, amount);
}
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decryptedSecret = null;
txStatus.showWait(pendingTx, tx.hash);
} catch (e) {
decryptedSecret = null;
const hash = tx ? tx.hash : null;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
}
+90
View 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 };
+4
View File
@@ -8,6 +8,8 @@ const {
} = require("../../shared/prices");
const { state, saveState } = require("../../shared/state");
// When views are added, removed, or transitions between them change,
// update the view-navigation documentation in README.md to match.
const VIEWS = [
"welcome",
"add-wallet",
@@ -23,6 +25,8 @@ const VIEWS = [
"receive",
"add-token",
"settings",
"delete-wallet-confirm",
"settings-addtoken",
"transaction",
"approve-site",
"approve-tx",
+7 -2
View File
@@ -6,6 +6,7 @@ const {
isoDate,
timeAgo,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
} = require("./helpers");
@@ -102,13 +103,17 @@ function renderHomeTxList(ctx) {
let html = "";
let i = 0;
for (const tx of homeTxs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const counterparty =
tx.direction === "sent" || tx.direction === "contract"
? tx.to
: tx.from;
const dirLabel = tx.directionLabel;
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
const title = addressTitle(counterparty, state.wallets);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = truncateMiddle(counterparty, maxAddr);
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
+2 -2
View File
@@ -30,8 +30,8 @@ function init(ctx) {
showFlash("Please choose a password.");
return;
}
if (pw.length < 8) {
showFlash("Password must be at least 8 characters.");
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return;
}
if (pw !== pw2) {
+14 -1
View File
@@ -1,6 +1,12 @@
// 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");
let ctx;
const { getProvider } = require("../../shared/balances");
@@ -44,8 +50,15 @@ function updateSendBalance() {
const dot = addressDotHtml(addr.address);
const link = `https://etherscan.io/address/${addr.address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(addr.address, state.wallets);
let fromHtml = "";
if (title) {
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
if (addr.ensName) {
fromHtml += `<div>${escapeHtml(addr.ensName)}</div>`;
}
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else if (addr.ensName) {
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(addr.ensName)}</div>`;
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else {
+94 -1
View File
@@ -1,7 +1,8 @@
const { $, showView, showFlash } = require("./helpers");
const { $, showView, showFlash, escapeHtml } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
const { log, debugFetch } = require("../../shared/log");
const deleteWallet = require("./deleteWallet");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
@@ -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() {
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
renderSiteLists();
renderWalletListSettings();
showView("settings");
}
@@ -55,6 +141,8 @@ function renderSiteLists() {
}
function init(ctx) {
deleteWallet.init(ctx);
$("btn-save-rpc").addEventListener("click", async () => {
const url = $("settings-rpc").value.trim();
if (!url) {
@@ -155,6 +243,11 @@ function init(ctx) {
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
$("btn-settings-add-token").addEventListener(
"click",
ctx.showSettingsAddTokenView,
);
$("btn-settings-back").addEventListener("click", () => {
ctx.renderWalletList();
showView("main");
+159
View 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, "&quot;")}"` +
`${tracked ? " disabled" : ""}>${t.symbol}</button>`
);
})
.join("");
el.querySelectorAll(".settings-addtoken-quick:not([disabled])").forEach(
(btn) => {
btn.addEventListener("click", async () => {
const token = {
address: btn.dataset.address,
symbol: btn.dataset.symbol,
decimals: parseInt(btn.dataset.decimals, 10),
name: btn.dataset.name || btn.dataset.symbol,
};
state.trackedTokens.push(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
renderDropdown();
ctx.doRefreshAndRender();
});
},
);
}
function renderDropdown() {
const sel = $("settings-addtoken-select");
const tokens = getTopTokens(100);
let html = '<option value="">-- select --</option>';
for (const t of tokens) {
const tracked = isTracked(t.address);
const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
html +=
`<option value="${t.address}"` +
` data-symbol="${t.symbol}"` +
` data-decimals="${t.decimals}"` +
` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` +
`${tracked ? " disabled" : ""}>${label}</option>`;
}
sel.innerHTML = html;
}
function show() {
$("settings-addtoken-address").value = "";
$("settings-addtoken-info").classList.add("hidden");
renderTop10();
renderDropdown();
showView("settings-addtoken");
}
function init(_ctx) {
ctx = _ctx;
$("btn-settings-addtoken-back").addEventListener("click", () => {
ctx.showSettingsView();
});
$("btn-settings-addtoken-select").addEventListener("click", async () => {
const sel = $("settings-addtoken-select");
const opt = sel.options[sel.selectedIndex];
if (!opt || !opt.value) {
showFlash("Please select a token.");
return;
}
if (isTracked(opt.value)) {
showFlash("Already tracked.");
return;
}
const token = {
address: opt.value,
symbol: opt.dataset.symbol,
decimals: parseInt(opt.dataset.decimals, 10),
name: opt.dataset.name || opt.dataset.symbol,
};
state.trackedTokens.push(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
renderDropdown();
ctx.doRefreshAndRender();
});
$("btn-settings-addtoken-manual").addEventListener("click", async () => {
const addr = $("settings-addtoken-address").value.trim();
if (!addr || !addr.startsWith("0x")) {
showFlash(
"Please enter a valid contract address starting with 0x.",
);
return;
}
if (isTracked(addr)) {
showFlash("Already tracked.");
return;
}
if (isScamAddress(addr)) {
showFlash("This address is on a known scam/fraud list.");
return;
}
const infoEl = $("settings-addtoken-info");
infoEl.textContent = "Looking up token...";
infoEl.classList.remove("hidden");
log.debugf("Looking up token contract", addr);
try {
const info = await lookupTokenInfo(addr, state.rpcUrl);
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
address: addr,
symbol: info.symbol,
decimals: info.decimals,
name: info.name,
});
await saveState();
showFlash("Added " + info.symbol);
$("settings-addtoken-address").value = "";
infoEl.classList.add("hidden");
renderTop10();
renderDropdown();
ctx.doRefreshAndRender();
} catch (e) {
const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", addr, detail);
showFlash(detail);
infoEl.classList.add("hidden");
}
});
}
module.exports = { init, show };
+97 -3
View File
@@ -13,6 +13,8 @@ const {
} = require("./helpers");
const { state } = require("../../shared/state");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
@@ -42,11 +44,11 @@ function txAddressHtml(address, ensName, title) {
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
let html = `<div class="mb-1">${blockie}</div>`;
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
html += `<div class="font-bold">${escapeHtml(title)}</div>`;
}
if (ensName) {
html +=
`<div class="flex items-center">${title ? "" : dot}` +
`<div class="flex items-center">${dot}` +
copyableHtml(ensName, "") +
extLink +
`</div>` +
@@ -55,7 +57,7 @@ function txAddressHtml(address, ensName, title) {
`</div>`;
} else {
html +=
`<div class="flex items-center">${title ? "" : dot}` +
`<div class="flex items-center">${dot}` +
copyableHtml(address, "break-all") +
extLink +
`</div>`;
@@ -85,9 +87,15 @@ function show(tx) {
fromEns: tx.fromEns || null,
toEns: tx.toEns || null,
directionLabel: tx.directionLabel || null,
direction: tx.direction || null,
isContractCall: tx.isContractCall || false,
method: tx.method || null,
},
};
render();
if (tx.isContractCall || tx.direction === "contract") {
loadCalldata(tx.hash, tx.to);
}
}
function render() {
@@ -121,6 +129,25 @@ function render() {
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 section by default; loadCalldata will show it if needed
const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden");
$("tx-detail-time").textContent =
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
@@ -137,6 +164,73 @@ 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) {
const dot = addressDotHtml(d.address);
detailsHtml += `<div>${dot}${copyableHtml(d.value, "break-all")}</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
section.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
};
});
} catch (e) {
log.errorf("loadCalldata failed:", e.message);
}
}
function init(_ctx) {
ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => {
+8
View File
@@ -5,6 +5,7 @@ const {
showView,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
@@ -37,6 +38,13 @@ function toAddressHtml(address) {
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(address, state.wallets);
if (title) {
return (
`<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` +
`<div class="break-all">${escapeHtml(address)}${extLink}</div>`
);
}
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
}
+19 -3
View File
@@ -123,15 +123,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(
provider
.lookupAddress(addr.address)
.then((name) => {
addr.ensName = name || null;
log.debugf(
"ENS reverse",
addr.address,
"->",
addr.ensName,
);
})
.catch(() => {
addr.ensName = null;
.catch((e) => {
log.errorf(
"ENS reverse failed",
addr.address,
e.message,
);
// Keep existing addr.ensName if we had one
}),
);
@@ -192,6 +204,10 @@ async function lookupTokenInfo(contractAddress, rpcUrl) {
name = symbol;
}
// Truncate to prevent storage of excessively long values from RPC
name = String(name).slice(0, 64);
symbol = String(symbol).slice(0, 12);
log.infof("Token resolved:", symbol, "decimals", Number(decimals));
return { name, symbol, decimals: Number(decimals) };
}
+1 -1
View File
@@ -39,7 +39,7 @@ async function resolveEnsName(address, rpcUrl) {
return name;
} catch (e) {
log.errorf("ENS reverse lookup failed", address, e.message);
setCache(address, null);
// Don't cache failures — let subsequent lookups retry
return null;
}
}
File diff suppressed because it is too large Load Diff
+25 -2
View File
@@ -37,7 +37,21 @@ function parseTx(tx, addrLower) {
if (token) {
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";
directionLabel = label;
value = "";
@@ -139,9 +153,18 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
// When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. 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 || []) {
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);
}