All checks were successful
check / check (push) Successful in 54s
script/lint ran `prettier --check .`, byte for byte what script/fmt-check
runs, so make check checked formatting twice and did no static analysis on
a cryptocurrency wallet. Two used-but-not-imported crashes shipped past it.
ESLint is pinned in package.json with @eslint/js recommended as the base and
a flat config in eslint.config.js. no-undef and no-unused-vars are restated
error-level so a future recommended-set change cannot downgrade them.
Globals are declared per tree rather than globally, because a too-wide set
hides the next unimported identifier: browser for the popup and content
scripts, service worker for src/background/ and src/shared/, browser for the
one documented POPUP ONLY module in src/shared/, jest for tests/, node for
build.js, and both for the e2e harnesses, which carry the callbacks they
ship into the page inline.
Two rules new to the recommended set are narrowed, and both would have cost
something to satisfy. no-useless-assignment is off for approval.js and
confirmTx.js only: it flags the `password = null` and `decryptedSecret =
null` wipes at 9 sites there, which are dead by construction — that is what
a best-effort wipe of decrypted key material is — and the rule's fix is to
delete the wipe. It stays on for the rest of the tree, so an ordinary dead
store elsewhere is still an error. preserve-caught-error is off tree-wide:
it would change what the wallet's error paths throw at 3 sites
(src/shared/balances.js 207 and 215, tests/e2e/firefox/run.js 131), and
adopting `{ cause }` is a decision of its own rather than a side effect of
turning a linter on, so new code is not held to it either pending that
decision.
Every remaining violation is fixed: 41 unused bindings and 53 undefined
identifiers. Unused catch bindings became `catch {`, which the repo already
used; the shared init(ctx) view signature keeps its parameter as _ctx in the
three views that do not read it. src/shared/uniswap.js keeps its unused
V2_SWAP_EXACT_OUT decoder behind a scoped disable, because deleting it would
widen the gap it represents rather than close it (#283). driver.js's waitFor
had a plain dead store in its `last` initializer, which the newly scoped
no-useless-assignment catches; the initializer is dropped.
Linting is containerized. script/lint builds the Dockerfile's new lint stage
so the ESLint deciding whether this repo is green is the pinned one and not
whatever the host has; AUTISTMASK_LINT_NATIVE, set only in that image, is
what makes make check inside the CI build lint in place instead of recursing
into docker, and a value set to anything else is now an error rather than a
silent fall-through to the docker path. The check stage takes a COPY --from=
lint dependency so a lint failure fails the whole build early rather than
racing it.
The lint stage roughly doubles the image build, which exposed script/test's
30s cap as marginal rather than a bound: on the first CI run to rebuild the
base stage cold it killed a healthy suite at 30.6s with nothing asserting
false. The cap is a guard against a hung suite, not a wall-clock budget, and
one a healthy suite can trip teaches "just run it again". It stays at 30s on
a host, where the suite runs in about 8s and REPO_POLICIES' figure holds,
and the Dockerfile raises it to 180s through AUTISTMASK_TEST_TIMEOUT for the
in-image run, which also pays a cold jest cache and shares the runner with
the rest of the build. script/test now names a timeout kill as one instead
of reporting it as a test failure, and skips the verbose rerun in that case,
which would only spend the same wall clock to be killed again.
No --fix anywhere in the lint path: make check remains non-mutating.
The README claim that a used-but-not-imported identifier is invisible to
make check, and the same claim in script/test-e2e, are no longer true and
are corrected.
445 lines
16 KiB
JavaScript
445 lines
16 KiB
JavaScript
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
balanceLinesForAddress,
|
|
isoDate,
|
|
timeAgo,
|
|
addressDotHtml,
|
|
addressTitle,
|
|
escapeHtml,
|
|
truncateMiddle,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
pushCurrentView,
|
|
} = require("./helpers");
|
|
const { state, saveState, currentAddress } = require("../../shared/state");
|
|
const {
|
|
updateSendBalance,
|
|
renderSendTokenSelect,
|
|
resetSendValidation,
|
|
} = require("./send");
|
|
const { deriveAddressFromXpub } = require("../../shared/wallet");
|
|
const { canRemoveAddress } = require("../../shared/walletDelete");
|
|
const {
|
|
walletDefect,
|
|
walletDefectHtml,
|
|
} = require("../../shared/walletDefects");
|
|
const {
|
|
formatUsd,
|
|
getPrice,
|
|
getAddressValueUsd,
|
|
} = require("../../shared/prices");
|
|
const {
|
|
fetchRecentTransactions,
|
|
filterTransactions,
|
|
} = require("../../shared/transactions");
|
|
const { log } = require("../../shared/log");
|
|
|
|
function findActiveAddr() {
|
|
for (const w of state.wallets) {
|
|
for (const a of w.addresses) {
|
|
if (a.address === state.activeAddress) return a;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function renderTotalValue() {
|
|
const el = $("total-value");
|
|
const subEl = $("total-value-sub");
|
|
const priceEl = $("eth-price-display");
|
|
if (!el) return;
|
|
|
|
const ethPrice = getPrice("ETH");
|
|
if (priceEl) {
|
|
priceEl.innerHTML = ethPrice
|
|
? formatUsd(ethPrice) + " USD/ETH"
|
|
: " ";
|
|
}
|
|
|
|
const addr = findActiveAddr();
|
|
if (!addr) {
|
|
el.innerHTML = " ";
|
|
if (subEl) subEl.innerHTML = " ";
|
|
return;
|
|
}
|
|
const ethBal = parseFloat(addr.balance || "0");
|
|
const ethStr = ethBal.toFixed(4) + " ETH";
|
|
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
|
|
el.textContent = ethStr + ethUsd;
|
|
|
|
if (subEl) {
|
|
const totalUsd = getAddressValueUsd(addr);
|
|
subEl.innerHTML =
|
|
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : " ";
|
|
}
|
|
}
|
|
|
|
function renderActiveAddress() {
|
|
const el = $("active-address-display");
|
|
if (!el) return;
|
|
if (state.activeAddress) {
|
|
el.innerHTML = renderAddressHtml(state.activeAddress);
|
|
attachCopyHandlers(el);
|
|
} else {
|
|
el.textContent = "";
|
|
}
|
|
}
|
|
|
|
let homeTxs = [];
|
|
|
|
function renderHomeTxList(ctx) {
|
|
const list = $("home-tx-list");
|
|
if (!list) return;
|
|
if (homeTxs.length === 0) {
|
|
list.innerHTML =
|
|
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
|
return;
|
|
}
|
|
let html = "";
|
|
let i = 0;
|
|
for (const tx of homeTxs) {
|
|
// For swap transactions, show the user's own labelled wallet
|
|
// address (the one that initiated the swap) instead of the
|
|
// contract address which is not useful in the list view.
|
|
const counterparty =
|
|
tx.direction === "contract" && tx.directionLabel === "Swap"
|
|
? tx.from
|
|
: tx.direction === "sent" || tx.direction === "contract"
|
|
? tx.to
|
|
: tx.from;
|
|
const dirLabel = tx.directionLabel;
|
|
const amountStr = tx.value
|
|
? escapeHtml(tx.value + " " + tx.symbol)
|
|
: escapeHtml(tx.symbol);
|
|
const title = addressTitle(counterparty, state.wallets);
|
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
|
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
|
|
const addrStr = escapeHtml(displayAddr);
|
|
const dot = addressDotHtml(counterparty);
|
|
const err = tx.isError ? " (failed)" : "";
|
|
const opacity = tx.isError ? " opacity:0.5;" : "";
|
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
|
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
|
html += `</div>`;
|
|
i++;
|
|
}
|
|
list.innerHTML = html;
|
|
list.querySelectorAll(".home-tx-row").forEach((row) => {
|
|
row.addEventListener("click", () => {
|
|
const idx = parseInt(row.dataset.tx, 10);
|
|
const tx = homeTxs[idx];
|
|
// Set selectedWallet/selectedAddress so back navigation works
|
|
for (let wi = 0; wi < state.wallets.length; wi++) {
|
|
for (
|
|
let ai = 0;
|
|
ai < state.wallets[wi].addresses.length;
|
|
ai++
|
|
) {
|
|
const addr = state.wallets[wi].addresses[ai].address;
|
|
if (
|
|
addr.toLowerCase() === tx.from.toLowerCase() ||
|
|
addr.toLowerCase() === tx.to.toLowerCase()
|
|
) {
|
|
state.selectedWallet = wi;
|
|
state.selectedAddress = ai;
|
|
state.selectedToken = null;
|
|
ctx.showTransactionDetail(tx);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadHomeTxs(ctx) {
|
|
const allAddresses = [];
|
|
for (const w of state.wallets) {
|
|
for (const a of w.addresses) {
|
|
allAddresses.push(a.address);
|
|
}
|
|
}
|
|
if (allAddresses.length === 0) return;
|
|
|
|
const filters = {
|
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
|
hideLowHolderTokens: state.hideLowHolderTokens,
|
|
hideFraudContracts: state.hideFraudContracts,
|
|
hideDustTransactions: state.hideDustTransactions,
|
|
dustThresholdGwei: state.dustThresholdGwei,
|
|
fraudContracts: state.fraudContracts,
|
|
};
|
|
|
|
try {
|
|
const fetches = allAddresses.map((addr) =>
|
|
fetchRecentTransactions(addr, state.blockscoutUrl),
|
|
);
|
|
const results = await Promise.all(fetches);
|
|
|
|
// Merge, deduplicate by hash, filter, sort, take 25
|
|
const seen = new Set();
|
|
let merged = [];
|
|
for (const txs of results) {
|
|
for (const tx of txs) {
|
|
if (seen.has(tx.hash)) continue;
|
|
seen.add(tx.hash);
|
|
merged.push(tx);
|
|
}
|
|
}
|
|
|
|
const filtered = filterTransactions(merged, filters);
|
|
|
|
// Persist any newly discovered fraud contracts
|
|
if (filtered.newFraudContracts.length > 0) {
|
|
for (const addr of filtered.newFraudContracts) {
|
|
if (!state.fraudContracts.includes(addr)) {
|
|
state.fraudContracts.push(addr);
|
|
}
|
|
}
|
|
await saveState();
|
|
}
|
|
|
|
merged = filtered.transactions;
|
|
merged.sort((a, b) => b.blockNumber - a.blockNumber);
|
|
homeTxs = merged.slice(0, 25);
|
|
renderHomeTxList(ctx);
|
|
} catch (e) {
|
|
log.errorf("loadHomeTxs failed:", e.message);
|
|
const list = $("home-tx-list");
|
|
if (list) {
|
|
list.innerHTML =
|
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
|
}
|
|
}
|
|
}
|
|
|
|
// The wallet list markup. Pure: it reads state and returns a string, so the
|
|
// list can be asserted on without a DOM.
|
|
function walletListHtml() {
|
|
let html = "";
|
|
state.wallets.forEach((wallet, wi) => {
|
|
const defect = walletDefect(wallet);
|
|
html += `<div>`;
|
|
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
|
|
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
|
|
// No "+" on a defective wallet: deriving another address from that
|
|
// xpub would only add one more address the key does not produce
|
|
// under the standard path.
|
|
if (!defect && (wallet.type === "hd" || wallet.type === "xprv")) {
|
|
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
|
|
}
|
|
html += `</div>`;
|
|
html += walletDefectHtml(wallet);
|
|
|
|
wallet.addresses.forEach((addr, ai) => {
|
|
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
|
|
const isActive = state.activeAddress === addr.address;
|
|
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg" style="padding:0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
|
|
// Only where a wallet can spare the address: a wallet holding a
|
|
// single address has no remove control, because its last address
|
|
// is never removable.
|
|
const removeBtn = canRemoveAddress(wallet)
|
|
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1" style="padding:0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
|
|
: "";
|
|
const dot = addressDotHtml(addr.address);
|
|
const titleBold = isActive ? "font-bold" : "";
|
|
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
|
|
if (addr.ensName) {
|
|
html += `<div class="text-xs font-bold flex items-center">${dot}${addr.ensName}</div>`;
|
|
}
|
|
html += `<div class="flex text-xs items-center justify-between">`;
|
|
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`;
|
|
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
|
|
html += `</div>`;
|
|
const addrUsd = formatUsd(getAddressValueUsd(addr));
|
|
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || " "}</div>`;
|
|
html += balanceLinesForAddress(
|
|
addr,
|
|
state.trackedTokens,
|
|
state.showZeroBalanceTokens,
|
|
);
|
|
html += `</div>`;
|
|
});
|
|
|
|
html += `</div>`;
|
|
});
|
|
return html;
|
|
}
|
|
|
|
function render(ctx) {
|
|
const container = $("wallet-list");
|
|
if (state.wallets.length === 0) {
|
|
container.innerHTML =
|
|
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
|
|
renderTotalValue();
|
|
renderActiveAddress();
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = walletListHtml();
|
|
|
|
container.querySelectorAll(".address-row").forEach((row) => {
|
|
row.addEventListener("click", async () => {
|
|
const wi = parseInt(row.dataset.wallet, 10);
|
|
const ai = parseInt(row.dataset.address, 10);
|
|
const addr = state.wallets[wi].addresses[ai].address;
|
|
if (state.activeAddress !== addr) {
|
|
state.activeAddress = addr;
|
|
await saveState();
|
|
render(ctx);
|
|
const runtime =
|
|
typeof browser !== "undefined"
|
|
? browser.runtime
|
|
: chrome.runtime;
|
|
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
|
}
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".btn-addr-info").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
state.selectedWallet = parseInt(btn.dataset.wallet, 10);
|
|
state.selectedAddress = parseInt(btn.dataset.address, 10);
|
|
ctx.showAddressDetail();
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".btn-remove-address").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
ctx.showDeleteAddress(
|
|
parseInt(btn.dataset.wallet, 10),
|
|
parseInt(btn.dataset.address, 10),
|
|
);
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".btn-add-address").forEach((btn) => {
|
|
btn.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
const wi = parseInt(btn.dataset.wallet, 10);
|
|
const wallet = state.wallets[wi];
|
|
const newAddr = deriveAddressFromXpub(
|
|
wallet.xpub,
|
|
wallet.nextIndex,
|
|
);
|
|
wallet.addresses.push({
|
|
address: newAddr,
|
|
balance: "0.0000",
|
|
tokenBalances: [],
|
|
});
|
|
wallet.nextIndex++;
|
|
await saveState();
|
|
render(ctx);
|
|
ctx.doRefreshAndRender();
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".wallet-name").forEach((span) => {
|
|
span.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const wi = parseInt(span.dataset.wallet, 10);
|
|
const wallet = state.wallets[wi];
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.value = wallet.name;
|
|
input.className =
|
|
"font-bold border border-border p-0 bg-bg text-fg";
|
|
input.style.width = "100%";
|
|
const save = async () => {
|
|
const val = input.value.trim();
|
|
if (val && val !== wallet.name) {
|
|
wallet.name = val;
|
|
await saveState();
|
|
}
|
|
render(ctx);
|
|
};
|
|
input.addEventListener("blur", save);
|
|
input.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Enter") input.blur();
|
|
if (ev.key === "Escape") {
|
|
input.value = wallet.name;
|
|
input.blur();
|
|
}
|
|
});
|
|
span.replaceWith(input);
|
|
input.focus();
|
|
input.select();
|
|
});
|
|
});
|
|
|
|
renderTotalValue();
|
|
renderActiveAddress();
|
|
loadHomeTxs(ctx);
|
|
}
|
|
|
|
// The defect of the wallet the selected address belongs to, or null. Call
|
|
// after selectActiveAddress().
|
|
function selectedWalletDefect() {
|
|
if (state.selectedWallet === null) return null;
|
|
return walletDefect(state.wallets[state.selectedWallet]);
|
|
}
|
|
|
|
function selectActiveAddress() {
|
|
for (let wi = 0; wi < state.wallets.length; wi++) {
|
|
for (let ai = 0; ai < state.wallets[wi].addresses.length; ai++) {
|
|
if (
|
|
state.wallets[wi].addresses[ai].address === state.activeAddress
|
|
) {
|
|
state.selectedWallet = wi;
|
|
state.selectedAddress = ai;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function init(ctx) {
|
|
$("btn-add-wallet-bottom").addEventListener("click", ctx.showAddWalletView);
|
|
|
|
$("btn-main-send").addEventListener("click", () => {
|
|
if (!selectActiveAddress()) {
|
|
showFlash("No active address selected.");
|
|
return;
|
|
}
|
|
// Before the balance check and before any password is asked for: this
|
|
// wallet cannot sign at all, so the send screen is a dead end.
|
|
const defect = selectedWalletDefect();
|
|
if (defect) {
|
|
showFlash(defect.shortMessage);
|
|
return;
|
|
}
|
|
const addr = currentAddress();
|
|
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
|
showFlash("Cannot send \u2014 zero balance.");
|
|
return;
|
|
}
|
|
$("send-to").value = "";
|
|
$("send-amount").value = "";
|
|
$("send-token").classList.remove("hidden");
|
|
$("send-token-static").classList.add("hidden");
|
|
renderSendTokenSelect(addr);
|
|
updateSendBalance();
|
|
resetSendValidation();
|
|
pushCurrentView();
|
|
showView("send");
|
|
});
|
|
|
|
$("btn-main-receive").addEventListener("click", () => {
|
|
if (!selectActiveAddress()) {
|
|
showFlash("No active address selected.");
|
|
return;
|
|
}
|
|
ctx.showReceive();
|
|
});
|
|
}
|
|
|
|
module.exports = { init, render, walletListHtml };
|