feat(aegis): 0.24.0 — ask the chain which derivation path holds the funds

A seed import offered exactly one prefilled path and no alternatives, so a
seed from a wallet on a different path derived a valid but empty address and
reported a 0 balance. Nothing distinguished "wrong path" from "empty wallet",
which is why repeated imports of a funded wallet all looked identical.

Both real cases hit it. Bitcoin.com derives mainnet BCH from coin type 0' —
its Copay lineage predates the 145' split — while Aegis prefilled
m/44'/145'/0'/0/0. And chipnet tooling derives from 145', while Aegis
prefilled BIP44's testnet 1'.

- A preset dropdown per coin and network, each entry naming the wallets that
  path belongs to, with the free-text field kept for anything unlisted.
- "Check which path has my funds" derives each candidate and asks Electrum
  for balance and history, five addresses deep per candidate so a wallet
  whose first address is spent clean is still found. Results are listed with
  balances and a one-click Use; when nothing matches, it says so instead of
  implying the wallet is empty.
- The chipnet IMPORT prefill becomes 145'. The vault-derived chipnet account
  path stays m/44'/1'/0' — changing that would move the addresses of wallets
  that already exist.

The scan derives, queries and returns counts; it stores nothing, and the
mnemonic crosses the same boundary importWallet already uses.
This commit is contained in:
Local Dev 2026-10-02 00:39:54 +02:00
parent c82aad16f3
commit 0b4ddee8c5
3 changed files with 212 additions and 4 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.23.0", "version": "0.24.0",
"category": "plugin", "category": "plugin",
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.",
"author": "Silent Mode", "author": "Silent Mode",

View file

@ -2058,6 +2058,107 @@ function registerPanelMessages(api) {
return fullState(); return fullState();
}); });
// ---- seed import: which derivation path actually holds the funds? -------
// The import form prefilled exactly one path, so a seed from a wallet that
// used a different one imported a valid, empty address and reported 0 with
// no way to tell "wrong path" from "empty wallet". Both real cases hit it:
// Bitcoin.com derives mainnet BCH from coin type 0' (its Copay lineage
// predates the 145' split), and chipnet tooling generally uses 145' rather
// than BIP44's testnet 1' — which is what Aegis defaulted chipnet to.
//
// So ask the chain instead of guessing. Each candidate is scanned for
// history and balance, and the panel reports what it found.
const SEED_PATH_CANDIDATES = {
"bch/mainnet": [
{ path: "m/44'/145'/0'/0/0", note: "BCH standard — Electron Cash, Zapit, newer Bitcoin.com" },
{ path: "m/44'/0'/0'/0/0", note: "BTC coin type — Bitcoin.com, Copay, BitPay" },
{ path: "m/44'/145'/0'", note: "account node — some QR exports" },
{ path: "m/0'/0/0", note: "pre-BIP44" },
],
"bch/chipnet": [
{ path: "m/44'/145'/0'/0/0", note: "BCH coin type on chipnet — most chipnet tooling" },
{ path: "m/44'/1'/0'/0/0", note: "BIP44 testnet coin type" },
{ path: "m/44'/145'/0'", note: "account node" },
],
};
api.onMessage("seedPathCandidates", (p, m) => {
fromPanel(m);
const key = `${String(p && p.chain || "")}/${String(p && p.network || "")}`;
return { key, candidates: SEED_PATH_CANDIDATES[key] || [] };
});
api.onMessage("scanSeedPaths", async (p, m) => {
fromPanel(m);
const chain = String(p && p.chain || "bch");
const network = String(p && p.network || "");
const cands = SEED_PATH_CANDIDATES[`${chain}/${network}`];
if (!cands) throw new Error(`Path scanning is not available for ${chain} ${network} yet.`);
const mnemonic = String(p && p.mnemonic || "").trim();
if (!mnemonic) throw new Error("seed phrase required");
// Same conversion importWallet does. Never stored here — this handler
// derives, queries and returns counts, nothing else.
let seedHex;
try { seedHex = ctx.d.derive.mnemonicToSeedHex(mnemonic); }
catch (e) { throw new Error("That does not look like a BIP39 seed phrase: " + (e?.message || e)); }
const prefix = network === "mainnet" ? "bitcoincash" : "bchtest";
const servers = network === "mainnet"
? bchServerList(api)
: ["wss://chipnet.imaginary.cash:50004", "wss://chipnet.bch.ninja:50004"];
const client = new ctx.d.electrum.Client(servers);
// Electrum keys a script by sha256(scriptPubKey), little-endian.
const scripthashOf = (addr) => {
const d = ctx.d.cashaddr.decode(addr);
const h = Buffer.from(d.hash);
const spk = d.type === 1
? Buffer.concat([Buffer.from([0xa9, 0x14]), h, Buffer.from([0x87])])
: Buffer.concat([Buffer.from([0x76, 0xa9, 0x14]), h, Buffer.from([0x88, 0xac])]);
return Buffer.from(ctx.d.sha256(new Uint8Array(spk))).reverse().toString("hex");
};
// Look a few addresses deep per candidate: a wallet whose first receive
// address is spent clean still has history, and funds often sit further
// along the branch.
const DEPTH = 5;
const out = [];
try {
for (const c of cands) {
const acct = wcAccountPath(c.path) || c.path;
const probes = [];
for (let i = 0; i < DEPTH; i++) probes.push(`${acct}/0/${i}`);
// The exact path the user would be importing, in case it is a leaf
// outside the account/0/i shape we probe.
if (!probes.includes(c.path) && /\/\d+$/.test(c.path)) probes.unshift(c.path);
let balance = 0, txCount = 0, firstAddress = null, fundedAddress = null;
for (const [idx, pth] of probes.entries()) {
let addr;
try { addr = deriveCashaddrFromSeed(seedHex, pth, prefix); }
catch { continue; }
if (idx === 0 || firstAddress === null) firstAddress = firstAddress || addr;
const sh = scripthashOf(addr);
try {
const bal = await client.call("blockchain.scripthash.get_balance", [sh]);
const got = Number(bal?.confirmed || 0) + Number(bal?.unconfirmed || 0);
if (got > 0 && !fundedAddress) fundedAddress = addr;
balance += got;
const hist = await client.call("blockchain.scripthash.get_history", [sh]);
txCount += Array.isArray(hist) ? hist.length : 0;
} catch (e) { api.log("scanSeedPaths:", pth, e?.message || e); }
}
out.push({
path: c.path, note: c.note, accountPath: acct,
firstAddress, fundedAddress, balance, txCount, scanned: probes.length,
});
}
} finally { try { client.disconnect(); } catch {} }
const meta = chainMeta(chain, network);
return {
chain, network, decimals: meta?.decimals || 8, ticker: meta?.ticker || "BCH",
results: out,
// Rank for the panel: funds first, then any history at all.
best: out.slice().sort((a, b) => (b.balance - a.balance) || (b.txCount - a.txCount))[0]?.path || null,
};
});
api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); }); api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); });
api.onMessage("revoke", (p, m) => { api.onMessage("revoke", (p, m) => {
fromPanel(m); fromPanel(m);

View file

@ -1426,7 +1426,15 @@ const IMPORT_COIN_CONFIG = {
bch: { bch: {
label: "Bitcoin Cash", logo: "bch", label: "Bitcoin Cash", logo: "bch",
networks: [ networks: [
{ id: "chipnet", label: "Chipnet testnet", defaultPath: "m/44'/1'/0'/0/0", testnet: true }, // Chipnet prefills the BCH coin type, not BIP44's testnet 1'. Chipnet
// is a BCH testnet and the tooling around it (keystore exports,
// faucets, test wallets) overwhelmingly derives from 145'; prefilling
// 1' sent every chipnet seed import to an empty address. 1' is still
// one of the presets, and the scan finds whichever is real.
// NOTE: this is the IMPORT prefill only. Vault-derived chipnet wallets
// keep m/44'/1'/0' (chain-bch.js) — changing that would move the
// addresses of wallets that already exist.
{ id: "chipnet", label: "Chipnet testnet", defaultPath: "m/44'/145'/0'/0/0", testnet: true },
{ id: "mainnet", label: "Mainnet", defaultPath: "m/44'/145'/0'/0/0" }, { id: "mainnet", label: "Mainnet", defaultPath: "m/44'/145'/0'/0/0" },
], ],
formats: [ formats: [
@ -1733,7 +1741,17 @@ function openImportModal(initialChain) {
</div> </div>
<textarea id="imMnemonic" spellcheck="false" rows="2" style="font-family:ui-monospace,monospace;font-size:12px" placeholder="paste the seed phrase"></textarea> <textarea id="imMnemonic" spellcheck="false" rows="2" style="font-family:ui-monospace,monospace;font-size:12px" placeholder="paste the seed phrase"></textarea>
<div class="lbl" id="imPathLabel" style="margin-top:6px">Derivation path</div> <div class="lbl" id="imPathLabel" style="margin-top:6px">Derivation path</div>
<!-- One prefilled path was the whole bug: a seed from a wallet on a
different path imports a valid but empty address, reports 0, and
gives no way to tell a wrong path from an empty wallet. Presets
name the wallets each path belongs to; the scan below asks the
chain which one actually holds coins. -->
<select id="imPathPreset" style="width:100%;padding:7px 9px;border-radius:7px;background:var(--panel);border:1px solid var(--line);color:var(--ink);font-size:13px;margin-bottom:6px" hidden></select>
<input type="text" id="imPath" spellcheck="false" placeholder="m/…"> <input type="text" id="imPath" spellcheck="false" placeholder="m/…">
<div id="imScanRow" style="margin-top:6px" hidden>
<button class="btn sm" id="imScanBtn" type="button">Check which path has my funds</button>
</div>
<div id="imScanOut"></div>
<div class="hint" id="imPathHint"></div> <div class="hint" id="imPathHint"></div>
</div> </div>
<div class="field" id="imRawField" hidden> <div class="field" id="imRawField" hidden>
@ -1775,10 +1793,17 @@ function openImportModal(initialChain) {
overlay.querySelector("#imHeaderLogo").innerHTML = logoSvg(cfg.logo, 22); overlay.querySelector("#imHeaderLogo").innerHTML = logoSvg(cfg.logo, 22);
netGroup.innerHTML = cfg.networks.map((n, i) => `<label><input type="radio" name="imNet" value="${esc(n.id)}" ${i === 0 ? "checked" : ""}> ${esc(n.label)}${n.testnet ? " " + testnetTag() : ""}</label>`).join(""); netGroup.innerHTML = cfg.networks.map((n, i) => `<label><input type="radio" name="imNet" value="${esc(n.id)}" ${i === 0 ? "checked" : ""}> ${esc(n.label)}${n.testnet ? " " + testnetTag() : ""}</label>`).join("");
fmtGroup.innerHTML = cfg.formats.map((f, i) => `<label><input type="radio" name="imKind" value="${esc(f.id)}" ${i === 0 ? "checked" : ""}> ${esc(f.label)}</label>`).join(""); fmtGroup.innerHTML = cfg.formats.map((f, i) => `<label><input type="radio" name="imKind" value="${esc(f.id)}" ${i === 0 ? "checked" : ""}> ${esc(f.label)}</label>`).join("");
overlay.querySelectorAll('input[name="imNet"]').forEach((r) => r.addEventListener("change", updatePathDefault)); overlay.querySelectorAll('input[name="imNet"]').forEach((r) => r.addEventListener("change", () => {
overlay.querySelectorAll('input[name="imKind"]').forEach((r) => r.addEventListener("change", updateFormatFields)); updatePathDefault(true);
paintPathPresets();
}));
overlay.querySelectorAll('input[name="imKind"]').forEach((r) => r.addEventListener("change", () => {
updateFormatFields();
paintPathPresets();
}));
updatePathDefault(true); updatePathDefault(true);
updateFormatFields(); updateFormatFields();
paintPathPresets();
} }
function updatePathDefault(force) { function updatePathDefault(force) {
@ -1811,6 +1836,88 @@ function openImportModal(initialChain) {
} }
} }
// Derivation-path presets + the "which path has my funds" scan. Both are
// mnemonic-only: a WIF carries its own single key and has no path.
const CUSTOM = "__custom__";
async function paintPathPresets() {
const presetSel = overlay.querySelector("#imPathPreset");
const scanRow = overlay.querySelector("#imScanRow");
const out = overlay.querySelector("#imScanOut");
if (!presetSel || !scanRow) return;
out.innerHTML = "";
const netId = overlay.querySelector('input[name="imNet"]:checked')?.value || "";
const fmt = overlay.querySelector('input[name="imKind"]:checked')?.value || "mnemonic";
let cands = [];
if (fmt === "mnemonic") {
try { cands = (await S.invoke("seedPathCandidates", { chain: curChain, network: netId }))?.candidates || []; }
catch { cands = []; }
}
if (!cands.length) { presetSel.hidden = true; scanRow.hidden = true; return; }
presetSel.hidden = false;
scanRow.hidden = false;
const cur = overlay.querySelector("#imPath").value.trim();
presetSel.innerHTML = cands.map((c) =>
`<option value="${esc(c.path)}" ${c.path === cur ? "selected" : ""}>${esc(c.path)} — ${esc(c.note)}</option>`
).join("") + `<option value="${CUSTOM}" ${cands.some((c) => c.path === cur) ? "" : "selected"}>Custom…</option>`;
presetSel.onchange = () => {
if (presetSel.value === CUSTOM) { overlay.querySelector("#imPath").focus(); return; }
overlay.querySelector("#imPath").value = presetSel.value;
};
// Typing by hand flips the select to Custom rather than leaving it
// pointing at a preset the field no longer matches.
overlay.querySelector("#imPath").oninput = () => {
const v = overlay.querySelector("#imPath").value.trim();
presetSel.value = cands.some((c) => c.path === v) ? v : CUSTOM;
};
}
async function runPathScan() {
const out = overlay.querySelector("#imScanOut");
const btn = overlay.querySelector("#imScanBtn");
const mnemonic = overlay.querySelector("#imMnemonic").value.trim();
if (!mnemonic) {
out.innerHTML = `<div class="msg err" style="margin-top:6px">Paste the seed phrase first — the scan derives addresses from it to ask the chain which path has history.</div>`;
return;
}
const netId = overlay.querySelector('input[name="imNet"]:checked')?.value || "";
btn.disabled = true;
const old = btn.textContent;
btn.textContent = "Scanning…";
out.innerHTML = `<div class="hint" style="margin-top:6px">Deriving candidates and querying the network…</div>`;
try {
const r = await S.invoke("scanSeedPaths", { chain: curChain, network: netId, mnemonic });
const any = r.results.some((x) => x.balance > 0 || x.txCount > 0);
const rows = r.results.map((x) => {
const funded = x.balance > 0;
const amt = fmtBig(x.balance || 0, r.decimals);
const tag = funded
? `<b style="color:var(--acid)">${esc(amt)} ${esc(r.ticker)}</b>`
: x.txCount > 0 ? `<span class="hint">no balance, ${x.txCount} past tx</span>`
: `<span class="hint">nothing</span>`;
return `<div class="tx" style="grid-template-columns:1fr auto;cursor:default;align-items:center;margin-top:4px">
<div>
<div class="mono" style="font-size:12px">${esc(x.path)}</div>
<div class="hint">${esc(x.note)} · ${tag}</div>
</div>
<button class="btn sm ${funded ? "primary" : ""}" data-usepath="${esc(x.path)}">Use</button>
</div>`;
}).join("");
out.innerHTML = (any
? `<div class="hint" style="margin-top:6px">Scanned ${r.results.length} paths, ${r.results[0]?.scanned || 0} addresses each.</div>`
: `<div class="msg err" style="margin-top:6px">None of these paths has any history on ${esc(r.network)}. Either this seed has never been used here, or its wallet uses a path Aegis does not list — type it in by hand and re-scan.</div>`)
+ rows;
out.querySelectorAll("[data-usepath]").forEach((b) => b.addEventListener("click", () => {
overlay.querySelector("#imPath").value = b.dataset.usepath;
const ps = overlay.querySelector("#imPathPreset");
if (ps) ps.value = [...ps.options].some((o) => o.value === b.dataset.usepath) ? b.dataset.usepath : CUSTOM;
flash(b, "Set");
}));
} catch (e) {
out.innerHTML = `<div class="msg err" style="margin-top:6px">${esc(cleanErr(e))}</div>`;
} finally { btn.disabled = false; btn.textContent = old; }
}
overlay.querySelector("#imScanBtn").addEventListener("click", runPathScan);
overlay.querySelector("#imCoin").addEventListener("change", (e) => { curChain = e.target.value; paintChain(); }); overlay.querySelector("#imCoin").addEventListener("change", (e) => { curChain = e.target.value; paintChain(); });
paintChain(); paintChain();