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:
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 - **360x600 popup**: Standard browser extension popup dimensions. The UI is
designed for this fixed viewport — no responsive breakpoints needed. designed for this fixed viewport — no 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 #### Language & Labeling
All user-facing text avoids crypto jargon wherever possible: 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 Chrome (Manifest V3)
- [ ] Test on Firefox (Manifest V2) - [ ] 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 ### Post-MVP
- [ ] EIP-1193 provider injection (window.ethereum) for web3 site connectivity - [ ] EIP-1193 provider injection (window.ethereum) for web3 site connectivity

View File

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

View File

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

View File

@@ -2,8 +2,12 @@
// Handles EIP-1193 RPC requests from content scripts and proxies // Handles EIP-1193 RPC requests from content scripts and proxies
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint. // non-sensitive calls to the configured Ethereum JSON-RPC endpoint.
const CHAIN_ID = "0x1"; const {
const DEFAULT_RPC = "https://eth.llamarpc.com"; ETHEREUM_MAINNET_CHAIN_ID,
DEFAULT_RPC_URL,
} = require("../shared/constants");
const { state, loadState, saveState } = require("../shared/state");
const { refreshBalances } = require("../shared/balances");
const storageApi = const storageApi =
typeof browser !== "undefined" typeof browser !== "undefined"
@@ -17,7 +21,7 @@ const connectedSites = {};
async function getState() { async function getState() {
const result = await storageApi.get("autistmask"); const result = await storageApi.get("autistmask");
return result.autistmask || { wallets: [], rpcUrl: DEFAULT_RPC }; return result.autistmask || { wallets: [], rpcUrl: DEFAULT_RPC_URL };
} }
async function getAccounts() { async function getAccounts() {
@@ -33,7 +37,7 @@ async function getAccounts() {
async function getRpcUrl() { async function getRpcUrl() {
const state = await getState(); const state = await getState();
return state.rpcUrl || DEFAULT_RPC; return state.rpcUrl || DEFAULT_RPC_URL;
} }
// Proxy an RPC call to the Ethereum node // Proxy an RPC call to the Ethereum node
@@ -94,7 +98,7 @@ async function handleRpc(method, params, origin) {
} }
if (method === "eth_chainId") { if (method === "eth_chainId") {
return { result: CHAIN_ID }; return { result: ETHEREUM_MAINNET_CHAIN_ID };
} }
if (method === "net_version") { if (method === "net_version") {
@@ -103,7 +107,7 @@ async function handleRpc(method, params, origin) {
if (method === "wallet_switchEthereumChain") { if (method === "wallet_switchEthereumChain") {
const chainId = params?.[0]?.chainId; const chainId = params?.[0]?.chainId;
if (chainId === CHAIN_ID) { if (chainId === ETHEREUM_MAINNET_CHAIN_ID) {
return { result: null }; return { result: null };
} }
return { return {
@@ -205,6 +209,24 @@ async function handleRpc(method, params, origin) {
return { error: { message: "Unsupported method: " + method } }; 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 // Listen for messages from content scripts
runtime.onMessage.addListener((msg, sender, sendResponse) => { runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type !== "AUTISTMASK_RPC") return; if (msg.type !== "AUTISTMASK_RPC") return;

View File

@@ -1,15 +1,17 @@
// AutistMask content script — bridges between inpage (window.ethereum) // AutistMask content script — bridges between inpage (window.ethereum)
// and the background service worker via extension messaging. // and the background service worker via extension messaging.
// Inject the inpage script into the page's JS context // 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"); const script = document.createElement("script");
script.src = (typeof browser !== "undefined" ? browser : chrome).runtime.getURL( script.src = browser.runtime.getURL("src/content/inpage.js");
"src/content/inpage.js",
);
script.onload = function () { script.onload = function () {
this.remove(); this.remove();
}; };
(document.head || document.documentElement).appendChild(script); (document.head || document.documentElement).appendChild(script);
}
// Relay requests from the page to the background script // Relay requests from the page to the background script
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {

View File

@@ -1,9 +1,7 @@
// AutistMask inpage script — injected into the page's JS context. // 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 () { (function () {
if (typeof window.ethereum !== "undefined") return;
const CHAIN_ID = "0x1"; // Ethereum mainnet const CHAIN_ID = "0x1"; // Ethereum mainnet
const listeners = {}; const listeners = {};
@@ -45,7 +43,7 @@
} }
} }
function request(args) { function sendRequest(args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = nextId++; const id = nextId++;
pending[id] = { resolve, reject }; pending[id] = { resolve, reject };
@@ -63,8 +61,21 @@
networkVersion: "1", networkVersion: "1",
selectedAddress: null, selectedAddress: null,
request(args) { async request(args) {
return request({ method: args.method, params: args.params || [] }); 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) // Legacy methods (still used by some dApps)
@@ -119,10 +130,46 @@
} }
return this; 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; window.ethereum = provider;
}
// Announce via EIP-6963 (multi-wallet discovery)
window.dispatchEvent(new Event("ethereum#initialized")); 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 >@sneak</a
> >
</h1> </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> </div>
<!-- ============ FLASH MESSAGE AREA ============ -->
<div
id="flash-msg"
class="text-xs text-muted min-h-[1.25rem] mb-1"
></div>
<!-- ============ WELCOME / FIRST USE ============ --> <!-- ============ WELCOME / FIRST USE ============ -->
<div id="view-welcome" class="view hidden"> <div id="view-welcome" class="view hidden">
<p class="mb-3">Welcome! To get started, add a wallet.</p> <p class="mb-3">Welcome! To get started, add a wallet.</p>
@@ -36,6 +49,12 @@
<!-- ============ ADD WALLET (unified create/import) ============ --> <!-- ============ ADD WALLET (unified create/import) ============ -->
<div id="view-add-wallet" class="view hidden"> <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> <h2 class="font-bold mb-2">Add Wallet</h2>
<p class="mb-2"> <p class="mb-2">
Enter your 12 or 24 word recovery phrase below, or click the Enter your 12 or 24 word recovery phrase below, or click the
@@ -62,10 +81,8 @@
id="add-wallet-phrase-warning" id="add-wallet-phrase-warning"
class="text-xs mb-2 border border-border border-dashed p-2 hidden" class="text-xs mb-2 border border-border border-dashed p-2 hidden"
> >
These words are your recovery phrase. Write them down on Write these words down and keep them safe. Anyone with them
paper and keep them somewhere safe. Anyone with these words can take your funds; if you lose them, your wallet is gone.
can access your funds. If you lose them, your wallet cannot
be recovered.
</div> </div>
<div class="mb-2" id="add-wallet-password-section"> <div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label> <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" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/> />
</div> </div>
<div class="flex gap-2">
<button <button
id="btn-add-wallet-confirm" id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Add Add
</button> </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"> <div class="mt-3 text-xs text-muted">
Have a private key instead? Have a private key instead?
<button <button
@@ -118,6 +123,12 @@
<!-- ============ IMPORT PRIVATE KEY ============ --> <!-- ============ IMPORT PRIVATE KEY ============ -->
<div id="view-import-key" class="view hidden"> <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> <h2 class="font-bold mb-2">Import Private Key</h2>
<p class="mb-2"> <p class="mb-2">
Paste your private key below. This wallet will have a single 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" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/> />
</div> </div>
<div class="flex gap-2">
<button <button
id="btn-import-key-confirm" id="btn-import-key-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Import Import
</button> </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> </div>
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ --> <!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
<div id="view-main" class="view hidden"> <div id="view-main" class="view hidden">
<!-- total portfolio value --> <!-- 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 --> <!-- wallet list -->
<div id="wallet-list"></div> <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> </div>
<!-- ============ ADDRESS DETAIL VIEW ============ --> <!-- ============ ADDRESS DETAIL VIEW ============ -->
<div id="view-address" class="view hidden"> <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 <div
class="flex justify-between items-center border-b border-border pb-1 mb-2" class="flex justify-between items-center border-b border-border pb-1 mb-2"
> >
<h2 class="font-bold" id="address-title">Address</h2> <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>
<div id="address-ens" class="font-bold mb-1 hidden"></div> <div id="address-ens" class="font-bold mb-1 hidden"></div>
<div <div
id="address-full" class="flex text-xs mb-3 cursor-pointer"
class="text-xs break-all mb-1 cursor-pointer"
title="Click to copy" title="Click to copy"
></div> >
<div <span
class="text-xs text-muted mb-3" id="address-full"
id="address-copied-msg" class="shrink-0"
></div> style="width: 42ch"
></span>
<!-- balance --> <span
<div class="border-b border-border-light pb-2 mb-2"> id="address-usd-total"
<div class="text-base font-bold"> class="text-right text-muted flex-1"
<span id="address-eth-balance">0.0000</span> ETH ></span>
</div> </div>
<div <!-- balances -->
class="text-xs text-muted" <div class="border-b border-border-light pb-2 mb-2">
id="address-usd-value" <div id="address-balances"></div>
></div>
</div> </div>
<!-- actions --> <!-- actions -->
@@ -245,31 +230,33 @@
> >
Receive Receive
</button> </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 <button
id="btn-add-token" 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> </button>
</div> </div>
<div id="token-list">
<div class="text-muted text-xs py-1"> <!-- transactions -->
No tokens added yet. Use "+ Add" to track a token. <div class="mt-3">
<div class="border-b border-border pb-1 mb-1">
<h2 class="font-bold">Transactions</h2>
</div> </div>
<div id="tx-list">
<div class="text-muted text-xs py-1">Loading...</div>
</div> </div>
</div> </div>
</div> </div>
<!-- ============ SEND ============ --> <!-- ============ SEND ============ -->
<div id="view-send" class="view hidden"> <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> <h2 class="font-bold mb-2">Send</h2>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1">What to send</label> <label class="block mb-1">What to send</label>
@@ -290,7 +277,13 @@
/> />
</div> </div>
<div class="mb-2"> <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 <input
type="text" type="text"
id="send-amount" id="send-amount"
@@ -298,28 +291,22 @@
placeholder="0.0" placeholder="0.0"
/> />
</div> </div>
<div class="flex gap-2">
<button <button
id="btn-send-review" id="btn-send-review"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Review Review
</button> </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> </div>
<!-- ============ CONFIRM TRANSACTION ============ --> <!-- ============ CONFIRM TRANSACTION ============ -->
<div id="view-confirm-tx" class="view hidden"> <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> <h2 class="font-bold mb-2">Confirm Transaction</h2>
<div class="mb-2"> <div class="mb-2">
<div class="text-xs text-muted">From</div> <div class="text-xs text-muted">From</div>
@@ -350,20 +337,12 @@
id="confirm-errors" id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 hidden" class="mb-2 border border-border border-dashed p-2 hidden"
></div> ></div>
<div class="flex gap-2">
<button <button
id="btn-confirm-send" id="btn-confirm-send"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Send Send
</button> </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 <div
id="confirm-status" id="confirm-status"
class="mt-2 border border-border p-1 hidden" class="mt-2 border border-border p-1 hidden"
@@ -408,6 +387,12 @@
<!-- ============ RECEIVE ============ --> <!-- ============ RECEIVE ============ -->
<div id="view-receive" class="view hidden"> <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> <h2 class="font-bold mb-2">Receive</h2>
<p class="mb-2"> <p class="mb-2">
Share this address with the sender. Make sure you only use Share this address with the sender. Make sure you only use
@@ -420,24 +405,22 @@
id="receive-address" id="receive-address"
class="border border-border p-2 break-all select-all mb-3" class="border border-border p-2 break-all select-all mb-3"
></div> ></div>
<div class="flex gap-2">
<button <button
id="btn-receive-copy" id="btn-receive-copy"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Copy address Copy address
</button> </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> </div>
<!-- ============ ADD TOKEN ============ --> <!-- ============ ADD TOKEN ============ -->
<div id="view-add-token" class="view hidden"> <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> <h2 class="font-bold mb-2">Add Token</h2>
<p class="mb-2"> <p class="mb-2">
Enter the contract address of the token you want to track. Enter the contract address of the token you want to track.
@@ -465,36 +448,44 @@
class="flex flex-wrap gap-1" class="flex flex-wrap gap-1"
></div> ></div>
</div> </div>
<div class="flex gap-2">
<button <button
id="btn-add-token-confirm" id="btn-add-token-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Add Add
</button> </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> </div>
<!-- ============ SETTINGS ============ --> <!-- ============ SETTINGS ============ -->
<div id="view-settings" class="view hidden"> <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> <div class="bg-well p-3 mx-1 mb-3">
<p class="text-xs text-muted mb-1"> <h3 class="font-bold mb-1">Wallets</h3>
The server used to talk to the Ethereum network. Change this <p class="text-xs text-muted mb-2">
if you run your own node or prefer a different provider. 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> </p>
<div class="mb-3">
<input <input
type="text" type="text"
id="settings-rpc" id="settings-rpc"
@@ -508,12 +499,22 @@
</button> </button>
</div> </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 <button
id="btn-settings-back" id="btn-save-blockscout"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Back Save
</button> </button>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
const { $, showError, hideError, showView } = require("./helpers"); const { $, showView, showFlash } = require("./helpers");
const { const {
generateMnemonic, generateMnemonic,
hdWalletFromMnemonic, hdWalletFromMnemonic,
@@ -6,13 +6,13 @@ const {
} = require("../../shared/wallet"); } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault"); const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances");
function show() { function show() {
$("wallet-mnemonic").value = ""; $("wallet-mnemonic").value = "";
$("add-wallet-password").value = ""; $("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = ""; $("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").classList.add("hidden"); $("add-wallet-phrase-warning").classList.add("hidden");
hideError("add-wallet-error");
showView("add-wallet"); showView("add-wallet");
} }
@@ -25,16 +25,14 @@ function init(ctx) {
$("btn-add-wallet-confirm").addEventListener("click", async () => { $("btn-add-wallet-confirm").addEventListener("click", async () => {
const mnemonic = $("wallet-mnemonic").value.trim(); const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) { if (!mnemonic) {
showError( showFlash(
"add-wallet-error", "Enter a recovery phrase or press the die to generate one.",
"Please enter a recovery phrase or press the die to generate one.",
); );
return; return;
} }
const words = mnemonic.split(/\s+/); const words = mnemonic.split(/\s+/);
if (words.length !== 12 && words.length !== 24) { if (words.length !== 12 && words.length !== 24) {
showError( showFlash(
"add-wallet-error",
"Recovery phrase must be 12 or 24 words. You entered " + "Recovery phrase must be 12 or 24 words. You entered " +
words.length + words.length +
".", ".",
@@ -42,34 +40,42 @@ function init(ctx) {
return; return;
} }
if (!isValidMnemonic(mnemonic)) { if (!isValidMnemonic(mnemonic)) {
showError( showFlash("Invalid recovery phrase. Check for typos.");
"add-wallet-error",
"Invalid recovery phrase. Please check for typos.",
);
return; return;
} }
const pw = $("add-wallet-password").value; const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value; const pw2 = $("add-wallet-password-confirm").value;
if (!pw) { if (!pw) {
showError("add-wallet-error", "Please choose a password."); showFlash("Please choose a password.");
return; return;
} }
if (pw.length < 8) { if (pw.length < 8) {
showError( showFlash("Password must be at least 8 characters.");
"add-wallet-error",
"Password must be at least 8 characters.",
);
return; return;
} }
if (pw !== pw2) { if (pw !== pw2) {
showError("add-wallet-error", "Passwords do not match."); showFlash("Passwords do not match.");
return; return;
} }
hideError("add-wallet-error");
const encrypted = await encryptWithPassword(mnemonic, pw);
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic); const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const duplicate = state.wallets.find(
(w) =>
w.type === "hd" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() ===
firstAddress.toLowerCase(),
);
if (duplicate) {
showFlash(
"This recovery phrase is already added (" +
duplicate.name +
").",
);
return;
}
const encrypted = await encryptWithPassword(mnemonic, pw);
const walletNum = state.wallets.length + 1; const walletNum = state.wallets.length + 1;
state.wallets.push({ const wallet = {
type: "hd", type: "hd",
name: "Wallet " + walletNum, name: "Wallet " + walletNum,
xpub: xpub, xpub: xpub,
@@ -78,11 +84,28 @@ function init(ctx) {
addresses: [ addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] }, { address: firstAddress, balance: "0.0000", tokenBalances: [] },
], ],
}); };
state.wallets.push(wallet);
state.hasWallet = true; state.hasWallet = true;
await saveState(); await saveState();
ctx.renderWalletList(); ctx.renderWalletList();
showView("main"); 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", () => { $("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 { state, currentAddress } = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
const { fetchRecentTransactions } = require("../../shared/transactions");
const { updateSendBalance } = require("./send");
const { log } = require("../../shared/log");
const QRCode = require("qrcode"); const QRCode = require("qrcode");
function show() { function show() {
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress]; 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-full").textContent = addr.address;
$("address-copied-msg").textContent = ""; $("address-usd-total").textContent = formatUsd(getAddressValueUsd(addr));
$("address-eth-balance").textContent = addr.balance;
$("address-usd-value").textContent = formatUsd(getAddressValueUsd(addr));
const ensEl = $("address-ens"); const ensEl = $("address-ens");
if (addr.ensName) { if (addr.ensName) {
ensEl.textContent = addr.ensName; ensEl.textContent = addr.ensName;
@@ -18,28 +22,71 @@ function show() {
} else { } else {
ensEl.classList.add("hidden"); ensEl.classList.add("hidden");
} }
renderTokenList(addr); $("address-balances").innerHTML = balanceLinesForAddress(addr);
renderSendTokenSelect(addr); renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address"); showView("address");
loadTransactions(addr.address);
} }
function renderTokenList(addr) { function formatDate(timestamp) {
const list = $("token-list"); const d = new Date(timestamp * 1000);
const balances = addr.tokenBalances || []; const pad = (n) => String(n).padStart(2, "0");
if (balances.length === 0 && state.trackedTokens.length === 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 = 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; return;
} }
list.innerHTML = balances const addrLower = address.toLowerCase();
.map( let html = "";
(t) => for (const tx of txs) {
`<div class="py-1 border-b border-border-light flex justify-between">` + const arrow = tx.direction === "sent" ? "\u2192" : "\u2190";
`<span>${t.symbol}</span>` + const counterparty = tx.direction === "sent" ? tx.to : tx.from;
`<span>${t.balance || "0"}</span>` + const label = tx.direction === "sent" ? "to" : "from";
`</div>`, const errorClass = tx.isError ? ' style="opacity:0.5"' : "";
) const errorTag = tx.isError
.join(""); ? ' <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) { function renderSendTokenSelect(addr) {
@@ -58,10 +105,7 @@ function init(ctx) {
const addr = $("address-full").textContent; const addr = $("address-full").textContent;
if (addr) { if (addr) {
navigator.clipboard.writeText(addr); navigator.clipboard.writeText(addr);
$("address-copied-msg").textContent = "Copied!"; showFlash("Copied!");
setTimeout(() => {
$("address-copied-msg").textContent = "";
}, 2000);
} }
}); });
@@ -71,11 +115,17 @@ function init(ctx) {
}); });
$("btn-send").addEventListener("click", () => { $("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-to").value = "";
$("send-amount").value = ""; $("send-amount").value = "";
$("send-password").value = ""; updateSendBalance();
$("send-fee-estimate").classList.add("hidden");
$("send-status").classList.add("hidden");
showView("send"); showView("send");
}); });

View File

@@ -8,10 +8,7 @@ const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { const { getProvider } = require("../../shared/balances");
getProvider,
invalidateBalanceCache,
} = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist"); const { isScamAddress } = require("../../shared/scamlist");
let pendingTx = null; let pendingTx = null;
@@ -163,12 +160,19 @@ function init(ctx) {
}); });
statusEl.textContent = "Sent. Waiting for confirmation..."; statusEl.textContent = "Sent. Waiting for confirmation...";
const receipt = await tx.wait(); const receipt = await tx.wait();
statusEl.textContent = statusEl.innerHTML = "";
"Confirmed in block " + statusEl.appendChild(
receipt.blockNumber + document.createTextNode(
". Tx: " + "Confirmed in block " + receipt.blockNumber + ". Tx: ",
receipt.hash; ),
invalidateBalanceCache(); );
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(); ctx.doRefreshAndRender();
} catch (e) { } catch (e) {
statusEl.textContent = "Failed: " + (e.shortMessage || e.message); statusEl.textContent = "Failed: " + (e.shortMessage || e.message);

View File

@@ -1,6 +1,11 @@
// Shared DOM helpers used by all views. // 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 = [ const VIEWS = [
"welcome", "welcome",
@@ -37,6 +42,7 @@ function showView(name) {
el.classList.toggle("hidden", v !== name); el.classList.toggle("hidden", v !== name);
} }
} }
clearFlash();
if (DEBUG) { if (DEBUG) {
const banner = document.getElementById("debug-banner"); const banner = document.getElementById("debug-banner");
if (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 { state, saveState } = require("../../shared/state");
const { deriveAddressFromXpub } = require("../../shared/wallet"); const { deriveAddressFromXpub } = require("../../shared/wallet");
const { const {
@@ -33,15 +33,17 @@ function render(ctx) {
html += `</div>`; html += `</div>`;
wallet.addresses.forEach((addr, ai) => { 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) { if (addr.ensName) {
html += `<div class="text-xs font-bold">${addr.ensName}</div>`; html += `<div class="text-xs font-bold">${addr.ensName}</div>`;
} }
html += `<div class="text-xs break-all">${addr.address}</div>`; const addrUsd = formatUsd(getAddressValueUsd(addr));
html += `<div class="flex justify-between items-center">`; html += `<div class="flex text-xs">`;
html += `<span class="text-xs">${addr.balance} ETH</span>`; html += `<span class="shrink-0" style="width:42ch">${addr.address}</span>`;
html += `<span class="text-xs text-muted">${formatUsd(getAddressValueUsd(addr))}</span>`; html += `<span class="text-right text-muted flex-1">${addrUsd}</span>`;
html += `</div>`; html += `</div>`;
html += balanceLinesForAddress(addr);
html += `</div>`; html += `</div>`;
}); });
@@ -62,27 +64,25 @@ function render(ctx) {
e.stopPropagation(); e.stopPropagation();
const wi = parseInt(btn.dataset.wallet, 10); const wi = parseInt(btn.dataset.wallet, 10);
const wallet = state.wallets[wi]; const wallet = state.wallets[wi];
const newAddr = deriveAddressFromXpub(
wallet.xpub,
wallet.nextIndex,
);
wallet.addresses.push({ wallet.addresses.push({
address: deriveAddressFromXpub(wallet.xpub, wallet.nextIndex), address: newAddr,
balance: "0.0000", balance: "0.0000",
tokenBalances: [], tokenBalances: [],
}); });
wallet.nextIndex++; wallet.nextIndex++;
await saveState(); await saveState();
render(ctx); render(ctx);
ctx.doRefreshAndRender();
}); });
}); });
renderTotalValue(); renderTotalValue();
} }
function init(ctx) { function init(ctx) {}
$("btn-settings").addEventListener("click", () => {
$("settings-rpc").value = state.rpcUrl;
showView("settings");
});
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
}
module.exports = { init, render }; 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 { addressFromPrivateKey } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault"); const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
@@ -7,7 +7,6 @@ function show() {
$("import-private-key").value = ""; $("import-private-key").value = "";
$("import-key-password").value = ""; $("import-key-password").value = "";
$("import-key-password-confirm").value = ""; $("import-key-password-confirm").value = "";
hideError("import-key-error");
showView("import-key"); showView("import-key");
} }
@@ -15,34 +14,30 @@ function init(ctx) {
$("btn-import-key-confirm").addEventListener("click", async () => { $("btn-import-key-confirm").addEventListener("click", async () => {
const key = $("import-private-key").value.trim(); const key = $("import-private-key").value.trim();
if (!key) { if (!key) {
showError("import-key-error", "Please enter your private key."); showFlash("Please enter your private key.");
return; return;
} }
let addr; let addr;
try { try {
addr = addressFromPrivateKey(key); addr = addressFromPrivateKey(key);
} catch (e) { } catch (e) {
showError("import-key-error", "Invalid private key."); showFlash("Invalid private key.");
return; return;
} }
const pw = $("import-key-password").value; const pw = $("import-key-password").value;
const pw2 = $("import-key-password-confirm").value; const pw2 = $("import-key-password-confirm").value;
if (!pw) { if (!pw) {
showError("import-key-error", "Please choose a password."); showFlash("Please choose a password.");
return; return;
} }
if (pw.length < 8) { if (pw.length < 8) {
showError( showFlash("Password must be at least 8 characters.");
"import-key-error",
"Password must be at least 8 characters.",
);
return; return;
} }
if (pw !== pw2) { if (pw !== pw2) {
showError("import-key-error", "Passwords do not match."); showFlash("Passwords do not match.");
return; return;
} }
hideError("import-key-error");
const encrypted = await encryptWithPassword(key, pw); const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1; const walletNum = state.wallets.length + 1;
state.wallets.push({ state.wallets.push({
@@ -57,6 +52,8 @@ function init(ctx) {
await saveState(); await saveState();
ctx.renderWalletList(); ctx.renderWalletList();
showView("main"); showView("main");
ctx.doRefreshAndRender();
}); });
$("btn-import-key-back").addEventListener("click", () => { $("btn-import-key-back").addEventListener("click", () => {

View File

@@ -1,9 +1,12 @@
const { $ } = require("./helpers"); const { $, showFlash } = require("./helpers");
function init(ctx) { function init(ctx) {
$("btn-receive-copy").addEventListener("click", () => { $("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address").textContent; 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); $("btn-receive-back").addEventListener("click", ctx.showAddressDetail);

View File

@@ -1,22 +1,41 @@
// Send view: collect To, Amount, Token. Then go to confirmation. // 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 { state, currentAddress } = require("../../shared/state");
const { getProvider } = require("../../shared/balances"); 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) { function init(ctx) {
$("send-token").addEventListener("change", updateSendBalance);
$("btn-send-review").addEventListener("click", async () => { $("btn-send-review").addEventListener("click", async () => {
const to = $("send-to").value.trim(); const to = $("send-to").value.trim();
const amount = $("send-amount").value.trim(); const amount = $("send-amount").value.trim();
if (!to) { if (!to) {
showError("send-error", "Please enter a recipient address."); showFlash("Please enter a recipient address.");
return; return;
} }
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
showError("send-error", "Please enter a valid amount."); showFlash("Please enter a valid amount.");
return; return;
} }
hideError("send-error");
// Resolve ENS if needed // Resolve ENS if needed
let resolvedTo = to; let resolvedTo = to;
@@ -26,13 +45,13 @@ function init(ctx) {
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
const resolved = await provider.resolveName(to); const resolved = await provider.resolveName(to);
if (!resolved) { if (!resolved) {
showError("send-error", "Could not resolve " + to); showFlash("Could not resolve " + to);
return; return;
} }
resolvedTo = resolved; resolvedTo = resolved;
ensName = to; ensName = to;
} catch (e) { } catch (e) {
showError("send-error", "Failed to resolve ENS name."); showFlash("Failed to resolve ENS name.");
return; return;
} }
} }
@@ -53,4 +72,4 @@ function init(ctx) {
$("btn-send-back").addEventListener("click", ctx.showAddressDetail); $("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 { state, saveState } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
const { log } = require("../../shared/log");
function init(ctx) { function init(ctx) {
$("btn-save-rpc").addEventListener("click", async () => { $("btn-save-rpc").addEventListener("click", async () => {
state.rpcUrl = $("settings-rpc").value.trim(); const url = $("settings-rpc").value.trim();
await saveState(); 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", () => { $("btn-settings-back").addEventListener("click", () => {
ctx.renderWalletList(); ctx.renderWalletList();

View File

@@ -1,19 +1,23 @@
// Balance fetching: ETH balances, ERC-20 token balances, ENS reverse lookup. // Balance fetching: ETH balances via RPC, ERC-20 token balances via
// Cached for 60 seconds. // Blockscout, ENS reverse lookup via RPC.
const { const {
JsonRpcProvider, JsonRpcProvider,
Network,
Contract, Contract,
formatEther, formatEther,
formatUnits, formatUnits,
} = require("ethers"); } = require("ethers");
const { ERC20_ABI } = require("./constants"); const { ERC20_ABI } = require("./constants");
const { log } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const BALANCE_CACHE_TTL = 60000; // 60 seconds // Use a static network to skip auto-detection (which can fail and cause
let lastFetchedAt = 0; // "could not coalesce error" on some RPC endpoints like Cloudflare).
const mainnet = Network.from("mainnet");
function getProvider(rpcUrl) { function getProvider(rpcUrl) {
return new JsonRpcProvider(rpcUrl); return new JsonRpcProvider(rpcUrl, mainnet, { staticNetwork: mainnet });
} }
function formatBalance(wei) { function formatBalance(wei) {
@@ -32,11 +36,42 @@ function formatTokenBalance(raw, decimals) {
return parts[0] + "." + dec; 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. // Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// trackedTokens: [{ address, symbol, decimals }] async function refreshBalances(wallets, rpcUrl, blockscoutUrl) {
async function refreshBalances(wallets, trackedTokens, rpcUrl) { log.debugf("refreshBalances start, rpc:", rpcUrl);
const now = Date.now();
if (now - lastFetchedAt < BALANCE_CACHE_TTL) return;
const provider = getProvider(rpcUrl); const provider = getProvider(rpcUrl);
const updates = []; const updates = [];
@@ -48,8 +83,15 @@ async function refreshBalances(wallets, trackedTokens, rpcUrl) {
.getBalance(addr.address) .getBalance(addr.address)
.then((bal) => { .then((bal) => {
addr.balance = formatBalance(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 // ENS reverse lookup
@@ -64,67 +106,117 @@ async function refreshBalances(wallets, trackedTokens, rpcUrl) {
}), }),
); );
// ERC-20 token balances // ERC-20 token balances via Blockscout
if (!addr.tokenBalances) addr.tokenBalances = [];
for (const token of trackedTokens) {
updates.push( updates.push(
(async () => { fetchTokenBalances(addr.address, blockscoutUrl).then(
try { (balances) => {
const contract = new Contract( if (balances !== null) {
token.address, addr.tokenBalances = balances;
ERC20_ABI, log.debugf(
provider, "Token balances",
); addr.address,
const raw = await contract.balanceOf(addr.address); balances.length,
const existing = addr.tokenBalances.find( "tokens",
(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
}
})(),
); );
} }
},
),
);
} }
} }
await Promise.all(updates); await Promise.all(updates);
lastFetchedAt = now; log.debugf("refreshBalances done");
} }
// Look up token metadata from its contract. // Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl) { async function lookupTokenInfo(contractAddress, rpcUrl) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
const provider = getProvider(rpcUrl); const provider = getProvider(rpcUrl);
const contract = new Contract(contractAddress, ERC20_ABI, provider); const contract = new Contract(contractAddress, ERC20_ABI, provider);
const [name, symbol, decimals] = await Promise.all([
contract.name(), let name, symbol, decimals;
contract.symbol(), try {
contract.decimals(), 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) }; return { name, symbol, decimals: Number(decimals) };
} }
// Force-invalidate the balance cache (e.g. after sending a tx). // Derive HD addresses starting from index 0 and check for on-chain activity.
function invalidateBalanceCache() { // Stops after gapLimit consecutive addresses with zero balance and zero tx count.
lastFetchedAt = 0; // 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 = { module.exports = {
refreshBalances, refreshBalances,
lookupTokenInfo, lookupTokenInfo,
invalidateBalanceCache,
getProvider, 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 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"; const BIP44_ETH_PATH = "m/44'/60'/0'/0";
@@ -15,8 +21,11 @@ const ERC20_ABI = [
]; ];
module.exports = { module.exports = {
DEBUG,
DEBUG_MNEMONIC,
ETHEREUM_MAINNET_CHAIN_ID, ETHEREUM_MAINNET_CHAIN_ID,
DEFAULT_RPC_URL, DEFAULT_RPC_URL,
DEFAULT_BLOCKSCOUT_URL,
BIP44_ETH_PATH, BIP44_ETH_PATH,
ERC20_ABI, 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) { function getAddressValueUsd(addr) {
if (!prices.ETH) return null;
let total = 0; let total = 0;
const ethBal = parseFloat(addr.balance || "0"); const ethBal = parseFloat(addr.balance || "0");
const ethPrice = prices.ETH; total += ethBal * prices.ETH;
if (ethPrice) {
total += ethBal * ethPrice;
}
for (const token of addr.tokenBalances || []) { for (const token of addr.tokenBalances || []) {
const tokenBal = parseFloat(token.balance || "0"); const tokenBal = parseFloat(token.balance || "0");
if (tokenBal > 0 && prices[token.symbol]) { if (tokenBal > 0 && prices[token.symbol]) {
@@ -53,6 +51,7 @@ function getAddressValueUsd(addr) {
} }
function getWalletValueUsd(wallet) { function getWalletValueUsd(wallet) {
if (!prices.ETH) return null;
let total = 0; let total = 0;
for (const addr of wallet.addresses) { for (const addr of wallet.addresses) {
total += getAddressValueUsd(addr); total += getAddressValueUsd(addr);
@@ -61,6 +60,7 @@ function getWalletValueUsd(wallet) {
} }
function getTotalValueUsd(wallets) { function getTotalValueUsd(wallets) {
if (!prices.ETH) return null;
let total = 0; let total = 0;
for (const wallet of wallets) { for (const wallet of wallets) {
total += getWalletValueUsd(wallet); total += getWalletValueUsd(wallet);

View File

@@ -1,6 +1,17 @@
// Known scam/fraud addresses. Checked locally before sending. // Known scam/fraud addresses. Checked locally before sending.
// This is a best-effort blocklist — it does not replace due diligence. // 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. // All addresses lowercased for comparison.
const SCAM_ADDRESSES = new Set([ const SCAM_ADDRESSES = new Set([
@@ -14,31 +25,6 @@ const SCAM_ADDRESSES = new Set([
"0x3ee18b2214aff97000d974cf647e7c347e8fa585", "0x3ee18b2214aff97000d974cf647e7c347e8fa585",
"0x55fe002aeff02f77364de339a1292923a15844b8", "0x55fe002aeff02f77364de339a1292923a15844b8",
"0x7f268357a8c2552623316e2562d90e642bb538e5", "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) { function isScamAddress(address) {

View File

@@ -1,5 +1,7 @@
// State management and extension storage persistence. // State management and extension storage persistence.
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const storageApi = const storageApi =
typeof browser !== "undefined" typeof browser !== "undefined"
? browser.storage.local ? browser.storage.local
@@ -9,7 +11,9 @@ const DEFAULT_STATE = {
hasWallet: false, hasWallet: false,
wallets: [], wallets: [],
trackedTokens: [], trackedTokens: [],
rpcUrl: "https://eth.llamarpc.com", rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
lastBalanceRefresh: 0,
}; };
const state = { const state = {
@@ -24,6 +28,8 @@ async function saveState() {
wallets: state.wallets, wallets: state.wallets,
trackedTokens: state.trackedTokens, trackedTokens: state.trackedTokens,
rpcUrl: state.rpcUrl, rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
lastBalanceRefresh: state.lastBalanceRefresh,
}; };
await storageApi.set({ autistmask: persisted }); await storageApi.set({ autistmask: persisted });
} }
@@ -36,6 +42,9 @@ async function loadState() {
state.wallets = saved.wallets || []; state.wallets = saved.wallets || [];
state.trackedTokens = saved.trackedTokens || []; state.trackedTokens = saved.trackedTokens || [];
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; 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. // All crypto delegated to ethers.js.
const { Mnemonic, HDNodeWallet, Wallet } = require("ethers"); const { Mnemonic, HDNodeWallet, Wallet } = require("ethers");
const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
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";
function generateMnemonic() { function generateMnemonic() {
if (DEBUG) return DEBUG_MNEMONIC; if (DEBUG) return DEBUG_MNEMONIC;
@@ -23,7 +18,7 @@ function deriveAddressFromXpub(xpub, index) {
} }
function hdWalletFromMnemonic(mnemonic) { 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 xpub = node.neuter().extendedKey;
const firstAddress = node.deriveChild(0).address; const firstAddress = node.deriveChild(0).address;
return { xpub, firstAddress }; return { xpub, firstAddress };
@@ -39,7 +34,7 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
const node = HDNodeWallet.fromPhrase( const node = HDNodeWallet.fromPhrase(
decryptedSecret, decryptedSecret,
"", "",
BIP44_ETH_BASE, BIP44_ETH_PATH,
); );
return node.deriveChild(addrIndex); return node.deriveChild(addrIndex);
} }
@@ -51,9 +46,6 @@ function isValidMnemonic(mnemonic) {
} }
module.exports = { module.exports = {
BIP44_ETH_BASE,
DEBUG,
DEBUG_MNEMONIC,
generateMnemonic, generateMnemonic,
deriveAddressFromXpub, deriveAddressFromXpub,
hdWalletFromMnemonic, hdWalletFromMnemonic,