Token auto-discovery, tx history, balance polling, EIP-6963, UI overhaul
All checks were successful
check / check (push) Successful in 14s
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:
25
README.md
25
README.md
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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"
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
< 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"
|
||||||
|
>
|
||||||
|
< 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"
|
||||||
|
>
|
||||||
|
< 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"
|
||||||
|
>
|
||||||
|
< 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"
|
||||||
|
>
|
||||||
|
< 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"
|
||||||
|
>
|
||||||
|
< 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"
|
||||||
|
>
|
||||||
|
< 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"
|
||||||
|
>
|
||||||
|
< 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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
30
src/shared/log.js
Normal 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 };
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
108
src/shared/transactions.js
Normal 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 };
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user