Token auto-discovery, tx history, balance polling, EIP-6963, UI overhaul
All checks were successful
check / check (push) Successful in 14s

Major changes:
- Fetch token balances and tx history from Blockscout API (configurable)
- Remove manual token discovery (discoverTokens) in favor of Blockscout
- HD address gap scanning on mnemonic import
- Duplicate mnemonic detection on wallet add
- EIP-6963 multi-wallet discovery + selectedAddress updates in inpage
- Two-tier balance refresh: 10s while popup open, 60s background
- Fix $0.00 flash before prices load (return null when no prices)
- No-layout-shift: min-height on total value element
- Aligned balance columns (42ch address width, consistent USD column)
- All errors use flash messages instead of off-screen error divs
- Settings gear in global title bar, add-wallet moved to settings pane
- Settings wells with light grey background, configurable Blockscout URL
- Consistent "< Back" buttons top-left on all views
- Address titles (Address 1.1, 1.2, etc.) on main and detail views
- Send view shows current balance of selected asset
- Clickable affordance policy added to README
- Shortened mnemonic backup warning
- Fix broken background script constant imports
This commit is contained in:
Jeffrey Paul 2026-02-26 02:13:39 +07:00
parent 2b2137716c
commit 3bd2b58543
27 changed files with 978 additions and 420 deletions

View File

@ -97,6 +97,24 @@ crypto knowledge.
- **360x600 popup**: Standard browser extension popup dimensions. The UI is
designed for this fixed viewport — no responsive breakpoints needed.
#### No Layout Shift
Asynchronous state changes (clipboard confirmation, transaction status, error
messages, flash notifications) must never move existing UI elements. All dynamic
content areas reserve their space up front using `min-height` or always-present
wrapper elements. `visibility: hidden` is preferred over `display: none` when
the element's space must be preserved. This prevents jarring content jumps that
disorient users and avoids mis-clicks caused by shifting buttons.
#### Clickable Affordance
Every interactive element must visually indicate that it is clickable. Buttons
use a visible border, padding, and a hover state (invert to white-on-black).
Text that triggers an action (e.g. "Import private key") uses an underline. No
invisible hit targets, no bare text that happens to have a click handler. If it
does something when you click it, it must look like it does something when you
click it.
#### Language & Labeling
All user-facing text avoids crypto jargon wherever possible:
@ -509,6 +527,13 @@ Everything needed for a minimal working wallet that can send and receive ETH.
- [ ] Test on Chrome (Manifest V3)
- [ ] Test on Firefox (Manifest V2)
### Scam List
- [ ] Research and document each address in scamlist.js (what it is, why it's on
the list, source)
- [ ] Add more known fraud addresses from Etherscan labels (drainers, phishing,
address poisoning deployers)
### Post-MVP
- [ ] EIP-1193 provider injection (window.ethereum) for web3 site connectivity

View File

@ -4,6 +4,7 @@
"version": "0.1.0",
"description": "Minimal Ethereum wallet for Chrome",
"permissions": ["storage", "activeTab"],
"host_permissions": ["<all_urls>"],
"action": {
"default_popup": "src/popup/index.html"
},
@ -11,6 +12,12 @@
"service_worker": "src/background/index.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/content/inpage.js"],
"run_at": "document_start",
"world": "MAIN"
},
{
"matches": ["<all_urls>"],
"js": ["src/content/index.js"],

View File

@ -3,7 +3,7 @@
"name": "AutistMask",
"version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab"],
"permissions": ["storage", "activeTab", "<all_urls>"],
"browser_action": {
"default_popup": "src/popup/index.html"
},

View File

@ -2,8 +2,12 @@
// Handles EIP-1193 RPC requests from content scripts and proxies
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint.
const CHAIN_ID = "0x1";
const DEFAULT_RPC = "https://eth.llamarpc.com";
const {
ETHEREUM_MAINNET_CHAIN_ID,
DEFAULT_RPC_URL,
} = require("../shared/constants");
const { state, loadState, saveState } = require("../shared/state");
const { refreshBalances } = require("../shared/balances");
const storageApi =
typeof browser !== "undefined"
@ -17,7 +21,7 @@ const connectedSites = {};
async function getState() {
const result = await storageApi.get("autistmask");
return result.autistmask || { wallets: [], rpcUrl: DEFAULT_RPC };
return result.autistmask || { wallets: [], rpcUrl: DEFAULT_RPC_URL };
}
async function getAccounts() {
@ -33,7 +37,7 @@ async function getAccounts() {
async function getRpcUrl() {
const state = await getState();
return state.rpcUrl || DEFAULT_RPC;
return state.rpcUrl || DEFAULT_RPC_URL;
}
// Proxy an RPC call to the Ethereum node
@ -94,7 +98,7 @@ async function handleRpc(method, params, origin) {
}
if (method === "eth_chainId") {
return { result: CHAIN_ID };
return { result: ETHEREUM_MAINNET_CHAIN_ID };
}
if (method === "net_version") {
@ -103,7 +107,7 @@ async function handleRpc(method, params, origin) {
if (method === "wallet_switchEthereumChain") {
const chainId = params?.[0]?.chainId;
if (chainId === CHAIN_ID) {
if (chainId === ETHEREUM_MAINNET_CHAIN_ID) {
return { result: null };
}
return {
@ -205,6 +209,24 @@ async function handleRpc(method, params, origin) {
return { error: { message: "Unsupported method: " + method } };
}
// Background balance refresh: every 60 seconds when the popup isn't open.
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
// fresh, so this naturally skips.
const BACKGROUND_REFRESH_INTERVAL = 60000;
async function backgroundRefresh() {
await loadState();
const now = Date.now();
if (now - (state.lastBalanceRefresh || 0) < BACKGROUND_REFRESH_INTERVAL)
return;
if (state.wallets.length === 0) return;
await refreshBalances(state.wallets, state.rpcUrl, state.blockscoutUrl);
state.lastBalanceRefresh = now;
await saveState();
}
setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL);
// Listen for messages from content scripts
runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type !== "AUTISTMASK_RPC") return;

View File

@ -1,15 +1,17 @@
// AutistMask content script — bridges between inpage (window.ethereum)
// and the background service worker via extension messaging.
// Inject the inpage script into the page's JS context
const script = document.createElement("script");
script.src = (typeof browser !== "undefined" ? browser : chrome).runtime.getURL(
"src/content/inpage.js",
);
script.onload = function () {
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
// in the manifest, so no injection is needed here. In Firefox (MV2), the
// "world" key is not supported, so we inject via a <script> tag.
if (typeof browser !== "undefined") {
const script = document.createElement("script");
script.src = browser.runtime.getURL("src/content/inpage.js");
script.onload = function () {
this.remove();
};
(document.head || document.documentElement).appendChild(script);
};
(document.head || document.documentElement).appendChild(script);
}
// Relay requests from the page to the background script
window.addEventListener("message", (event) => {

View File

@ -1,9 +1,7 @@
// AutistMask inpage script — injected into the page's JS context.
// Creates window.ethereum (EIP-1193 provider).
// Creates window.ethereum (EIP-1193 provider) and announces via EIP-6963.
(function () {
if (typeof window.ethereum !== "undefined") return;
const CHAIN_ID = "0x1"; // Ethereum mainnet
const listeners = {};
@ -45,7 +43,7 @@
}
}
function request(args) {
function sendRequest(args) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending[id] = { resolve, reject };
@ -63,8 +61,21 @@
networkVersion: "1",
selectedAddress: null,
request(args) {
return request({ method: args.method, params: args.params || [] });
async request(args) {
const result = await sendRequest({
method: args.method,
params: args.params || [],
});
if (
args.method === "eth_requestAccounts" ||
args.method === "eth_accounts"
) {
provider.selectedAddress =
Array.isArray(result) && result.length > 0
? result[0]
: null;
}
return result;
},
// Legacy methods (still used by some dApps)
@ -119,10 +130,46 @@
}
return this;
},
// Some dApps (wagmi) check this to confirm MetaMask-like behavior
_metamask: {
isUnlocked() {
return Promise.resolve(true);
},
},
};
// Set window.ethereum if no other wallet has claimed it
if (typeof window.ethereum === "undefined") {
window.ethereum = provider;
// Announce via EIP-6963 (multi-wallet discovery)
}
window.dispatchEvent(new Event("ethereum#initialized"));
// EIP-6963: Multi Injected Provider Discovery
const ICON_SVG =
"data:image/svg+xml," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">' +
'<rect width="32" height="32" rx="6" fill="#000"/>' +
'<text x="16" y="23" text-anchor="middle" font-family="monospace" font-size="20" font-weight="bold" fill="#fff">A</text>' +
"</svg>",
);
const providerInfo = {
uuid: "f3c5b2a1-8d4e-4f6a-9c7b-1e2d3a4b5c6d",
name: "AutistMask",
icon: ICON_SVG,
rdns: "berlin.sneak.autistmask",
};
function announceProvider() {
window.dispatchEvent(
new CustomEvent("eip6963:announceProvider", {
detail: Object.freeze({ info: providerInfo, provider }),
}),
);
}
window.addEventListener("eip6963:requestProvider", announceProvider);
announceProvider();
})();

View File

@ -21,8 +21,21 @@
>@sneak</a
>
</h1>
<button
id="btn-settings"
class="bg-transparent border-none text-fg cursor-pointer text-2xl p-0 leading-none"
title="Settings"
>
&#9881;
</button>
</div>
<!-- ============ FLASH MESSAGE AREA ============ -->
<div
id="flash-msg"
class="text-xs text-muted min-h-[1.25rem] mb-1"
></div>
<!-- ============ WELCOME / FIRST USE ============ -->
<div id="view-welcome" class="view hidden">
<p class="mb-3">Welcome! To get started, add a wallet.</p>
@ -36,6 +49,12 @@
<!-- ============ ADD WALLET (unified create/import) ============ -->
<div id="view-add-wallet" class="view hidden">
<button
id="btn-add-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-2">Add Wallet</h2>
<p class="mb-2">
Enter your 12 or 24 word recovery phrase below, or click the
@ -62,10 +81,8 @@
id="add-wallet-phrase-warning"
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
>
These words are your recovery phrase. Write them down on
paper and keep them somewhere safe. Anyone with these words
can access your funds. If you lose them, your wallet cannot
be recovered.
Write these words down and keep them safe. Anyone with them
can take your funds; if you lose them, your wallet is gone.
</div>
<div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label>
@ -87,24 +104,12 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="flex gap-2">
<button
id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
<button
id="btn-add-wallet-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Back
</button>
</div>
<div
id="add-wallet-error"
class="mt-2 border border-border border-dashed p-1 hidden"
></div>
<div class="mt-3 text-xs text-muted">
Have a private key instead?
<button
@ -118,6 +123,12 @@
<!-- ============ IMPORT PRIVATE KEY ============ -->
<div id="view-import-key" class="view hidden">
<button
id="btn-import-key-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-2">Import Private Key</h2>
<p class="mb-2">
Paste your private key below. This wallet will have a single
@ -151,84 +162,58 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="flex gap-2">
<button
id="btn-import-key-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Import
</button>
<button
id="btn-import-key-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Back
</button>
</div>
<div
id="import-key-error"
class="mt-2 border border-border border-dashed p-1 hidden"
></div>
</div>
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
<div id="view-main" class="view hidden">
<!-- total portfolio value -->
<div id="total-value" class="text-2xl font-bold mb-2"></div>
<div
id="total-value"
class="text-2xl font-bold mb-2 min-h-[2rem]"
></div>
<!-- wallet list -->
<div id="wallet-list"></div>
<div class="mt-3 border-t border-border pt-2 flex gap-2">
<button
id="btn-main-add-wallet"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
+ Add wallet
</button>
<button
id="btn-settings"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Settings
</button>
</div>
</div>
<!-- ============ ADDRESS DETAIL VIEW ============ -->
<div id="view-address" class="view hidden">
<button
id="btn-address-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<div
class="flex justify-between items-center border-b border-border pb-1 mb-2"
>
<h2 class="font-bold" id="address-title">Address</h2>
<button
id="btn-address-back"
class="border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Back
</button>
</div>
<div id="address-ens" class="font-bold mb-1 hidden"></div>
<div
id="address-full"
class="text-xs break-all mb-1 cursor-pointer"
class="flex text-xs mb-3 cursor-pointer"
title="Click to copy"
></div>
<div
class="text-xs text-muted mb-3"
id="address-copied-msg"
></div>
<!-- balance -->
<div class="border-b border-border-light pb-2 mb-2">
<div class="text-base font-bold">
<span id="address-eth-balance">0.0000</span> ETH
>
<span
id="address-full"
class="shrink-0"
style="width: 42ch"
></span>
<span
id="address-usd-total"
class="text-right text-muted flex-1"
></span>
</div>
<div
class="text-xs text-muted"
id="address-usd-value"
></div>
<!-- balances -->
<div class="border-b border-border-light pb-2 mb-2">
<div id="address-balances"></div>
</div>
<!-- actions -->
@ -245,31 +230,33 @@
>
Receive
</button>
</div>
<!-- tokens -->
<div>
<div
class="flex justify-between items-center border-b border-border pb-1 mb-1"
>
<h2 class="font-bold">Tokens</h2>
<button
id="btn-add-token"
class="border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer flex-1"
>
+ Add
+ Token
</button>
</div>
<div id="token-list">
<div class="text-muted text-xs py-1">
No tokens added yet. Use "+ Add" to track a token.
<!-- transactions -->
<div class="mt-3">
<div class="border-b border-border pb-1 mb-1">
<h2 class="font-bold">Transactions</h2>
</div>
<div id="tx-list">
<div class="text-muted text-xs py-1">Loading...</div>
</div>
</div>
</div>
<!-- ============ SEND ============ -->
<div id="view-send" class="view hidden">
<button
id="btn-send-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">Send</h2>
<div class="mb-2">
<label class="block mb-1">What to send</label>
@ -290,7 +277,13 @@
/>
</div>
<div class="mb-2">
<label class="block mb-1">Amount</label>
<div class="flex justify-between mb-1">
<label>Amount</label>
<span
id="send-balance"
class="text-xs text-muted"
></span>
</div>
<input
type="text"
id="send-amount"
@ -298,28 +291,22 @@
placeholder="0.0"
/>
</div>
<div class="flex gap-2">
<button
id="btn-send-review"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Review
</button>
<button
id="btn-send-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Cancel
</button>
</div>
<div
id="send-error"
class="mt-2 border border-border border-dashed p-1 hidden"
></div>
</div>
<!-- ============ CONFIRM TRANSACTION ============ -->
<div id="view-confirm-tx" class="view hidden">
<button
id="btn-confirm-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">Confirm Transaction</h2>
<div class="mb-2">
<div class="text-xs text-muted">From</div>
@ -350,20 +337,12 @@
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 hidden"
></div>
<div class="flex gap-2">
<button
id="btn-confirm-send"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Send
</button>
<button
id="btn-confirm-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Back
</button>
</div>
<div
id="confirm-status"
class="mt-2 border border-border p-1 hidden"
@ -408,6 +387,12 @@
<!-- ============ RECEIVE ============ -->
<div id="view-receive" class="view hidden">
<button
id="btn-receive-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">Receive</h2>
<p class="mb-2">
Share this address with the sender. Make sure you only use
@ -420,24 +405,22 @@
id="receive-address"
class="border border-border p-2 break-all select-all mb-3"
></div>
<div class="flex gap-2">
<button
id="btn-receive-copy"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Copy address
</button>
<button
id="btn-receive-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Back
</button>
</div>
</div>
<!-- ============ ADD TOKEN ============ -->
<div id="view-add-token" class="view hidden">
<button
id="btn-add-token-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="mb-2">
Enter the contract address of the token you want to track.
@ -465,36 +448,44 @@
class="flex flex-wrap gap-1"
></div>
</div>
<div class="flex gap-2">
<button
id="btn-add-token-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
<button
id="btn-add-token-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Cancel
</button>
</div>
<div
id="add-token-error"
class="mt-2 border border-border border-dashed p-1 hidden"
></div>
</div>
<!-- ============ SETTINGS ============ -->
<div id="view-settings" class="view hidden">
<h2 class="font-bold mb-2">Settings</h2>
<button
id="btn-settings-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">Settings</h2>
<h2 class="font-bold mb-1">Network</h2>
<p class="text-xs text-muted mb-1">
The server used to talk to the Ethereum network. Change this
if you run your own node or prefer a different provider.
<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>
<button
id="btn-main-add-wallet"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
+ Add wallet
</button>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Ethereum RPC</h3>
<p class="text-xs text-muted mb-1">
The server used to talk to the Ethereum network. Change
this if you run your own node or prefer a different
provider.
</p>
<div class="mb-3">
<input
type="text"
id="settings-rpc"
@ -508,12 +499,22 @@
</button>
</div>
<div class="border-t border-border pt-2">
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Blockscout API</h3>
<p class="text-xs text-muted mb-1">
Used to fetch token balances and transaction history.
Change this if you run your own Blockscout instance.
</p>
<input
type="text"
id="settings-blockscout"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
<button
id="btn-settings-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
id="btn-save-blockscout"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Back
Save
</button>
</div>
</div>

View File

@ -1,11 +1,11 @@
// AutistMask popup entry point.
// Loads state, initializes views, triggers first render.
const { DEBUG } = require("../shared/wallet");
const { DEBUG } = require("../shared/constants");
const { state, saveState, loadState } = require("../shared/state");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const { showView } = require("./views/helpers");
const { $, showView } = require("./views/helpers");
const home = require("./views/home");
const welcome = require("./views/welcome");
@ -23,13 +23,22 @@ function renderWalletList() {
home.render(ctx);
}
let refreshInFlight = false;
async function doRefreshAndRender() {
if (refreshInFlight) return;
refreshInFlight = true;
try {
await Promise.all([
refreshPrices(),
refreshBalances(state.wallets, state.trackedTokens, state.rpcUrl),
refreshBalances(state.wallets, state.rpcUrl, state.blockscoutUrl),
]);
state.lastBalanceRefresh = Date.now();
await saveState();
renderWalletList();
} finally {
refreshInFlight = false;
}
}
const ctx = {
@ -54,6 +63,12 @@ async function init() {
await loadState();
$("btn-settings").addEventListener("click", () => {
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
showView("settings");
});
welcome.init(ctx);
addWallet.init(ctx);
importKey.init(ctx);
@ -72,6 +87,7 @@ async function init() {
renderWalletList();
showView("main");
doRefreshAndRender();
setInterval(doRefreshAndRender, 10000);
}
}

View File

@ -10,6 +10,7 @@
--color-border: #000000;
--color-border-light: #cccccc;
--color-hover: #eeeeee;
--color-well: #f5f5f5;
}
body {

View File

@ -1,16 +1,13 @@
const { $, showError, hideError, showView } = require("./helpers");
const { $, showView, showFlash } = require("./helpers");
const { TOKENS } = require("../../shared/tokens");
const { state, saveState } = require("../../shared/state");
const {
lookupTokenInfo,
invalidateBalanceCache,
refreshBalances,
} = require("../../shared/balances");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log");
function show() {
$("add-token-address").value = "";
$("add-token-info").classList.add("hidden");
hideError("add-token-error");
const list = $("common-token-list");
list.innerHTML = TOKENS.slice(0, 25)
.map(
@ -30,8 +27,7 @@ function init(ctx) {
$("btn-add-token-confirm").addEventListener("click", async () => {
const contractAddr = $("add-token-address").value.trim();
if (!contractAddr || !contractAddr.startsWith("0x")) {
showError(
"add-token-error",
showFlash(
"Please enter a valid contract address starting with 0x.",
);
return;
@ -40,18 +36,20 @@ function init(ctx) {
(t) => t.address.toLowerCase() === contractAddr.toLowerCase(),
);
if (already) {
showError(
"add-token-error",
already.symbol + " is already being tracked.",
);
showFlash(already.symbol + " is already being tracked.");
return;
}
if (isScamAddress(contractAddr)) {
showFlash("This address is on a known scam/fraud list.");
return;
}
hideError("add-token-error");
const infoEl = $("add-token-info");
infoEl.textContent = "Looking up token...";
infoEl.classList.remove("hidden");
log.debugf("Looking up token contract", contractAddr);
try {
const info = await lookupTokenInfo(contractAddr, state.rpcUrl);
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
address: contractAddr,
symbol: info.symbol,
@ -59,19 +57,12 @@ function init(ctx) {
name: info.name,
});
await saveState();
invalidateBalanceCache();
await refreshBalances(
state.wallets,
state.trackedTokens,
state.rpcUrl,
);
await saveState();
ctx.doRefreshAndRender();
ctx.showAddressDetail();
} catch (e) {
showError(
"add-token-error",
"Could not read token contract. Check the address.",
);
const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", contractAddr, detail);
showFlash(detail);
infoEl.classList.add("hidden");
}
});

View File

@ -1,4 +1,4 @@
const { $, showError, hideError, showView } = require("./helpers");
const { $, showView, showFlash } = require("./helpers");
const {
generateMnemonic,
hdWalletFromMnemonic,
@ -6,13 +6,13 @@ const {
} = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances");
function show() {
$("wallet-mnemonic").value = "";
$("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").classList.add("hidden");
hideError("add-wallet-error");
showView("add-wallet");
}
@ -25,16 +25,14 @@ function init(ctx) {
$("btn-add-wallet-confirm").addEventListener("click", async () => {
const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) {
showError(
"add-wallet-error",
"Please enter a recovery phrase or press the die to generate one.",
showFlash(
"Enter a recovery phrase or press the die to generate one.",
);
return;
}
const words = mnemonic.split(/\s+/);
if (words.length !== 12 && words.length !== 24) {
showError(
"add-wallet-error",
showFlash(
"Recovery phrase must be 12 or 24 words. You entered " +
words.length +
".",
@ -42,34 +40,42 @@ function init(ctx) {
return;
}
if (!isValidMnemonic(mnemonic)) {
showError(
"add-wallet-error",
"Invalid recovery phrase. Please check for typos.",
);
showFlash("Invalid recovery phrase. Check for typos.");
return;
}
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showError("add-wallet-error", "Please choose a password.");
showFlash("Please choose a password.");
return;
}
if (pw.length < 8) {
showError(
"add-wallet-error",
"Password must be at least 8 characters.",
);
showFlash("Password must be at least 8 characters.");
return;
}
if (pw !== pw2) {
showError("add-wallet-error", "Passwords do not match.");
showFlash("Passwords do not match.");
return;
}
hideError("add-wallet-error");
const encrypted = await encryptWithPassword(mnemonic, pw);
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const duplicate = state.wallets.find(
(w) =>
w.type === "hd" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() ===
firstAddress.toLowerCase(),
);
if (duplicate) {
showFlash(
"This recovery phrase is already added (" +
duplicate.name +
").",
);
return;
}
const encrypted = await encryptWithPassword(mnemonic, pw);
const walletNum = state.wallets.length + 1;
state.wallets.push({
const wallet = {
type: "hd",
name: "Wallet " + walletNum,
xpub: xpub,
@ -78,11 +84,28 @@ function init(ctx) {
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();
}
ctx.doRefreshAndRender();
});
$("btn-add-wallet-back").addEventListener("click", () => {

View File

@ -1,16 +1,20 @@
const { $, showView } = require("./helpers");
const { $, showView, showFlash, balanceLinesForAddress } = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
const { fetchRecentTransactions } = require("../../shared/transactions");
const { updateSendBalance } = require("./send");
const { log } = require("../../shared/log");
const QRCode = require("qrcode");
function show() {
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];
$("address-title").textContent = wallet.name;
const wi = state.selectedWallet;
const ai = state.selectedAddress;
$("address-title").textContent =
wallet.name + " \u2014 Address " + (wi + 1) + "." + (ai + 1);
$("address-full").textContent = addr.address;
$("address-copied-msg").textContent = "";
$("address-eth-balance").textContent = addr.balance;
$("address-usd-value").textContent = formatUsd(getAddressValueUsd(addr));
$("address-usd-total").textContent = formatUsd(getAddressValueUsd(addr));
const ensEl = $("address-ens");
if (addr.ensName) {
ensEl.textContent = addr.ensName;
@ -18,28 +22,71 @@ function show() {
} else {
ensEl.classList.add("hidden");
}
renderTokenList(addr);
$("address-balances").innerHTML = balanceLinesForAddress(addr);
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
function renderTokenList(addr) {
const list = $("token-list");
const balances = addr.tokenBalances || [];
if (balances.length === 0 && state.trackedTokens.length === 0) {
function formatDate(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())
);
}
function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
async function loadTransactions(address) {
try {
const txs = await fetchRecentTransactions(address, state.blockscoutUrl);
renderTransactions(txs, address);
} catch (e) {
log.errorf("loadTransactions failed:", e.message);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
}
}
function renderTransactions(txs, address) {
const list = $("tx-list");
if (txs.length === 0) {
list.innerHTML =
'<div class="text-muted text-xs py-1">No tokens added yet. Use "+ Add" to track a token.</div>';
'<div class="text-muted text-xs py-1">No transactions found.</div>';
return;
}
list.innerHTML = balances
.map(
(t) =>
`<div class="py-1 border-b border-border-light flex justify-between">` +
`<span>${t.symbol}</span>` +
`<span>${t.balance || "0"}</span>` +
`</div>`,
)
.join("");
const addrLower = address.toLowerCase();
let html = "";
for (const tx of txs) {
const arrow = tx.direction === "sent" ? "\u2192" : "\u2190";
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const label = tx.direction === "sent" ? "to" : "from";
const errorClass = tx.isError ? ' style="opacity:0.5"' : "";
const errorTag = tx.isError
? ' <span class="text-muted">[failed]</span>'
: "";
html += `<div class="py-1 border-b border-border-light text-xs"${errorClass}>`;
html += `<div>${formatDate(tx.timestamp)} ${arrow} ${escapeHtml(tx.value)} ${escapeHtml(tx.symbol)}${errorTag}</div>`;
html += `<div class="text-muted break-all">${label}: ${escapeHtml(counterparty)}</div>`;
html += `<div class="break-all"><a href="https://etherscan.io/tx/${escapeHtml(tx.hash)}" target="_blank" class="underline decoration-dashed">${escapeHtml(tx.hash)}</a></div>`;
html += `</div>`;
}
list.innerHTML = html;
}
function renderSendTokenSelect(addr) {
@ -58,10 +105,7 @@ function init(ctx) {
const addr = $("address-full").textContent;
if (addr) {
navigator.clipboard.writeText(addr);
$("address-copied-msg").textContent = "Copied!";
setTimeout(() => {
$("address-copied-msg").textContent = "";
}, 2000);
showFlash("Copied!");
}
});
@ -71,11 +115,17 @@ function init(ctx) {
});
$("btn-send").addEventListener("click", () => {
const addr =
state.wallets[state.selectedWallet].addresses[
state.selectedAddress
];
if (!addr.balance || parseFloat(addr.balance) === 0) {
showFlash("Cannot send \u2014 zero balance.");
return;
}
$("send-to").value = "";
$("send-amount").value = "";
$("send-password").value = "";
$("send-fee-estimate").classList.add("hidden");
$("send-status").classList.add("hidden");
updateSendBalance();
showView("send");
});

View File

@ -8,10 +8,7 @@ const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
const {
getProvider,
invalidateBalanceCache,
} = require("../../shared/balances");
const { getProvider } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
let pendingTx = null;
@ -163,12 +160,19 @@ function init(ctx) {
});
statusEl.textContent = "Sent. Waiting for confirmation...";
const receipt = await tx.wait();
statusEl.textContent =
"Confirmed in block " +
receipt.blockNumber +
". Tx: " +
receipt.hash;
invalidateBalanceCache();
statusEl.innerHTML = "";
statusEl.appendChild(
document.createTextNode(
"Confirmed in block " + receipt.blockNumber + ". Tx: ",
),
);
const link = document.createElement("a");
link.href = "https://etherscan.io/tx/" + receipt.hash;
link.target = "_blank";
link.rel = "noopener";
link.className = "underline decoration-dashed break-all";
link.textContent = receipt.hash;
statusEl.appendChild(link);
ctx.doRefreshAndRender();
} catch (e) {
statusEl.textContent = "Failed: " + (e.shortMessage || e.message);

View File

@ -1,6 +1,11 @@
// Shared DOM helpers used by all views.
const { DEBUG } = require("../../shared/wallet");
const { DEBUG } = require("../../shared/constants");
const {
formatUsd,
getPrice,
getAddressValueUsd,
} = require("../../shared/prices");
const VIEWS = [
"welcome",
@ -37,6 +42,7 @@ function showView(name) {
el.classList.toggle("hidden", v !== name);
}
}
clearFlash();
if (DEBUG) {
const banner = document.getElementById("debug-banner");
if (banner) {
@ -45,4 +51,58 @@ function showView(name) {
}
}
module.exports = { $, showError, hideError, showView };
let flashTimer = null;
function clearFlash() {
if (flashTimer) {
clearTimeout(flashTimer);
flashTimer = null;
}
$("flash-msg").textContent = "";
}
function showFlash(msg, duration = 2000) {
clearFlash();
$("flash-msg").textContent = msg;
flashTimer = setTimeout(() => {
$("flash-msg").textContent = "";
flashTimer = null;
}, duration);
}
function balanceLine(symbol, amount, price) {
const qty = amount.toFixed(4);
const usd = price ? formatUsd(amount * price) : "";
return (
`<div class="flex text-xs">` +
`<span class="flex justify-between shrink-0" style="width:42ch">` +
`<span>${symbol}</span>` +
`<span>${qty}</span>` +
`</span>` +
`<span class="text-right text-muted flex-1">${usd}</span>` +
`</div>`
);
}
function balanceLinesForAddress(addr) {
let html = balanceLine(
"ETH",
parseFloat(addr.balance || "0"),
getPrice("ETH"),
);
for (const t of addr.tokenBalances || []) {
const bal = parseFloat(t.balance || "0");
if (bal === 0) continue;
html += balanceLine(t.symbol, bal, getPrice(t.symbol));
}
return html;
}
module.exports = {
$,
showError,
hideError,
showView,
showFlash,
balanceLinesForAddress,
};

View File

@ -1,4 +1,4 @@
const { $, showView } = require("./helpers");
const { $, showView, showFlash, balanceLinesForAddress } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { deriveAddressFromXpub } = require("../../shared/wallet");
const {
@ -33,15 +33,17 @@ function render(ctx) {
html += `</div>`;
wallet.addresses.forEach((addr, ai) => {
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover px-1" data-wallet="${wi}" data-address="${ai}">`;
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
html += `<div class="text-xs font-bold">Address ${wi + 1}.${ai + 1}</div>`;
if (addr.ensName) {
html += `<div class="text-xs font-bold">${addr.ensName}</div>`;
}
html += `<div class="text-xs break-all">${addr.address}</div>`;
html += `<div class="flex justify-between items-center">`;
html += `<span class="text-xs">${addr.balance} ETH</span>`;
html += `<span class="text-xs text-muted">${formatUsd(getAddressValueUsd(addr))}</span>`;
const addrUsd = formatUsd(getAddressValueUsd(addr));
html += `<div class="flex text-xs">`;
html += `<span class="shrink-0" style="width:42ch">${addr.address}</span>`;
html += `<span class="text-right text-muted flex-1">${addrUsd}</span>`;
html += `</div>`;
html += balanceLinesForAddress(addr);
html += `</div>`;
});
@ -62,27 +64,25 @@ function render(ctx) {
e.stopPropagation();
const wi = parseInt(btn.dataset.wallet, 10);
const wallet = state.wallets[wi];
const newAddr = deriveAddressFromXpub(
wallet.xpub,
wallet.nextIndex,
);
wallet.addresses.push({
address: deriveAddressFromXpub(wallet.xpub, wallet.nextIndex),
address: newAddr,
balance: "0.0000",
tokenBalances: [],
});
wallet.nextIndex++;
await saveState();
render(ctx);
ctx.doRefreshAndRender();
});
});
renderTotalValue();
}
function init(ctx) {
$("btn-settings").addEventListener("click", () => {
$("settings-rpc").value = state.rpcUrl;
showView("settings");
});
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
}
function init(ctx) {}
module.exports = { init, render };

View File

@ -1,4 +1,4 @@
const { $, showError, hideError, showView } = require("./helpers");
const { $, showView, showFlash } = require("./helpers");
const { addressFromPrivateKey } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
@ -7,7 +7,6 @@ function show() {
$("import-private-key").value = "";
$("import-key-password").value = "";
$("import-key-password-confirm").value = "";
hideError("import-key-error");
showView("import-key");
}
@ -15,34 +14,30 @@ function init(ctx) {
$("btn-import-key-confirm").addEventListener("click", async () => {
const key = $("import-private-key").value.trim();
if (!key) {
showError("import-key-error", "Please enter your private key.");
showFlash("Please enter your private key.");
return;
}
let addr;
try {
addr = addressFromPrivateKey(key);
} catch (e) {
showError("import-key-error", "Invalid private key.");
showFlash("Invalid private key.");
return;
}
const pw = $("import-key-password").value;
const pw2 = $("import-key-password-confirm").value;
if (!pw) {
showError("import-key-error", "Please choose a password.");
showFlash("Please choose a password.");
return;
}
if (pw.length < 8) {
showError(
"import-key-error",
"Password must be at least 8 characters.",
);
showFlash("Password must be at least 8 characters.");
return;
}
if (pw !== pw2) {
showError("import-key-error", "Passwords do not match.");
showFlash("Passwords do not match.");
return;
}
hideError("import-key-error");
const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1;
state.wallets.push({
@ -57,6 +52,8 @@ function init(ctx) {
await saveState();
ctx.renderWalletList();
showView("main");
ctx.doRefreshAndRender();
});
$("btn-import-key-back").addEventListener("click", () => {

View File

@ -1,9 +1,12 @@
const { $ } = require("./helpers");
const { $, showFlash } = require("./helpers");
function init(ctx) {
$("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address").textContent;
if (addr) navigator.clipboard.writeText(addr);
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
}
});
$("btn-receive-back").addEventListener("click", ctx.showAddressDetail);

View File

@ -1,22 +1,41 @@
// Send view: collect To, Amount, Token. Then go to confirmation.
const { $, showError, hideError } = require("./helpers");
const { $, showFlash } = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
function updateSendBalance() {
const addr = currentAddress();
if (!addr) return;
const token = $("send-token").value;
if (token === "ETH") {
$("send-balance").textContent =
"Current balance: " + (addr.balance || "0") + " ETH";
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const symbol = tb ? tb.symbol : "?";
const bal = tb ? tb.balance || "0" : "0";
$("send-balance").textContent =
"Current balance: " + bal + " " + symbol;
}
}
function init(ctx) {
$("send-token").addEventListener("change", updateSendBalance);
$("btn-send-review").addEventListener("click", async () => {
const to = $("send-to").value.trim();
const amount = $("send-amount").value.trim();
if (!to) {
showError("send-error", "Please enter a recipient address.");
showFlash("Please enter a recipient address.");
return;
}
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
showError("send-error", "Please enter a valid amount.");
showFlash("Please enter a valid amount.");
return;
}
hideError("send-error");
// Resolve ENS if needed
let resolvedTo = to;
@ -26,13 +45,13 @@ function init(ctx) {
const provider = getProvider(state.rpcUrl);
const resolved = await provider.resolveName(to);
if (!resolved) {
showError("send-error", "Could not resolve " + to);
showFlash("Could not resolve " + to);
return;
}
resolvedTo = resolved;
ensName = to;
} catch (e) {
showError("send-error", "Failed to resolve ENS name.");
showFlash("Failed to resolve ENS name.");
return;
}
}
@ -53,4 +72,4 @@ function init(ctx) {
$("btn-send-back").addEventListener("click", ctx.showAddressDetail);
}
module.exports = { init };
module.exports = { init, updateSendBalance };

View File

@ -1,11 +1,75 @@
const { $, showView } = require("./helpers");
const { $, showView, showFlash } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
const { log } = require("../../shared/log");
function init(ctx) {
$("btn-save-rpc").addEventListener("click", async () => {
state.rpcUrl = $("settings-rpc").value.trim();
await saveState();
const url = $("settings-rpc").value.trim();
if (!url) {
showFlash("Please enter an RPC URL.");
return;
}
showFlash("Testing endpoint...");
try {
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_chainId",
params: [],
}),
});
const json = await resp.json();
if (json.error) {
log.errorf("RPC validation error:", json.error);
showFlash("Endpoint returned error: " + json.error.message);
return;
}
if (json.result !== ETHEREUM_MAINNET_CHAIN_ID) {
showFlash(
"Wrong network (expected mainnet, got chain " +
json.result +
").",
);
return;
}
} catch (e) {
log.errorf("RPC validation fetch failed:", e.message);
showFlash("Could not reach endpoint.");
return;
}
state.rpcUrl = url;
await saveState();
showFlash("Saved.");
});
$("btn-save-blockscout").addEventListener("click", async () => {
const url = $("settings-blockscout").value.trim();
if (!url) {
showFlash("Please enter a Blockscout API URL.");
return;
}
showFlash("Testing endpoint...");
try {
const resp = await fetch(url + "/stats");
if (!resp.ok) {
showFlash("Endpoint returned HTTP " + resp.status + ".");
return;
}
} catch (e) {
log.errorf("Blockscout validation failed:", e.message);
showFlash("Could not reach endpoint.");
return;
}
state.blockscoutUrl = url;
await saveState();
showFlash("Saved.");
});
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
$("btn-settings-back").addEventListener("click", () => {
ctx.renderWalletList();

View File

@ -1,19 +1,23 @@
// Balance fetching: ETH balances, ERC-20 token balances, ENS reverse lookup.
// Cached for 60 seconds.
// Balance fetching: ETH balances via RPC, ERC-20 token balances via
// Blockscout, ENS reverse lookup via RPC.
const {
JsonRpcProvider,
Network,
Contract,
formatEther,
formatUnits,
} = require("ethers");
const { ERC20_ABI } = require("./constants");
const { log } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const BALANCE_CACHE_TTL = 60000; // 60 seconds
let lastFetchedAt = 0;
// Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare).
const mainnet = Network.from("mainnet");
function getProvider(rpcUrl) {
return new JsonRpcProvider(rpcUrl);
return new JsonRpcProvider(rpcUrl, mainnet, { staticNetwork: mainnet });
}
function formatBalance(wei) {
@ -32,11 +36,42 @@ function formatTokenBalance(raw, decimals) {
return parts[0] + "." + dec;
}
// Fetch token balances for a single address from Blockscout.
// Returns [{ address, symbol, decimals, balance }].
async function fetchTokenBalances(address, blockscoutUrl) {
try {
const resp = await fetch(
blockscoutUrl + "/addresses/" + address + "/token-balances",
);
if (!resp.ok) {
log.errorf("blockscout token-balances:", resp.status);
return null;
}
const items = await resp.json();
if (!Array.isArray(items)) return null;
const balances = [];
for (const item of items) {
if (item.token?.type !== "ERC-20") continue;
const decimals = parseInt(item.token.decimals || "18", 10);
const bal = formatTokenBalance(item.value || "0", decimals);
if (bal === "0.0") continue;
balances.push({
address: item.token.address_hash,
symbol: item.token.symbol || "???",
decimals: decimals,
balance: bal,
});
}
return balances;
} catch (e) {
log.errorf("fetchTokenBalances failed:", e.message);
return null;
}
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// trackedTokens: [{ address, symbol, decimals }]
async function refreshBalances(wallets, trackedTokens, rpcUrl) {
const now = Date.now();
if (now - lastFetchedAt < BALANCE_CACHE_TTL) return;
async function refreshBalances(wallets, rpcUrl, blockscoutUrl) {
log.debugf("refreshBalances start, rpc:", rpcUrl);
const provider = getProvider(rpcUrl);
const updates = [];
@ -48,8 +83,15 @@ async function refreshBalances(wallets, trackedTokens, rpcUrl) {
.getBalance(addr.address)
.then((bal) => {
addr.balance = formatBalance(bal);
log.debugf("ETH balance", addr.address, addr.balance);
})
.catch(() => {}),
.catch((e) => {
log.errorf(
"ETH balance failed",
addr.address,
e.shortMessage || e.message,
);
}),
);
// ENS reverse lookup
@ -64,67 +106,117 @@ async function refreshBalances(wallets, trackedTokens, rpcUrl) {
}),
);
// ERC-20 token balances
if (!addr.tokenBalances) addr.tokenBalances = [];
for (const token of trackedTokens) {
// ERC-20 token balances via Blockscout
updates.push(
(async () => {
try {
const contract = new Contract(
token.address,
ERC20_ABI,
provider,
);
const raw = await contract.balanceOf(addr.address);
const existing = addr.tokenBalances.find(
(t) =>
t.address.toLowerCase() ===
token.address.toLowerCase(),
);
const bal = formatTokenBalance(raw, token.decimals);
if (existing) {
existing.balance = bal;
} else {
addr.tokenBalances.push({
address: token.address,
symbol: token.symbol,
decimals: token.decimals,
balance: bal,
});
}
} catch (e) {
// skip on error
}
})(),
fetchTokenBalances(addr.address, blockscoutUrl).then(
(balances) => {
if (balances !== null) {
addr.tokenBalances = balances;
log.debugf(
"Token balances",
addr.address,
balances.length,
"tokens",
);
}
},
),
);
}
}
await Promise.all(updates);
lastFetchedAt = now;
log.debugf("refreshBalances done");
}
// Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
const provider = getProvider(rpcUrl);
const contract = new Contract(contractAddress, ERC20_ABI, provider);
const [name, symbol, decimals] = await Promise.all([
contract.name(),
contract.symbol(),
contract.decimals(),
]);
let name, symbol, decimals;
try {
symbol = await contract.symbol();
log.debugf("symbol() =", symbol);
} catch (e) {
log.errorf("symbol() failed:", e.shortMessage || e.message);
throw new Error("Not a valid ERC-20 token (symbol() failed).");
}
try {
decimals = await contract.decimals();
log.debugf("decimals() =", decimals);
} catch (e) {
log.errorf("decimals() failed:", e.shortMessage || e.message);
throw new Error("Not a valid ERC-20 token (decimals() failed).");
}
try {
name = await contract.name();
log.debugf("name() =", name);
} catch (e) {
log.warnf("name() failed, using symbol as name:", e.message);
name = symbol;
}
log.infof("Token resolved:", symbol, "decimals", Number(decimals));
return { name, symbol, decimals: Number(decimals) };
}
// Force-invalidate the balance cache (e.g. after sending a tx).
function invalidateBalanceCache() {
lastFetchedAt = 0;
// Derive HD addresses starting from index 0 and check for on-chain activity.
// Stops after gapLimit consecutive addresses with zero balance and zero tx count.
// Returns { addresses: [{ address, index }], nextIndex }.
async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
const provider = getProvider(rpcUrl);
const used = [];
let gap = 0;
let index = 0;
while (gap < gapLimit) {
const addr = deriveAddressFromXpub(xpub, index);
let balance, txCount;
try {
[balance, txCount] = await Promise.all([
provider.getBalance(addr),
provider.getTransactionCount(addr),
]);
} catch (e) {
log.errorf(
"scanForAddresses check failed",
addr,
e.shortMessage || e.message,
);
// Treat RPC failure as empty to avoid infinite loop
gap++;
index++;
continue;
}
if (balance > 0n || txCount > 0) {
used.push({ address: addr, index });
gap = 0;
log.debugf("scanForAddresses used", addr, "index:", index);
} else {
gap++;
}
index++;
}
const nextIndex = used.length > 0 ? used[used.length - 1].index + 1 : 1;
log.infof(
"scanForAddresses done, found:",
used.length,
"nextIndex:",
nextIndex,
);
return { addresses: used, nextIndex };
}
module.exports = {
refreshBalances,
lookupTokenInfo,
invalidateBalanceCache,
getProvider,
scanForAddresses,
};

View File

@ -1,6 +1,12 @@
const DEBUG = true;
const DEBUG_MNEMONIC =
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
const DEFAULT_RPC_URL = "https://eth.llamarpc.com";
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
const DEFAULT_BLOCKSCOUT_URL = "https://eth.blockscout.com/api/v2";
const BIP44_ETH_PATH = "m/44'/60'/0'/0";
@ -15,8 +21,11 @@ const ERC20_ABI = [
];
module.exports = {
DEBUG,
DEBUG_MNEMONIC,
ETHEREUM_MAINNET_CHAIN_ID,
DEFAULT_RPC_URL,
DEFAULT_BLOCKSCOUT_URL,
BIP44_ETH_PATH,
ERC20_ABI,
};

30
src/shared/log.js Normal file
View File

@ -0,0 +1,30 @@
// Leveled logger. Outputs to console with [AutistMask] prefix.
// Level is DEBUG when the DEBUG constant is true, INFO otherwise.
const { DEBUG } = require("./constants");
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
const threshold = DEBUG ? LEVELS.debug : LEVELS.info;
function emit(level, method, args) {
if (LEVELS[level] >= threshold) {
console[method]("[AutistMask]", ...args);
}
}
const log = {
debugf(...args) {
emit("debug", "log", args);
},
infof(...args) {
emit("info", "log", args);
},
warnf(...args) {
emit("warn", "warn", args);
},
errorf(...args) {
emit("error", "error", args);
},
};
module.exports = { log };

View File

@ -37,12 +37,10 @@ function formatUsd(amount) {
}
function getAddressValueUsd(addr) {
if (!prices.ETH) return null;
let total = 0;
const ethBal = parseFloat(addr.balance || "0");
const ethPrice = prices.ETH;
if (ethPrice) {
total += ethBal * ethPrice;
}
total += ethBal * prices.ETH;
for (const token of addr.tokenBalances || []) {
const tokenBal = parseFloat(token.balance || "0");
if (tokenBal > 0 && prices[token.symbol]) {
@ -53,6 +51,7 @@ function getAddressValueUsd(addr) {
}
function getWalletValueUsd(wallet) {
if (!prices.ETH) return null;
let total = 0;
for (const addr of wallet.addresses) {
total += getAddressValueUsd(addr);
@ -61,6 +60,7 @@ function getWalletValueUsd(wallet) {
}
function getTotalValueUsd(wallets) {
if (!prices.ETH) return null;
let total = 0;
for (const wallet of wallets) {
total += getWalletValueUsd(wallet);

View File

@ -1,6 +1,17 @@
// Known scam/fraud addresses. Checked locally before sending.
// This is a best-effort blocklist — it does not replace due diligence.
// Sources: Etherscan labels, MistTrack, community reports.
//
// Policy: This list contains ONLY addresses involved in fraud — phishing,
// wallet drainers, address poisoning, and similar scams. It does NOT include
// addresses that are merely sanctioned or regulated in specific jurisdictions
// (e.g. Tornado Cash, OFAC SDN entries). AutistMask is used internationally
// and does not enforce jurisdiction-specific sanctions.
//
// Sources:
// - Known wallet-drainer contracts identified via Etherscan labels,
// MistTrack alerts, and community incident reports (e.g. address-
// poisoning campaigns, phishing kit deployments).
//
// All addresses lowercased for comparison.
const SCAM_ADDRESSES = new Set([
@ -14,31 +25,6 @@ const SCAM_ADDRESSES = new Set([
"0x3ee18b2214aff97000d974cf647e7c347e8fa585",
"0x55fe002aeff02f77364de339a1292923a15844b8",
"0x7f268357a8c2552623316e2562d90e642bb538e5",
// Tornado Cash sanctioned addresses (OFAC)
"0x722122df12d4e14e13ac3b6895a86e84145b6967",
"0xdd4c48c0b24039969fc16d1cdf626eab821d3384",
"0xd90e2f925da726b50c4ed8d0fb90ad053324f31b",
"0xd96f2b1ab14cd8ab753fa0357fee5cd7d512c838",
"0x4736dcf1b7a3d580672cce6e7c65cd5cc9cfbfa9",
"0xd4b88df4d29f5cedd6857912842cff3b20c8cfa3",
"0x910cbd523d972eb0a6f4cae4618ad62622b39dbf",
"0xa160cdab225685da1d56aa342ad8841c3b53f291",
"0xfd8610d20aa15b7b2e3be39b396a1bc3516c7144",
"0xf60dd140cff0706bae9cd734ac3683731eb5bb31",
"0x22aaa7720ddd5388a3c0a3333430953c68f1849b",
"0xba214c1c1928a32bffe790263e38b4af9bfcd659",
"0xb1c8094b234dce6e03f10a5b673c1d8c69739a00",
"0x527653ea119f3e6a1f5bd18fbf4714081d7b31ce",
"0x58e8dcc13be9780fc42e8723d8ead4cf46943df2",
"0xd691f27f38b395864ea86cfc7253969b409c362d",
"0xaeaac358560e11f52454d997aaff2c5731b6f8a6",
"0x1356c899d8c9467c7f71c195612f8a395abf2f0a",
"0xa60c772958a3ed56c1f15dd055ba37ac8e523a0d",
"0x169ad27a470d064dede56a2d3ff727986b15d52b",
"0x0836222f2b2b24a3f36f98668ed8f0b38d1a872f",
"0x178169b423a011fff22b9e3f3abea13414ddd0f1",
"0x610b717796ad172b316957a19699d4b58edca1e0",
"0xbb93e510bbcd0b7beb5a853875f9ec60275cf498",
]);
function isScamAddress(address) {

View File

@ -1,5 +1,7 @@
// State management and extension storage persistence.
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const storageApi =
typeof browser !== "undefined"
? browser.storage.local
@ -9,7 +11,9 @@ const DEFAULT_STATE = {
hasWallet: false,
wallets: [],
trackedTokens: [],
rpcUrl: "https://eth.llamarpc.com",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
lastBalanceRefresh: 0,
};
const state = {
@ -24,6 +28,8 @@ async function saveState() {
wallets: state.wallets,
trackedTokens: state.trackedTokens,
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
lastBalanceRefresh: state.lastBalanceRefresh,
};
await storageApi.set({ autistmask: persisted });
}
@ -36,6 +42,9 @@ async function loadState() {
state.wallets = saved.wallets || [];
state.trackedTokens = saved.trackedTokens || [];
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
state.blockscoutUrl =
saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
state.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
}
}

108
src/shared/transactions.js Normal file
View File

@ -0,0 +1,108 @@
// Transaction history fetching via Blockscout v2 API.
// Fetches normal transactions and ERC-20 token transfers,
// merges them, and returns the most recent entries.
const { formatEther, formatUnits } = require("ethers");
const { log } = require("./log");
function formatTxValue(val) {
const parts = val.split(".");
if (parts.length === 1) return val;
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
return parts[0] + "." + dec;
}
function parseTx(tx, addrLower) {
const from = tx.from?.hash || "";
const to = tx.to?.hash || "";
return {
hash: tx.hash,
blockNumber: tx.block_number,
timestamp: Math.floor(new Date(tx.timestamp).getTime() / 1000),
from: from,
to: to,
value: formatTxValue(formatEther(tx.value || "0")),
symbol: "ETH",
direction: from.toLowerCase() === addrLower ? "sent" : "received",
isError: tx.status !== "ok",
};
}
function parseTokenTransfer(tt, addrLower) {
const from = tt.from?.hash || "";
const to = tt.to?.hash || "";
const decimals = parseInt(tt.total?.decimals || "18", 10);
const rawValue = tt.total?.value || "0";
return {
hash: tt.transaction_hash,
blockNumber: tt.block_number,
timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
from: from,
to: to,
value: formatTxValue(formatUnits(rawValue, decimals)),
symbol: tt.token?.symbol || "?",
direction: from.toLowerCase() === addrLower ? "sent" : "received",
isError: false,
};
}
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
log.debugf("fetchRecentTransactions", address);
const addrLower = address.toLowerCase();
const [txResp, ttResp] = await Promise.all([
fetch(
blockscoutUrl +
"/addresses/" +
address +
"/transactions?limit=" +
count,
),
fetch(
blockscoutUrl +
"/addresses/" +
address +
"/token-transfers?limit=" +
count +
"&type=ERC-20",
),
]);
if (!txResp.ok) {
log.errorf(
"blockscout transactions:",
txResp.status,
txResp.statusText,
);
}
if (!ttResp.ok) {
log.errorf(
"blockscout token-transfers:",
ttResp.status,
ttResp.statusText,
);
}
const txJson = txResp.ok ? await txResp.json() : {};
const ttJson = ttResp.ok ? await ttResp.json() : {};
const txs = [];
for (const tx of txJson.items || []) {
txs.push(parseTx(tx, addrLower));
}
// Deduplicate: skip token transfers whose tx hash is already in the list
const seenHashes = new Set(txs.map((t) => t.hash));
for (const tt of ttJson.items || []) {
if (seenHashes.has(tt.transaction_hash)) continue;
txs.push(parseTokenTransfer(tt, addrLower));
}
txs.sort((a, b) => b.blockNumber - a.blockNumber);
const result = txs.slice(0, count);
log.debugf("fetchRecentTransactions done, count:", result.length);
return result;
}
module.exports = { fetchRecentTransactions };

View File

@ -2,12 +2,7 @@
// All crypto delegated to ethers.js.
const { Mnemonic, HDNodeWallet, Wallet } = require("ethers");
const BIP44_ETH_BASE = "m/44'/60'/0'/0";
const DEBUG = true;
const DEBUG_MNEMONIC =
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
function generateMnemonic() {
if (DEBUG) return DEBUG_MNEMONIC;
@ -23,7 +18,7 @@ function deriveAddressFromXpub(xpub, index) {
}
function hdWalletFromMnemonic(mnemonic) {
const node = HDNodeWallet.fromPhrase(mnemonic, "", BIP44_ETH_BASE);
const node = HDNodeWallet.fromPhrase(mnemonic, "", BIP44_ETH_PATH);
const xpub = node.neuter().extendedKey;
const firstAddress = node.deriveChild(0).address;
return { xpub, firstAddress };
@ -39,7 +34,7 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
const node = HDNodeWallet.fromPhrase(
decryptedSecret,
"",
BIP44_ETH_BASE,
BIP44_ETH_PATH,
);
return node.deriveChild(addrIndex);
}
@ -51,9 +46,6 @@ function isValidMnemonic(mnemonic) {
}
module.exports = {
BIP44_ETH_BASE,
DEBUG,
DEBUG_MNEMONIC,
generateMnemonic,
deriveAddressFromXpub,
hdWalletFromMnemonic,