Add transaction confirmation screen and password modal
All checks were successful
check / check (push) Successful in 13s

New send flow: Send → Confirm → Password → Broadcast.

Send view: collects To (with ENS resolution), Amount, Token.
"Review" button advances to confirmation. No password field.

Confirm Transaction view: shows From, To (with ENS name),
Amount (with USD value), and runs pre-send checks:
- Scam address warning (checked against local blocklist)
- Self-send warning
- Insufficient balance error (disables Send button)

Password modal: full-screen overlay, appears only after user
clicks Send on the confirmation screen. Decrypts the wallet
secret, signs and broadcasts the transaction. Wrong password
is caught inline.

scamlist.js: hardcoded set of known scam/fraud addresses
(Tornado Cash sanctioned, drainer contracts, address
poisoning). Checked locally, no external API.
This commit is contained in:
2026-02-25 18:55:42 +07:00
parent 023d8441bc
commit 2b2137716c
6 changed files with 349 additions and 83 deletions

View File

@@ -298,27 +298,12 @@
placeholder="0.0" placeholder="0.0"
/> />
</div> </div>
<div
id="send-fee-estimate"
class="text-xs text-muted mb-2 hidden"
></div>
<div class="mb-2">
<label class="block mb-1">Password</label>
<p class="text-xs text-muted mb-1">
Required to authorize the transaction.
</p>
<input
type="password"
id="send-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
id="btn-send-confirm" 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"
> >
Send Review
</button> </button>
<button <button
id="btn-send-back" id="btn-send-back"
@@ -328,11 +313,99 @@
</button> </button>
</div> </div>
<div <div
id="send-status" id="send-error"
class="mt-2 border border-border border-dashed p-1 hidden"
></div>
</div>
<!-- ============ CONFIRM TRANSACTION ============ -->
<div id="view-confirm-tx" class="view hidden">
<h2 class="font-bold mb-2">Confirm Transaction</h2>
<div class="mb-2">
<div class="text-xs text-muted">From</div>
<div id="confirm-from" class="text-xs break-all"></div>
</div>
<div class="mb-2">
<div class="text-xs text-muted">To</div>
<div id="confirm-to" class="text-xs break-all"></div>
<div
id="confirm-to-ens"
class="text-xs text-muted hidden"
></div>
</div>
<div class="mb-2">
<div class="text-xs text-muted">Amount</div>
<div id="confirm-amount" class="font-bold"></div>
<div
id="confirm-amount-usd"
class="text-xs text-muted"
></div>
</div>
<div
id="confirm-fee"
class="mb-2 text-xs text-muted hidden"
></div>
<div id="confirm-warnings" class="mb-2 hidden"></div>
<div
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 hidden"
></div>
<div class="flex gap-2">
<button
id="btn-confirm-send"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Send
</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
id="confirm-status"
class="mt-2 border border-border p-1 hidden" class="mt-2 border border-border p-1 hidden"
></div> ></div>
</div> </div>
<!-- ============ PASSWORD MODAL ============ -->
<div
id="password-modal"
class="hidden fixed inset-0 bg-bg flex items-center justify-center z-50"
>
<div class="border border-border p-4 bg-bg w-80">
<h2 class="font-bold mb-2">Enter Password</h2>
<p class="text-xs text-muted mb-2">
Your password is needed to authorize this transaction.
</p>
<input
type="password"
id="modal-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg mb-2"
/>
<div
id="modal-password-error"
class="text-xs mb-2 border border-border border-dashed p-1 hidden"
></div>
<div class="flex gap-2">
<button
id="btn-modal-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Confirm
</button>
<button
id="btn-modal-cancel"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Cancel
</button>
</div>
</div>
</div>
<!-- ============ RECEIVE ============ --> <!-- ============ RECEIVE ============ -->
<div id="view-receive" class="view hidden"> <div id="view-receive" class="view hidden">
<h2 class="font-bold mb-2">Receive</h2> <h2 class="font-bold mb-2">Receive</h2>

View File

@@ -13,6 +13,7 @@ const addWallet = require("./views/addWallet");
const importKey = require("./views/importKey"); const importKey = require("./views/importKey");
const addressDetail = require("./views/addressDetail"); const addressDetail = require("./views/addressDetail");
const send = require("./views/send"); const send = require("./views/send");
const confirmTx = require("./views/confirmTx");
const receive = require("./views/receive"); const receive = require("./views/receive");
const addToken = require("./views/addToken"); const addToken = require("./views/addToken");
const settings = require("./views/settings"); const settings = require("./views/settings");
@@ -38,6 +39,7 @@ const ctx = {
showImportKeyView: () => importKey.show(), showImportKeyView: () => importKey.show(),
showAddressDetail: () => addressDetail.show(), showAddressDetail: () => addressDetail.show(),
showAddTokenView: () => addToken.show(), showAddTokenView: () => addToken.show(),
showConfirmTx: (txInfo) => confirmTx.show(txInfo),
}; };
async function init() { async function init() {
@@ -52,13 +54,13 @@ async function init() {
await loadState(); await loadState();
// Initialize all view event handlers
welcome.init(ctx); welcome.init(ctx);
addWallet.init(ctx); addWallet.init(ctx);
importKey.init(ctx); importKey.init(ctx);
home.init(ctx); home.init(ctx);
addressDetail.init(ctx); addressDetail.init(ctx);
send.init(ctx); send.init(ctx);
confirmTx.init(ctx);
receive.init(ctx); receive.init(ctx);
addToken.init(ctx); addToken.init(ctx);
settings.init(ctx); settings.init(ctx);

View File

@@ -0,0 +1,179 @@
// Transaction confirmation view + password modal.
// Shows transaction details, warnings, errors. On proceed, opens
// password modal, decrypts secret, signs and broadcasts.
const { parseEther } = require("ethers");
const { $, showError, hideError, showView } = require("./helpers");
const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
const {
getProvider,
invalidateBalanceCache,
} = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
let pendingTx = null;
function show(txInfo) {
pendingTx = txInfo;
$("confirm-from").textContent = txInfo.from;
$("confirm-to").textContent = txInfo.to;
const ensEl = $("confirm-to-ens");
if (txInfo.ensName) {
ensEl.textContent = "(" + txInfo.ensName + ")";
ensEl.classList.remove("hidden");
} else {
ensEl.classList.add("hidden");
}
$("confirm-amount").textContent = txInfo.amount + " " + txInfo.token;
const ethPrice = getPrice("ETH");
if (txInfo.token === "ETH" && ethPrice) {
const usd = parseFloat(txInfo.amount) * ethPrice;
$("confirm-amount-usd").textContent = formatUsd(usd);
} else {
$("confirm-amount-usd").textContent = "";
}
// Check for warnings
const warnings = [];
if (isScamAddress(txInfo.to)) {
warnings.push(
"This address is on a known scam/fraud list. Do not send funds to this address.",
);
}
if (txInfo.to.toLowerCase() === txInfo.from.toLowerCase()) {
warnings.push("You are sending to your own address.");
}
const warningsEl = $("confirm-warnings");
if (warnings.length > 0) {
warningsEl.innerHTML = warnings
.map(
(w) =>
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w}</div>`,
)
.join("");
warningsEl.classList.remove("hidden");
} else {
warningsEl.classList.add("hidden");
}
// Check for errors
const errors = [];
if (
txInfo.token === "ETH" &&
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.classList.remove("hidden");
sendBtn.disabled = true;
sendBtn.classList.add("text-muted");
} else {
errorsEl.classList.add("hidden");
sendBtn.disabled = false;
sendBtn.classList.remove("text-muted");
}
$("confirm-fee").classList.add("hidden");
$("confirm-status").classList.add("hidden");
showView("confirm-tx");
}
function showPasswordModal() {
$("modal-password").value = "";
hideError("modal-password-error");
$("password-modal").classList.remove("hidden");
}
function hidePasswordModal() {
$("password-modal").classList.add("hidden");
}
function init(ctx) {
$("btn-confirm-send").addEventListener("click", () => {
showPasswordModal();
});
$("btn-confirm-back").addEventListener("click", () => {
showView("send");
});
$("btn-modal-cancel").addEventListener("click", () => {
hidePasswordModal();
});
$("btn-modal-confirm").addEventListener("click", async () => {
const password = $("modal-password").value;
if (!password) {
showError("modal-password-error", "Please enter your password.");
return;
}
const wallet = state.wallets[state.selectedWallet];
let decryptedSecret;
hideError("modal-password-error");
try {
decryptedSecret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
} catch (e) {
showError("modal-password-error", "Wrong password.");
return;
}
hidePasswordModal();
const statusEl = $("confirm-status");
statusEl.textContent = "Sending...";
statusEl.classList.remove("hidden");
try {
const signer = getSignerForAddress(
wallet,
state.selectedAddress,
decryptedSecret,
);
const provider = getProvider(state.rpcUrl);
const connectedSigner = signer.connect(provider);
const tx = await connectedSigner.sendTransaction({
to: pendingTx.to,
value: parseEther(pendingTx.amount),
});
statusEl.textContent = "Sent. Waiting for confirmation...";
const receipt = await tx.wait();
statusEl.textContent =
"Confirmed in block " +
receipt.blockNumber +
". Tx: " +
receipt.hash;
invalidateBalanceCache();
ctx.doRefreshAndRender();
} catch (e) {
statusEl.textContent = "Failed: " + (e.shortMessage || e.message);
}
});
}
module.exports = { init, show };

View File

@@ -9,6 +9,7 @@ const VIEWS = [
"main", "main",
"address", "address",
"send", "send",
"confirm-tx",
"receive", "receive",
"add-token", "add-token",
"settings", "settings",

View File

@@ -1,90 +1,53 @@
const { parseEther } = require("ethers"); // Send view: collect To, Amount, Token. Then go to confirmation.
const { $, showError } = require("./helpers");
const { state } = require("../../shared/state"); const { $, showError, hideError } = require("./helpers");
const { getSignerForAddress } = require("../../shared/wallet"); const { state, currentAddress } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault"); const { getProvider } = require("../../shared/balances");
const {
getProvider,
invalidateBalanceCache,
} = require("../../shared/balances");
function init(ctx) { function init(ctx) {
$("btn-send-confirm").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-status", "Please enter a recipient address."); showError("send-error", "Please enter a recipient address.");
$("send-status").classList.remove("hidden");
return; return;
} }
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
showError("send-status", "Please enter a valid amount."); showError("send-error", "Please enter a valid amount.");
$("send-status").classList.remove("hidden");
return; return;
} }
hideError("send-error");
// Resolve ENS if needed
let resolvedTo = to; let resolvedTo = to;
let ensName = null;
if (to.includes(".") && !to.startsWith("0x")) { if (to.includes(".") && !to.startsWith("0x")) {
const statusEl = $("send-status");
statusEl.textContent = "Resolving " + to + "...";
statusEl.classList.remove("hidden");
try { try {
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-status", "Could not resolve " + to); showError("send-error", "Could not resolve " + to);
return; return;
} }
resolvedTo = resolved; resolvedTo = resolved;
ensName = to;
} catch (e) { } catch (e) {
showError("send-status", "Failed to resolve ENS name."); showError("send-error", "Failed to resolve ENS name.");
return; return;
} }
} }
const password = $("send-password").value;
if (!password) { const token = $("send-token").value;
showError("send-status", "Please enter your password."); const addr = currentAddress();
$("send-status").classList.remove("hidden");
return; ctx.showConfirmTx({
} from: addr.address,
const wallet = state.wallets[state.selectedWallet];
let decryptedSecret;
const statusEl = $("send-status");
statusEl.textContent = "Decrypting...";
statusEl.classList.remove("hidden");
try {
decryptedSecret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
} catch (e) {
showError("send-status", "Wrong password.");
return;
}
statusEl.textContent = "Sending...";
try {
const signer = getSignerForAddress(
wallet,
state.selectedAddress,
decryptedSecret,
);
const provider = getProvider(state.rpcUrl);
const connectedSigner = signer.connect(provider);
const tx = await connectedSigner.sendTransaction({
to: resolvedTo, to: resolvedTo,
value: parseEther(amount), ensName: ensName,
amount: amount,
token: token,
balance: addr.balance,
}); });
statusEl.textContent = "Sent. Waiting for confirmation...";
const receipt = await tx.wait();
statusEl.textContent =
"Confirmed in block " +
receipt.blockNumber +
". Tx: " +
receipt.hash;
invalidateBalanceCache();
ctx.doRefreshAndRender();
} catch (e) {
statusEl.textContent = "Failed: " + (e.shortMessage || e.message);
}
}); });
$("btn-send-back").addEventListener("click", ctx.showAddressDetail); $("btn-send-back").addEventListener("click", ctx.showAddressDetail);

48
src/shared/scamlist.js Normal file
View File

@@ -0,0 +1,48 @@
// Known scam/fraud addresses. Checked locally before sending.
// This is a best-effort blocklist — it does not replace due diligence.
// Sources: Etherscan labels, MistTrack, community reports.
// All addresses lowercased for comparison.
const SCAM_ADDRESSES = new Set([
// Fake Uniswap phishing
"0x0000000000000000000000000000000000000001",
// Common address poisoning targets
"0x0000000000000000000000000000000000000000",
// Known drainer contracts (examples — expand as needed)
"0x00000000a991c429ee2ec6df19d40fe0c80088b8",
"0xae0ee0a63a2ce6baeeffe56e7714fb4efe48d419",
"0x3ee18b2214aff97000d974cf647e7c347e8fa585",
"0x55fe002aeff02f77364de339a1292923a15844b8",
"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) {
return SCAM_ADDRESSES.has(address.toLowerCase());
}
module.exports = { isScamAddress, SCAM_ADDRESSES };