Loading...
';
@@ -272,7 +283,8 @@ function showTxDetail(tx) {
});
}
-function init(ctx) {
+function init(_ctx) {
+ ctx = _ctx;
$("address-full").addEventListener("click", () => {
const addr = $("address-full").dataset.full;
if (addr) {
@@ -297,6 +309,7 @@ function init(ctx) {
}
$("send-to").value = "";
$("send-amount").value = "";
+ $("send-token").disabled = false;
updateSendBalance();
showView("send");
});
@@ -318,7 +331,11 @@ function init(ctx) {
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
$("btn-tx-back").addEventListener("click", () => {
- show();
+ if (state.selectedToken) {
+ ctx.showAddressToken();
+ } else {
+ show();
+ }
});
}
diff --git a/src/popup/views/addressToken.js b/src/popup/views/addressToken.js
new file mode 100644
index 0000000..a9c9999
--- /dev/null
+++ b/src/popup/views/addressToken.js
@@ -0,0 +1,370 @@
+// Address-token detail view: shows a single token's balance and
+// filtered transactions for the selected address.
+
+const {
+ $,
+ showView,
+ showFlash,
+ addressDotHtml,
+ escapeHtml,
+ truncateMiddle,
+ balanceLine,
+} = require("./helpers");
+const { state, currentAddress, saveState } = require("../../shared/state");
+const {
+ formatUsd,
+ getPrice,
+ getAddressValueUsd,
+} = require("../../shared/prices");
+const {
+ fetchRecentTransactions,
+ filterTransactions,
+} = require("../../shared/transactions");
+const { resolveEnsNames } = require("../../shared/ens");
+const { updateSendBalance, renderSendTokenSelect } = require("./send");
+const { log } = require("../../shared/log");
+const QRCode = require("qrcode");
+const makeBlockie = require("ethereum-blockies-base64");
+
+const EXT_ICON =
+ `Loading...
';
+ showView("address-token");
+ loadTransactions(addr.address, tokenId);
+}
+
+async function loadTransactions(address, tokenId) {
+ try {
+ const rawTxs = await fetchRecentTransactions(
+ address,
+ state.blockscoutUrl,
+ );
+ const result = filterTransactions(rawTxs, {
+ hideLowHolderTokens: state.hideLowHolderTokens,
+ hideFraudContracts: state.hideFraudContracts,
+ hideDustTransactions: state.hideDustTransactions,
+ dustThresholdGwei: state.dustThresholdGwei,
+ fraudContracts: state.fraudContracts,
+ });
+ let txs = result.transactions;
+
+ // Persist any newly discovered fraud contracts
+ if (result.newFraudContracts.length > 0) {
+ for (const addr of result.newFraudContracts) {
+ if (!state.fraudContracts.includes(addr)) {
+ state.fraudContracts.push(addr);
+ }
+ }
+ await saveState();
+ }
+
+ // Filter to this token only
+ if (tokenId === "ETH") {
+ txs = txs.filter((tx) => tx.contractAddress === null);
+ } else {
+ txs = txs.filter(
+ (tx) =>
+ tx.contractAddress &&
+ tx.contractAddress.toLowerCase() === tokenId.toLowerCase(),
+ );
+ }
+
+ loadedTxs = txs;
+
+ // Collect unique counterparty addresses for ENS resolution
+ const counterparties = [
+ ...new Set(
+ txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
+ ),
+ ];
+ if (counterparties.length > 0) {
+ try {
+ ensNameMap = await resolveEnsNames(
+ counterparties,
+ state.rpcUrl,
+ );
+ } catch {
+ ensNameMap = new Map();
+ }
+ }
+
+ renderTransactions(txs);
+ } catch (e) {
+ log.errorf("loadTransactions failed:", e.message);
+ $("address-token-tx-list").innerHTML =
+ 'Failed to load transactions.
';
+ }
+}
+
+function renderTransactions(txs) {
+ const list = $("address-token-tx-list");
+ if (txs.length === 0) {
+ list.innerHTML =
+ 'No transactions found.
';
+ return;
+ }
+ let html = "";
+ let i = 0;
+ for (const tx of txs) {
+ const counterparty = tx.direction === "sent" ? tx.to : tx.from;
+ const ensName = ensNameMap.get(counterparty) || null;
+ const dirLabel = tx.direction === "sent" ? "Sent" : "Received";
+ const amountStr = escapeHtml(tx.value + " " + tx.symbol);
+ const maxAddr = Math.max(10, 36 - Math.max(0, amountStr.length - 10));
+ const displayAddr = ensName || 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 += ``;
+ html += `
${ago}${dirLabel}${err}
`;
+ html += `
${dot}${addrStr}${amountStr}
`;
+ html += `
`;
+ i++;
+ }
+ list.innerHTML = html;
+ list.querySelectorAll(".tx-row").forEach((row) => {
+ row.addEventListener("click", () => {
+ const idx = parseInt(row.dataset.tx, 10);
+ showTxDetail(loadedTxs[idx]);
+ });
+ });
+}
+
+function copyableHtml(text, extraClass) {
+ const cls =
+ "underline decoration-dashed cursor-pointer" +
+ (extraClass ? " " + extraClass : "");
+ return `${dot}` +
+ copyableHtml(ensName, "") +
+ extLink +
+ `
` +
+ `${dot}` +
+ copyableHtml(address, "break-all") +
+ extLink +
+ `
`;
+ }
+ return html;
+}
+
+function txDetailHashHtml(hash) {
+ const link = etherscanTxLink(hash);
+ const extLink = `` +
+ `
` +
`` +
`${symbol}` +
`${qty}` +
@@ -91,18 +96,29 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
"ETH",
parseFloat(addr.balance || "0"),
getPrice("ETH"),
+ "ETH",
);
const seen = new Set();
for (const t of addr.tokenBalances || []) {
const bal = parseFloat(t.balance || "0");
if (bal === 0 && !showZero) continue;
- html += balanceLine(t.symbol, bal, getPrice(t.symbol));
+ html += balanceLine(
+ t.symbol,
+ bal,
+ getPrice(t.symbol),
+ t.address.toLowerCase(),
+ );
seen.add(t.address.toLowerCase());
}
if (showZero && trackedTokens) {
for (const t of trackedTokens) {
if (seen.has(t.address.toLowerCase())) continue;
- html += balanceLine(t.symbol, 0, getPrice(t.symbol));
+ html += balanceLine(
+ t.symbol,
+ 0,
+ getPrice(t.symbol),
+ t.address.toLowerCase(),
+ );
}
}
return html;
@@ -193,6 +209,7 @@ module.exports = {
hideError,
showView,
showFlash,
+ balanceLine,
balanceLinesForAddress,
addressColor,
addressDotHtml,
diff --git a/src/popup/views/home.js b/src/popup/views/home.js
index 8f722c2..c97b569 100644
--- a/src/popup/views/home.js
+++ b/src/popup/views/home.js
@@ -403,6 +403,7 @@ function init(ctx) {
}
$("send-to").value = "";
$("send-amount").value = "";
+ $("send-token").disabled = false;
renderSendTokenSelect(addr);
updateSendBalance();
showView("send");
diff --git a/src/popup/views/receive.js b/src/popup/views/receive.js
index 706b28b..40d5b8b 100644
--- a/src/popup/views/receive.js
+++ b/src/popup/views/receive.js
@@ -1,4 +1,5 @@
const { $, showFlash } = require("./helpers");
+const { state } = require("../../shared/state");
function init(ctx) {
$("btn-receive-copy").addEventListener("click", () => {
@@ -9,7 +10,13 @@ function init(ctx) {
}
});
- $("btn-receive-back").addEventListener("click", ctx.showAddressDetail);
+ $("btn-receive-back").addEventListener("click", () => {
+ if (state.selectedToken) {
+ ctx.showAddressToken();
+ } else {
+ ctx.showAddressDetail();
+ }
+ });
}
module.exports = { init };
diff --git a/src/popup/views/send.js b/src/popup/views/send.js
index e148a64..4001d91 100644
--- a/src/popup/views/send.js
+++ b/src/popup/views/send.js
@@ -2,6 +2,7 @@
const { $, showFlash, formatAddressHtml } = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
+let ctx;
const { getProvider } = require("../../shared/balances");
const { KNOWN_SYMBOLS } = require("../../shared/tokens");
@@ -53,7 +54,8 @@ function updateSendBalance() {
}
}
-function init(ctx) {
+function init(_ctx) {
+ ctx = _ctx;
$("send-token").addEventListener("change", updateSendBalance);
$("btn-send-review").addEventListener("click", async () => {
@@ -100,7 +102,14 @@ function init(ctx) {
});
});
- $("btn-send-back").addEventListener("click", ctx.showAddressDetail);
+ $("btn-send-back").addEventListener("click", () => {
+ $("send-token").disabled = false;
+ if (state.selectedToken) {
+ ctx.showAddressToken();
+ } else {
+ ctx.showAddressDetail();
+ }
+ });
}
module.exports = { init, updateSendBalance, renderSendTokenSelect };
diff --git a/src/shared/state.js b/src/shared/state.js
index 955a181..1f47b00 100644
--- a/src/shared/state.js
+++ b/src/shared/state.js
@@ -31,6 +31,7 @@ const state = {
...DEFAULT_STATE,
selectedWallet: null,
selectedAddress: null,
+ selectedToken: null,
};
async function saveState() {