Compare commits

..

2 Commits

Author SHA1 Message Date
88a0d5bc55 Merge branch 'main' into feat/issue-125-dark-mode
All checks were successful
check / check (push) Successful in 21s
2026-03-01 12:33:48 +01:00
user
30d48421e5 feat: add theme setting (Light/Dark/System) with dark mode
All checks were successful
check / check (push) Successful in 24s
Add theme preference (light/dark/system) stored in extension state.
System mode follows prefers-color-scheme and listens for changes.
Dark mode inverts the monochrome palette (white-on-black).
Theme selector added to Display section in settings.

Closes #125
2026-02-28 23:22:41 -08:00
11 changed files with 91 additions and 429 deletions

View File

@@ -437,10 +437,6 @@ transitions.
- **When**: User tapped a transaction row from AddressDetail or AddressToken.
- **Elements**:
- "Transaction" heading, "Back" button
- Type: transaction classification — one of: Native ETH Transfer, ERC-20
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
- Token contract: shown for ERC-20 transfers — color dot + full contract
address (tap to copy) + etherscan token link
- Status: "Success" or "Failed"
- Time: ISO datetime + relative age in parentheses
- Amount: value + symbol (bold)
@@ -449,11 +445,6 @@ transitions.
- To: blockie + color dot + full address (tap to copy) + etherscan link
- ENS name if available
- Transaction hash: full hash (tap to copy) + etherscan link
- Block: block number (tap to copy) + etherscan block link
- Nonce: transaction nonce (tap to copy)
- Transaction fee: ETH amount (tap to copy)
- Gas price: value in Gwei (tap to copy)
- Gas used: integer (tap to copy)
- **Transitions**:
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail**

View File

@@ -185,7 +185,7 @@
<!-- active address headline -->
<div
id="total-value"
class="text-2xl font-bold min-h-[2rem] text-fg"
class="text-2xl font-bold min-h-[2rem]"
></div>
<div
id="total-value-sub"
@@ -924,12 +924,6 @@
/>
<span class="text-xs text-muted">gwei</span>
</div>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input type="checkbox" id="settings-utc-timestamps" />
UTC Timestamps
</label>
</div>
<div class="bg-well p-3 mx-1 mb-3">
@@ -1064,134 +1058,60 @@
<h2 id="tx-detail-heading" class="font-bold mb-2">
Transaction
</h2>
<!-- ── Identity ── -->
<div class="tx-detail-group mb-1">
<div class="mb-3">
<div class="text-xs text-muted mb-1">
Transaction hash
</div>
<div
id="tx-detail-hash"
class="text-xs break-all"
></div>
</div>
<div id="tx-detail-type-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Type</div>
<div
id="tx-detail-type"
class="text-xs font-bold"
></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Status</div>
<div id="tx-detail-status" class="text-xs"></div>
</div>
<div class="mb-1">
<div class="text-xs text-muted mb-1">Time</div>
<div id="tx-detail-time" class="text-xs"></div>
</div>
<div id="tx-detail-type-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Type</div>
<div id="tx-detail-type" class="text-xs font-bold"></div>
</div>
<!-- ── Value ── -->
<div class="tx-detail-group mb-1">
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="tx-detail-value" class="text-xs"></div>
</div>
<div class="mb-3 hidden">
<div class="text-xs text-muted mb-1">
Native quantity
</div>
<div id="tx-detail-native" class="text-xs"></div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Status</div>
<div id="tx-detail-status" class="text-xs"></div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Time</div>
<div id="tx-detail-time" class="text-xs"></div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="tx-detail-value" class="text-xs"></div>
</div>
<div class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Native quantity</div>
<div id="tx-detail-native" class="text-xs"></div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">From</div>
<div id="tx-detail-from" class="text-xs break-all"></div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div>
</div>
<div id="tx-detail-calldata-section" class="mb-4 hidden">
<div
id="tx-detail-token-contract-section"
class="mb-1 hidden"
id="tx-detail-calldata-well"
class="mb-3 border border-border border-dashed p-2"
>
<div class="text-xs text-muted mb-1">
Token contract
</div>
<div class="text-xs text-muted mb-1">Action</div>
<div
id="tx-detail-token-contract"
class="text-xs break-all"
id="tx-detail-calldata-action"
class="text-xs font-bold mb-2"
></div>
<div
id="tx-detail-calldata-details"
class="text-xs"
></div>
</div>
</div>
<!-- ── Parties ── -->
<div class="tx-detail-group mb-1">
<div class="mb-3">
<div class="text-xs text-muted mb-1">From</div>
<div
id="tx-detail-from"
class="text-xs break-all"
></div>
</div>
<div class="mb-1">
<div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="tx-detail-hash" class="text-xs break-all"></div>
</div>
<!-- ── Protocol ── -->
<div id="tx-detail-calldata-section" class="mb-1 hidden">
<div class="tx-detail-group mb-1">
<div
id="tx-detail-calldata-well"
class="border border-border border-dashed p-2"
>
<div class="text-xs text-muted mb-1">Action</div>
<div
id="tx-detail-calldata-action"
class="text-xs font-bold mb-2"
></div>
<div
id="tx-detail-calldata-details"
class="text-xs"
></div>
</div>
</div>
</div>
<!-- ── On-chain details ── -->
<div
id="tx-detail-onchain-group"
class="tx-detail-group mb-1 hidden"
>
<div id="tx-detail-block-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Block</div>
<div id="tx-detail-block" class="text-xs"></div>
</div>
<div id="tx-detail-nonce-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Nonce</div>
<div id="tx-detail-nonce" class="text-xs"></div>
</div>
<div id="tx-detail-fee-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">
Transaction fee
</div>
<div id="tx-detail-fee" class="text-xs"></div>
</div>
<div id="tx-detail-gasprice-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Gas price</div>
<div id="tx-detail-gasprice" class="text-xs"></div>
</div>
<div id="tx-detail-gasused-section" class="mb-1 hidden">
<div class="text-xs text-muted mb-1">Gas used</div>
<div id="tx-detail-gasused" class="text-xs"></div>
</div>
</div>
<!-- ── Raw data ── -->
<div id="tx-detail-rawdata-section" class="mb-4 hidden">
<div class="tx-detail-group">
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="tx-detail-rawdata"
class="text-xs break-all font-mono border border-border border-dashed p-2"
></div>
</div>
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="tx-detail-rawdata"
class="text-xs break-all font-mono border border-border border-dashed p-2"
></div>
</div>
</div>

View File

@@ -18,13 +18,13 @@
html.dark {
--color-bg: #000000;
--color-fg: #ffffff;
--color-muted: #aaaaaa;
--color-muted: #999999;
--color-border: #ffffff;
--color-border-light: #444444;
--color-hover: #222222;
--color-well: #1a1a1a;
--color-danger-well: #2a0a0a;
--color-section: #2a2a2a;
--color-border-light: #333333;
--color-hover: #111111;
--color-well: #0a0a0a;
--color-danger-well: #1a0000;
--color-section: #222222;
}
body {
@@ -44,11 +44,3 @@ body {
background-color 225ms ease-out,
color 225ms ease-out;
}
/* Transaction detail view — visual grouping of related fields */
.tx-detail-group {
border-bottom: 1px solid var(--color-border-light);
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
padding-top: 0.25rem;
}

View File

@@ -95,39 +95,18 @@ function show() {
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
"T" +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds()) +
tzStr
pad(d.getSeconds())
);
}

View File

@@ -48,39 +48,18 @@ function etherscanAddressLink(address) {
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
"T" +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds()) +
tzStr
pad(d.getSeconds())
);
}

View File

@@ -228,39 +228,18 @@ function formatAddressHtml(address, ensName, maxLen, title) {
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
"T" +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds()) +
tzStr
pad(d.getSeconds())
);
}

View File

@@ -249,12 +249,6 @@ function init(ctx) {
}
});
$("settings-utc-timestamps").checked = state.utcTimestamps;
$("settings-utc-timestamps").addEventListener("change", async () => {
state.utcTimestamps = $("settings-utc-timestamps").checked;
await saveState();
});
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
$("btn-settings-add-token").addEventListener(

View File

@@ -13,7 +13,6 @@ const {
timeAgo,
} = require("./helpers");
const { state } = require("../../shared/state");
const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval");
@@ -27,25 +26,6 @@ const EXT_ICON =
let ctx;
/**
* Determine a human-readable transaction type string from tx fields.
*/
function getTransactionType(tx) {
if (!tx.to) return "Contract Creation";
if (tx.direction === "contract") {
if (tx.directionLabel === "Swap") return "Swap";
if (
tx.method === "approve" ||
tx.directionLabel === "Approve" ||
tx.method === "setApprovalForAll"
)
return "Token Approval";
return "Contract Call";
}
if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer";
return "Native ETH Transfer";
}
function copyableHtml(text, extraClass) {
const cls =
"underline decoration-dashed cursor-pointer" +
@@ -119,7 +99,6 @@ function show(tx) {
direction: tx.direction || null,
isContractCall: tx.isContractCall || false,
method: tx.method || null,
contractAddress: tx.contractAddress || null,
},
};
render();
@@ -156,56 +135,30 @@ function render() {
nativeEl.parentElement.classList.add("hidden");
}
// Always show transaction type as the first field
// Show type label for contract interactions (Swap, Execute, etc.)
const typeSection = $("tx-detail-type-section");
const typeEl = $("tx-detail-type");
const headingEl = $("tx-detail-heading");
if (typeSection && typeEl) {
typeEl.textContent = getTransactionType(tx);
typeSection.classList.remove("hidden");
if (tx.direction === "contract" && tx.directionLabel) {
if (typeSection) {
typeEl.textContent = tx.directionLabel;
typeSection.classList.remove("hidden");
}
} else {
if (typeSection) typeSection.classList.add("hidden");
}
if (headingEl) headingEl.textContent = "Transaction";
// Token contract address (for ERC-20 transfers)
const tokenContractSection = $("tx-detail-token-contract-section");
const tokenContractEl = $("tx-detail-token-contract");
if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress);
const link = `https://etherscan.io/token/${tx.contractAddress}`;
tokenContractEl.innerHTML =
`<div class="flex items-center">${dot}` +
copyableHtml(tx.contractAddress, "break-all") +
etherscanLinkHtml(link) +
`</div>`;
tokenContractSection.classList.remove("hidden");
} else {
tokenContractSection.classList.add("hidden");
}
}
// Hide calldata and raw data sections; always fetch full tx details
// Hide calldata and raw data sections; re-fetch if this is a contract call
const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section");
if (rawDataSection) rawDataSection.classList.add("hidden");
// Hide on-chain detail sections (and their group wrapper) until populated
const onchainGroup = $("tx-detail-onchain-group");
if (onchainGroup) onchainGroup.classList.add("hidden");
for (const id of [
"tx-detail-block-section",
"tx-detail-nonce-section",
"tx-detail-fee-section",
"tx-detail-gasprice-section",
"tx-detail-gasused-section",
]) {
const el = $(id);
if (el) el.classList.add("hidden");
if (tx.isContractCall || tx.direction === "contract") {
loadCalldata(tx.hash, tx.to);
}
loadFullTxDetails(tx.hash, tx.to, tx.isContractCall);
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
@@ -224,108 +177,7 @@ function render() {
});
}
function showDetailField(sectionId, contentId, value) {
const section = $(sectionId);
const el = $(contentId);
if (!section || !el) return;
el.innerHTML = copyableHtml(value, "");
section.classList.remove("hidden");
}
function populateOnChainDetails(txData) {
// Block number
if (txData.block_number != null) {
const blockLink = `https://etherscan.io/block/${txData.block_number}`;
const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block");
if (blockSection && blockEl) {
blockEl.innerHTML =
copyableHtml(String(txData.block_number), "") +
etherscanLinkHtml(blockLink);
blockSection.classList.remove("hidden");
}
}
// Nonce
if (txData.nonce != null) {
showDetailField(
"tx-detail-nonce-section",
"tx-detail-nonce",
String(txData.nonce),
);
}
// Transaction fee
const feeWei = txData.fee?.value || txData.tx_fee;
if (feeWei) {
const feeEth = formatEther(String(feeWei));
showDetailField(
"tx-detail-fee-section",
"tx-detail-fee",
feeEth + " ETH",
);
}
// Gas price
const gasPrice = txData.gas_price;
if (gasPrice) {
const gwei = formatUnits(String(gasPrice), "gwei");
showDetailField(
"tx-detail-gasprice-section",
"tx-detail-gasprice",
gwei + " Gwei",
);
}
// Gas used
const gasUsed = txData.gas_used;
if (gasUsed) {
showDetailField(
"tx-detail-gasused-section",
"tx-detail-gasused",
String(gasUsed),
);
}
// Show the on-chain details group if any child section is visible
const onchainGroup = $("tx-detail-onchain-group");
if (onchainGroup) {
const hasVisible = [
"tx-detail-block-section",
"tx-detail-nonce-section",
"tx-detail-fee-section",
"tx-detail-gasprice-section",
"tx-detail-gasused-section",
].some((id) => {
const el = $(id);
return el && !el.classList.contains("hidden");
});
if (hasVisible) {
onchainGroup.classList.remove("hidden");
}
}
// Bind copy handlers for newly added elements
for (const id of [
"tx-detail-block-section",
"tx-detail-nonce-section",
"tx-detail-fee-section",
"tx-detail-gasprice-section",
"tx-detail-gasused-section",
]) {
const section = $(id);
if (!section) continue;
section.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
}
}
async function loadFullTxDetails(txHash, toAddress, isContractCall) {
async function loadCalldata(txHash, toAddress) {
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
@@ -340,10 +192,6 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
);
if (!resp.ok) return;
const txData = await resp.json();
// Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData);
const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return;

View File

@@ -23,7 +23,6 @@ const DEFAULT_STATE = {
hideFraudContracts: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
@@ -55,7 +54,6 @@ async function saveState() {
hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions,
dustThresholdGwei: state.dustThresholdGwei,
utcTimestamps: state.utcTimestamps,
fraudContracts: state.fraudContracts,
tokenHolderCache: state.tokenHolderCache,
theme: state.theme,
@@ -112,8 +110,6 @@ async function loadState() {
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
state.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
state.fraudContracts = saved.fraudContracts || [];
state.tokenHolderCache = saved.tokenHolderCache || {};
state.theme = saved.theme || "system";

View File

@@ -153,38 +153,24 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
// When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. For contract calls (swaps), a single transaction
// can produce multiple token transfers (input, intermediates, output).
// We consolidate these into the original tx entry using the token
// transfer where the user *receives* tokens (the swap output), so
// the transaction list shows the final result rather than confusing
// intermediate hops. We preserve the original tx's from/to so the
// user sees their own address, not a router or Permit2 contract.
// amount and symbol. A single transaction (e.g. a swap) can produce
// multiple token transfers (one per token involved), so we key token
// transfers by hash + contract address to keep all of them. We also
// preserve contract-call metadata (direction, label, method) from the
// matching normal tx so swaps display correctly.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
parsed.direction = "contract";
parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true;
parsed.method = existing.method;
// Remove the bare-hash normal tx so it doesn't appear as a
// duplicate with empty value; token transfers replace it.
txsByHash.delete(parsed.hash);
}
// Non-contract token transfers get their own entries.
// Use composite key so multiple token transfers per tx are kept.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
}

View File

@@ -359,12 +359,9 @@ function decode(data, toAddress) {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
if (!inputAmount) inputAmount = s.amountIn;
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
if (!minOutput) minOutput = s.amountOutMin;
}
}
@@ -372,9 +369,9 @@ function decode(data, toAddress) {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
if (!inputAmount) inputAmount = s.amountIn;
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
if (!minOutput) minOutput = s.amountOutMin;
}
}
@@ -391,11 +388,12 @@ function decode(data, toAddress) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
if (!outputToken && v4.tokenOut)
outputToken = v4.tokenOut;
if (!inputAmount && v4.amountIn)
inputAmount = v4.amountIn;
// Always update output: last swap step wins
if (v4.tokenOut) outputToken = v4.tokenOut;
if (v4.amountOutMin) minOutput = v4.amountOutMin;
if (!minOutput && v4.amountOutMin)
minOutput = v4.amountOutMin;
}
}