Compare commits

..

1 Commits

Author SHA1 Message Date
user
cd412209a7 fix: cross-wallet duplicate address detection on import
All checks were successful
check / check (push) Successful in 22s
Previously each import method (mnemonic, private key, xprv) only checked
for duplicates among wallets of the same type. Now all three check the
derived address against ALL existing wallet addresses regardless of type,
and mnemonic/xprv imports also compare xpubs against existing HD/xprv
wallets.

Closes #111
2026-02-28 16:01:40 -08:00
3 changed files with 65 additions and 2409 deletions

View File

@@ -97,18 +97,26 @@ async function importMnemonic(ctx) {
const pw = validatePassword(); const pw = validatePassword();
if (!pw) return; if (!pw) return;
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic); const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const duplicate = state.wallets.find( const addrDup = state.wallets.find((w) =>
(w) => w.addresses.some(
w.type === "hd" && (a) => a.address.toLowerCase() === firstAddress.toLowerCase(),
w.addresses[0] && ),
w.addresses[0].address.toLowerCase() === firstAddress.toLowerCase(),
); );
if (duplicate) { if (addrDup) {
showFlash( showFlash(
"This recovery phrase is already added (" + duplicate.name + ").", "An address from this phrase already exists in " +
addrDup.name +
".",
); );
return; return;
} }
const xpubDup = state.wallets.find(
(w) => (w.type === "hd" || w.type === "xprv") && w.xpub === xpub,
);
if (xpubDup) {
showFlash("This recovery phrase matches " + xpubDup.name + ".");
return;
}
const encrypted = await encryptWithPassword(mnemonic, pw); const encrypted = await encryptWithPassword(mnemonic, pw);
const walletNum = state.wallets.length + 1; const walletNum = state.wallets.length + 1;
const wallet = { const wallet = {
@@ -162,16 +170,11 @@ async function importPrivateKey(ctx) {
} }
const pw = validatePassword(); const pw = validatePassword();
if (!pw) return; if (!pw) return;
const duplicate = state.wallets.find( const duplicate = state.wallets.find((w) =>
(w) => w.addresses.some((a) => a.address.toLowerCase() === addr.toLowerCase()),
w.type === "key" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() === addr.toLowerCase(),
); );
if (duplicate) { if (duplicate) {
showFlash( showFlash("This address already exists in " + duplicate.name + ".");
"This private key is already added (" + duplicate.name + ").",
);
return; return;
} }
const encrypted = await encryptWithPassword(key, pw); const encrypted = await encryptWithPassword(key, pw);
@@ -208,14 +211,22 @@ async function importXprvKey(ctx) {
return; return;
} }
const { xpub, firstAddress } = result; const { xpub, firstAddress } = result;
const duplicate = state.wallets.find( const addrDup = state.wallets.find((w) =>
(w) => w.addresses.some(
(w.type === "hd" || w.type === "xprv") && (a) => a.address.toLowerCase() === firstAddress.toLowerCase(),
w.addresses[0] && ),
w.addresses[0].address.toLowerCase() === firstAddress.toLowerCase(),
); );
if (duplicate) { if (addrDup) {
showFlash("This key is already added (" + duplicate.name + ")."); showFlash(
"An address from this key already exists in " + addrDup.name + ".",
);
return;
}
const xpubDup = state.wallets.find(
(w) => (w.type === "hd" || w.type === "xprv") && w.xpub === xpub,
);
if (xpubDup) {
showFlash("This key matches " + xpubDup.name + ".");
return; return;
} }
const pw = validatePassword(); const pw = validatePassword();

View File

@@ -24,7 +24,7 @@ const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { isScamAddress, isNullOrBurnAddress } = require("../../shared/scamlist"); const { isScamAddress } = require("../../shared/scamlist");
const { ERC20_ABI } = require("../../shared/constants"); const { ERC20_ABI } = require("../../shared/constants");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
@@ -38,28 +38,6 @@ const EXT_ICON =
`</svg></span>`; `</svg></span>`;
let pendingTx = null; let pendingTx = null;
// Track active warnings so async checks can append without overwriting.
let activeWarnings = [];
function renderWarnings(el, warnings) {
activeWarnings = warnings.slice();
if (warnings.length > 0) {
el.innerHTML = warnings
.map(
(w) =>
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w}</div>`,
)
.join("");
el.classList.remove("hidden");
} else {
el.classList.add("hidden");
}
}
function appendWarning(el, message) {
activeWarnings.push(message);
renderWarnings(el, activeWarnings);
}
function restore() { function restore() {
const d = state.viewData; const d = state.viewData;
@@ -187,24 +165,29 @@ function show(txInfo) {
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd); $("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
} }
// Check for warnings (synchronous checks first, async checks added later) // Check for warnings
const warnings = []; const warnings = [];
if (isScamAddress(txInfo.to)) { if (isScamAddress(txInfo.to)) {
warnings.push( warnings.push(
"This address is on a known scam/fraud list. Do not send funds to this address.", "This address is on a known scam/fraud list. Do not send funds to this address.",
); );
} }
if (isNullOrBurnAddress(txInfo.to)) {
warnings.push(
"This is a null or burn address. Funds sent here will be permanently lost.",
);
}
if (txInfo.to.toLowerCase() === txInfo.from.toLowerCase()) { if (txInfo.to.toLowerCase() === txInfo.from.toLowerCase()) {
warnings.push("You are sending to your own address."); warnings.push("You are sending to your own address.");
} }
const warningsEl = $("confirm-warnings"); const warningsEl = $("confirm-warnings");
renderWarnings(warningsEl, 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 // Check for errors
const errors = []; const errors = [];
@@ -260,11 +243,8 @@ function show(txInfo) {
state.viewData = { pendingTx: txInfo }; state.viewData = { pendingTx: txInfo };
showView("confirm-tx"); showView("confirm-tx");
// Hide the legacy recipient warning element (warnings now unified) // Reset recipient warning to hidden (space always reserved, no layout shift)
const legacyWarningEl = $("confirm-recipient-warning"); $("confirm-recipient-warning").style.visibility = "hidden";
if (legacyWarningEl) {
legacyWarningEl.style.display = "none";
}
estimateGas(txInfo); estimateGas(txInfo);
checkRecipientHistory(txInfo); checkRecipientHistory(txInfo);
@@ -311,24 +291,19 @@ async function estimateGas(txInfo) {
} }
async function checkRecipientHistory(txInfo) { async function checkRecipientHistory(txInfo) {
const warningsEl = $("confirm-warnings"); const el = $("confirm-recipient-warning");
try { try {
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
// Skip warning for contract addresses — they may legitimately
// have zero outgoing transactions (getTransactionCount returns
// the nonce, i.e. sent-tx count only).
const code = await provider.getCode(txInfo.to); const code = await provider.getCode(txInfo.to);
if (code && code !== "0x") { if (code && code !== "0x") {
// Recipient is a contract address — warn the user
appendWarning(
warningsEl,
"The recipient is a contract address. Sending tokens directly to a contract may result in permanent loss of funds.",
);
return; return;
} }
const txCount = await provider.getTransactionCount(txInfo.to); const txCount = await provider.getTransactionCount(txInfo.to);
if (txCount === 0) { if (txCount === 0) {
appendWarning( el.style.visibility = "visible";
warningsEl,
"The recipient address has ZERO transaction history. This may indicate a fresh or unused address. Double-check the address before sending.",
);
} }
} catch (e) { } catch (e) {
log.errorf("recipient history check failed:", e.message); log.errorf("recipient history check failed:", e.message);

File diff suppressed because it is too large Load Diff