Add transaction confirmation screen and password modal
All checks were successful
check / check (push) Successful in 13s
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:
@@ -298,27 +298,12 @@
|
||||
placeholder="0.0"
|
||||
/>
|
||||
</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">
|
||||
<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"
|
||||
>
|
||||
Send
|
||||
Review
|
||||
</button>
|
||||
<button
|
||||
id="btn-send-back"
|
||||
@@ -328,11 +313,99 @@
|
||||
</button>
|
||||
</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"
|
||||
></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 ============ -->
|
||||
<div id="view-receive" class="view hidden">
|
||||
<h2 class="font-bold mb-2">Receive</h2>
|
||||
|
||||
@@ -13,6 +13,7 @@ const addWallet = require("./views/addWallet");
|
||||
const importKey = require("./views/importKey");
|
||||
const addressDetail = require("./views/addressDetail");
|
||||
const send = require("./views/send");
|
||||
const confirmTx = require("./views/confirmTx");
|
||||
const receive = require("./views/receive");
|
||||
const addToken = require("./views/addToken");
|
||||
const settings = require("./views/settings");
|
||||
@@ -38,6 +39,7 @@ const ctx = {
|
||||
showImportKeyView: () => importKey.show(),
|
||||
showAddressDetail: () => addressDetail.show(),
|
||||
showAddTokenView: () => addToken.show(),
|
||||
showConfirmTx: (txInfo) => confirmTx.show(txInfo),
|
||||
};
|
||||
|
||||
async function init() {
|
||||
@@ -52,13 +54,13 @@ async function init() {
|
||||
|
||||
await loadState();
|
||||
|
||||
// Initialize all view event handlers
|
||||
welcome.init(ctx);
|
||||
addWallet.init(ctx);
|
||||
importKey.init(ctx);
|
||||
home.init(ctx);
|
||||
addressDetail.init(ctx);
|
||||
send.init(ctx);
|
||||
confirmTx.init(ctx);
|
||||
receive.init(ctx);
|
||||
addToken.init(ctx);
|
||||
settings.init(ctx);
|
||||
|
||||
179
src/popup/views/confirmTx.js
Normal file
179
src/popup/views/confirmTx.js
Normal 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 };
|
||||
@@ -9,6 +9,7 @@ const VIEWS = [
|
||||
"main",
|
||||
"address",
|
||||
"send",
|
||||
"confirm-tx",
|
||||
"receive",
|
||||
"add-token",
|
||||
"settings",
|
||||
|
||||
@@ -1,90 +1,53 @@
|
||||
const { parseEther } = require("ethers");
|
||||
const { $, showError } = require("./helpers");
|
||||
const { state } = require("../../shared/state");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const {
|
||||
getProvider,
|
||||
invalidateBalanceCache,
|
||||
} = require("../../shared/balances");
|
||||
// Send view: collect To, Amount, Token. Then go to confirmation.
|
||||
|
||||
const { $, showError, hideError } = require("./helpers");
|
||||
const { state, currentAddress } = require("../../shared/state");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
|
||||
function init(ctx) {
|
||||
$("btn-send-confirm").addEventListener("click", async () => {
|
||||
$("btn-send-review").addEventListener("click", async () => {
|
||||
const to = $("send-to").value.trim();
|
||||
const amount = $("send-amount").value.trim();
|
||||
if (!to) {
|
||||
showError("send-status", "Please enter a recipient address.");
|
||||
$("send-status").classList.remove("hidden");
|
||||
showError("send-error", "Please enter a recipient address.");
|
||||
return;
|
||||
}
|
||||
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
|
||||
showError("send-status", "Please enter a valid amount.");
|
||||
$("send-status").classList.remove("hidden");
|
||||
showError("send-error", "Please enter a valid amount.");
|
||||
return;
|
||||
}
|
||||
hideError("send-error");
|
||||
|
||||
// Resolve ENS if needed
|
||||
let resolvedTo = to;
|
||||
let ensName = null;
|
||||
if (to.includes(".") && !to.startsWith("0x")) {
|
||||
const statusEl = $("send-status");
|
||||
statusEl.textContent = "Resolving " + to + "...";
|
||||
statusEl.classList.remove("hidden");
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const resolved = await provider.resolveName(to);
|
||||
if (!resolved) {
|
||||
showError("send-status", "Could not resolve " + to);
|
||||
showError("send-error", "Could not resolve " + to);
|
||||
return;
|
||||
}
|
||||
resolvedTo = resolved;
|
||||
ensName = to;
|
||||
} catch (e) {
|
||||
showError("send-status", "Failed to resolve ENS name.");
|
||||
showError("send-error", "Failed to resolve ENS name.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const password = $("send-password").value;
|
||||
if (!password) {
|
||||
showError("send-status", "Please enter your password.");
|
||||
$("send-status").classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
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,
|
||||
value: parseEther(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);
|
||||
}
|
||||
|
||||
const token = $("send-token").value;
|
||||
const addr = currentAddress();
|
||||
|
||||
ctx.showConfirmTx({
|
||||
from: addr.address,
|
||||
to: resolvedTo,
|
||||
ensName: ensName,
|
||||
amount: amount,
|
||||
token: token,
|
||||
balance: addr.balance,
|
||||
});
|
||||
});
|
||||
|
||||
$("btn-send-back").addEventListener("click", ctx.showAddressDetail);
|
||||
|
||||
48
src/shared/scamlist.js
Normal file
48
src/shared/scamlist.js
Normal 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 };
|
||||
Reference in New Issue
Block a user