All checks were successful
check / check (push) Successful in 25s
## Summary Fixes the view stack pop bug where pressing Back in Settings (or any view) always returned to Main instead of the previous view. Closes [issue #134](#134) ## Problem The popup UI had no navigation stack. Every back button was hardcoded to a specific destination (usually Main). The reported path: > Main → Address → Transaction → Settings (gear icon) → Back ...would go to Main instead of returning to the Transaction view. ## Solution Implemented a proper view navigation stack (like iOS) as already described in the README: - **`viewStack`** array added to persisted state — survives popup close/reopen - **`pushCurrentView()`** — pushes the current view name onto the stack before any forward navigation - **`goBack()`** — pops the stack and shows the previous view; falls back to Main if the stack is empty; re-renders the wallet list when returning to Main - **`clearViewStack()`** — resets the stack for root transitions (e.g., after adding/deleting a wallet) ### What Changed 1. **helpers.js** — Added navigation stack functions (`pushCurrentView`, `goBack`, `clearViewStack`, `setRenderMain`) 2. **state.js** — Added `viewStack` to persisted state 3. **index.js** — All `ctx.show*()` wrappers now push before navigating forward; gear button uses stack for toggle behavior 4. **All view back buttons** — Replaced hardcoded destinations with `goBack()` (settings, addressDetail, addressToken, transactionDetail, send, receive, addToken, confirmTx, addWallet, settingsAddToken, deleteWallet, export-privkey) 5. **Direct `showView()` forward navigations** — Added `pushCurrentView()` calls before `showView("send")` in addressDetail, addressToken, and home; before `showView("export-privkey")` in addressDetail; before `deleteWallet.show()` in settings 6. **Reset-to-root transitions** — `clearViewStack()` called after adding a wallet (all 3 import types), after deleting the last wallet, and after transaction completion (Done button) ### Navigation Paths Verified - **Main → Settings → Back** → returns to Main ✓ - **Main → Address → Settings → Back** → returns to Address ✓ - **Main → Address → Transaction → Settings → Back** → returns to Transaction ✓ (the reported bug) - **Main → Address → Token → Send → ConfirmTx → Back → Back → Back → Back** → unwinds correctly through each view back to Main ✓ - **Main → Address → Token → Transaction → Settings → Back** → returns to Transaction ✓ - **Settings → Add Wallet → (add) → Main** → stack cleared, fresh root ✓ - **Settings → Delete Wallet → Back** → returns to Settings ✓ - **Settings → Delete Wallet → (confirm)** → stack reset to [main], settings shown ✓ - **Address → Send → ConfirmTx → (broadcast) → SuccessTx → Done** → stack reset, returns to address context ✓ - **Popup close/reopen** → viewStack persisted, back navigation still works ✓ Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: #146 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
364 lines
12 KiB
JavaScript
364 lines
12 KiB
JavaScript
// Transaction confirmation view with inline password.
|
|
// Shows transaction details, warnings, errors. On Sign & Send,
|
|
// reads inline password, decrypts secret, signs and broadcasts.
|
|
|
|
const {
|
|
parseEther,
|
|
parseUnits,
|
|
formatEther,
|
|
formatUnits,
|
|
Contract,
|
|
} = require("ethers");
|
|
const {
|
|
$,
|
|
showError,
|
|
hideError,
|
|
showView,
|
|
showFlash,
|
|
flashCopyFeedback,
|
|
addressTitle,
|
|
escapeHtml,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
goBack,
|
|
} = require("./helpers");
|
|
const { state, currentNetwork } = require("../../shared/state");
|
|
const { getSignerForAddress } = require("../../shared/wallet");
|
|
const { decryptWithPassword } = require("../../shared/vault");
|
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
|
const { getProvider } = require("../../shared/balances");
|
|
const {
|
|
getLocalWarnings,
|
|
getFullWarnings,
|
|
} = require("../../shared/addressWarnings");
|
|
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
|
|
const { log } = require("../../shared/log");
|
|
const makeBlockie = require("ethereum-blockies-base64");
|
|
const txStatus = require("./txStatus");
|
|
|
|
let pendingTx = null;
|
|
|
|
function restore() {
|
|
const d = state.viewData;
|
|
if (d && d.pendingTx) {
|
|
show(d.pendingTx);
|
|
}
|
|
}
|
|
|
|
function blockieHtml(address) {
|
|
const src = makeBlockie(address);
|
|
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
|
|
}
|
|
|
|
function confirmAddressHtml(address, ensName, title) {
|
|
const blockie = blockieHtml(address);
|
|
return (
|
|
`<div class="mb-1">${blockie}</div>` +
|
|
renderAddressHtml(address, { title, ensName })
|
|
);
|
|
}
|
|
|
|
function valueWithUsd(text, usdAmount) {
|
|
if (usdAmount !== null && usdAmount !== undefined && !isNaN(usdAmount)) {
|
|
return text + " (" + formatUsd(usdAmount) + ")";
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function show(txInfo) {
|
|
pendingTx = txInfo;
|
|
|
|
const isErc20 = txInfo.token !== "ETH";
|
|
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
|
|
|
// Transaction type
|
|
if (isErc20) {
|
|
$("confirm-type").textContent =
|
|
"ERC-20 token transfer (" + symbol + ")";
|
|
} else {
|
|
$("confirm-type").textContent = "Native ETH transfer";
|
|
}
|
|
|
|
// Token contract section (ERC-20 only)
|
|
const tokenSection = $("confirm-token-section");
|
|
if (isErc20) {
|
|
$("confirm-token-contract").innerHTML = renderAddressHtml(
|
|
txInfo.token,
|
|
{},
|
|
);
|
|
tokenSection.classList.remove("hidden");
|
|
attachCopyHandlers(tokenSection);
|
|
} else {
|
|
tokenSection.classList.add("hidden");
|
|
}
|
|
|
|
// From (with blockie)
|
|
const fromTitle = addressTitle(txInfo.from, state.wallets);
|
|
$("confirm-from").innerHTML = confirmAddressHtml(
|
|
txInfo.from,
|
|
null,
|
|
fromTitle,
|
|
);
|
|
|
|
// To (with blockie)
|
|
const toTitle = addressTitle(txInfo.to, state.wallets);
|
|
$("confirm-to").innerHTML = confirmAddressHtml(
|
|
txInfo.to,
|
|
txInfo.ensName,
|
|
toTitle,
|
|
);
|
|
$("confirm-to-ens").classList.add("hidden");
|
|
|
|
// Amount (with inline USD)
|
|
const ethPrice = getPrice("ETH");
|
|
const tokenPrice = getPrice(symbol);
|
|
const amountNum = parseFloat(txInfo.amount);
|
|
const price = isErc20 ? tokenPrice : ethPrice;
|
|
const amountUsd = price ? amountNum * price : null;
|
|
$("confirm-amount").textContent = valueWithUsd(
|
|
txInfo.amount + " " + symbol,
|
|
amountUsd,
|
|
);
|
|
|
|
// Balance (with inline USD)
|
|
if (isErc20) {
|
|
const bal = txInfo.tokenBalance || "0";
|
|
const balUsd = tokenPrice ? parseFloat(bal) * tokenPrice : null;
|
|
$("confirm-balance").textContent = valueWithUsd(
|
|
bal + " " + symbol,
|
|
balUsd,
|
|
);
|
|
} else {
|
|
const bal = txInfo.balance || "0";
|
|
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
|
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
|
|
}
|
|
|
|
// Check for warnings (synchronous local checks)
|
|
const localWarnings = getLocalWarnings(txInfo.to, {
|
|
fromAddress: txInfo.from,
|
|
});
|
|
|
|
const warningsEl = $("confirm-warnings");
|
|
if (localWarnings.length > 0) {
|
|
warningsEl.innerHTML = localWarnings
|
|
.map(
|
|
(w) =>
|
|
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w.message}</div>`,
|
|
)
|
|
.join("");
|
|
warningsEl.style.visibility = "visible";
|
|
} else {
|
|
warningsEl.innerHTML = "";
|
|
warningsEl.style.visibility = "hidden";
|
|
}
|
|
|
|
// Check for errors
|
|
const errors = [];
|
|
if (isErc20) {
|
|
const tokenBal = parseFloat(txInfo.tokenBalance || "0");
|
|
if (parseFloat(txInfo.amount) > tokenBal) {
|
|
errors.push(
|
|
"Insufficient " +
|
|
symbol +
|
|
" balance. You have " +
|
|
txInfo.tokenBalance +
|
|
" " +
|
|
symbol +
|
|
" but are trying to send " +
|
|
txInfo.amount +
|
|
" " +
|
|
symbol +
|
|
".",
|
|
);
|
|
}
|
|
} else if (parseFloat(txInfo.amount) > parseFloat(txInfo.balance)) {
|
|
errors.push(
|
|
"Insufficient balance. You have " +
|
|
txInfo.balance +
|
|
" ETH but are trying to send " +
|
|
txInfo.amount +
|
|
" ETH.",
|
|
);
|
|
}
|
|
|
|
const errorsEl = $("confirm-errors");
|
|
const sendBtn = $("btn-confirm-send");
|
|
if (errors.length > 0) {
|
|
errorsEl.innerHTML = errors
|
|
.map((e) => `<div class="text-xs">${e}</div>`)
|
|
.join("");
|
|
errorsEl.style.visibility = "visible";
|
|
sendBtn.disabled = true;
|
|
sendBtn.classList.add("text-muted");
|
|
} else {
|
|
errorsEl.innerHTML = "";
|
|
errorsEl.style.visibility = "hidden";
|
|
sendBtn.disabled = false;
|
|
sendBtn.classList.remove("text-muted");
|
|
}
|
|
|
|
// Reset password field and error
|
|
$("confirm-tx-password").value = "";
|
|
hideError("confirm-tx-password-error");
|
|
|
|
// Gas estimate — show placeholder then fetch async
|
|
$("confirm-fee").style.visibility = "visible";
|
|
$("confirm-fee-amount").textContent = "Estimating...";
|
|
state.viewData = { pendingTx: txInfo };
|
|
showView("confirm-tx");
|
|
attachCopyHandlers("view-confirm-tx");
|
|
|
|
// Reset async warnings to hidden (space always reserved, no layout shift)
|
|
$("confirm-recipient-warning").style.visibility = "hidden";
|
|
$("confirm-contract-warning").style.visibility = "hidden";
|
|
$("confirm-burn-warning").style.visibility = "hidden";
|
|
$("confirm-etherscan-warning").style.visibility = "hidden";
|
|
|
|
// Show burn warning via reserved element (in addition to inline warning)
|
|
if (isBurnAddress(txInfo.to)) {
|
|
$("confirm-burn-warning").style.visibility = "visible";
|
|
}
|
|
|
|
estimateGas(txInfo);
|
|
checkRecipientHistory(txInfo);
|
|
}
|
|
|
|
async function estimateGas(txInfo) {
|
|
try {
|
|
const provider = getProvider(state.rpcUrl);
|
|
const feeData = await provider.getFeeData();
|
|
const gasPrice = feeData.gasPrice;
|
|
let gasLimit;
|
|
|
|
if (txInfo.token === "ETH") {
|
|
gasLimit = await provider.estimateGas({
|
|
from: txInfo.from,
|
|
to: txInfo.to,
|
|
value: parseEther(txInfo.amount),
|
|
});
|
|
} else {
|
|
const contract = new Contract(txInfo.token, ERC20_ABI, provider);
|
|
const decimals = await contract.decimals();
|
|
const amount = parseUnits(txInfo.amount, decimals);
|
|
gasLimit = await contract.transfer.estimateGas(txInfo.to, amount, {
|
|
from: txInfo.from,
|
|
});
|
|
}
|
|
|
|
const gasCostWei = gasLimit * gasPrice;
|
|
const gasCostEth = formatEther(gasCostWei);
|
|
// Format to 6 significant decimal places
|
|
const parts = gasCostEth.split(".");
|
|
const dec =
|
|
parts.length > 1
|
|
? parts[1].slice(0, 6).replace(/0+$/, "") || "0"
|
|
: "0";
|
|
const feeStr = parts[0] + "." + dec + " ETH";
|
|
const ethPrice = getPrice("ETH");
|
|
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null;
|
|
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd);
|
|
} catch (e) {
|
|
log.errorf("gas estimation failed:", e.message);
|
|
$("confirm-fee-amount").textContent = "Unable to estimate";
|
|
}
|
|
}
|
|
|
|
async function checkRecipientHistory(txInfo) {
|
|
try {
|
|
const provider = getProvider(state.rpcUrl);
|
|
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
|
|
fromAddress: txInfo.from,
|
|
});
|
|
for (const w of asyncWarnings) {
|
|
if (w.type === "contract") {
|
|
$("confirm-contract-warning").style.visibility = "visible";
|
|
}
|
|
if (w.type === "new-address") {
|
|
$("confirm-recipient-warning").style.visibility = "visible";
|
|
}
|
|
if (w.type === "etherscan-phishing") {
|
|
$("confirm-etherscan-warning").style.visibility = "visible";
|
|
}
|
|
}
|
|
} catch (e) {
|
|
log.errorf("recipient history check failed:", e.message);
|
|
}
|
|
}
|
|
|
|
function init(ctx) {
|
|
$("btn-confirm-send").addEventListener("click", async () => {
|
|
const password = $("confirm-tx-password").value;
|
|
if (!password) {
|
|
showError(
|
|
"confirm-tx-password-error",
|
|
"Please enter your password.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const wallet = state.wallets[state.selectedWallet];
|
|
let decryptedSecret;
|
|
hideError("confirm-tx-password-error");
|
|
|
|
try {
|
|
decryptedSecret = await decryptWithPassword(
|
|
wallet.encryptedSecret,
|
|
password,
|
|
);
|
|
} catch (e) {
|
|
showError("confirm-tx-password-error", "Wrong password.");
|
|
return;
|
|
}
|
|
|
|
$("btn-confirm-send").disabled = true;
|
|
$("btn-confirm-send").classList.add("text-muted");
|
|
|
|
let tx;
|
|
try {
|
|
const signer = getSignerForAddress(
|
|
wallet,
|
|
state.selectedAddress,
|
|
decryptedSecret,
|
|
);
|
|
const provider = getProvider(state.rpcUrl);
|
|
const connectedSigner = signer.connect(provider);
|
|
|
|
if (pendingTx.token === "ETH") {
|
|
tx = await connectedSigner.sendTransaction({
|
|
to: pendingTx.to,
|
|
value: parseEther(pendingTx.amount),
|
|
});
|
|
} else {
|
|
const contract = new Contract(
|
|
pendingTx.token,
|
|
ERC20_ABI,
|
|
connectedSigner,
|
|
);
|
|
const decimals = await contract.decimals();
|
|
const amount = parseUnits(pendingTx.amount, decimals);
|
|
tx = await contract.transfer(pendingTx.to, amount);
|
|
}
|
|
|
|
// Best-effort: clear decrypted secret after use.
|
|
// Note: JS strings are immutable; this nulls the reference but
|
|
// the original string may persist in memory until GC.
|
|
decryptedSecret = null;
|
|
txStatus.showWait(pendingTx, tx.hash);
|
|
} catch (e) {
|
|
decryptedSecret = null;
|
|
const hash = tx ? tx.hash : null;
|
|
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
|
|
} finally {
|
|
$("btn-confirm-send").disabled = false;
|
|
$("btn-confirm-send").classList.remove("text-muted");
|
|
}
|
|
});
|
|
|
|
$("btn-confirm-back").addEventListener("click", () => {
|
|
goBack();
|
|
});
|
|
}
|
|
|
|
module.exports = { init, show, restore };
|