diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index c19a9259..5837e7e4 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.10.0", + "version": "0.11.0", "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.", "author": "Silent Mode", diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 4efd8a90..cc9bd7a5 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -542,7 +542,7 @@ async function mountWallet(entry) { if (!blob || blob.kind !== "seed" || !blob.seed) { wcIneligible.set(entry.id, { short: "WIF import", - detail: "Wallets imported from a single private key (WIF) can't pair. WizardConnect hands the dapp an xpub so it can derive addresses on its own, and a lone private key carries no chain code to build one from. Create the wallet from your vault instead, or re-import it from its seed phrase.", + detail: "Wallets imported from a single private key (WIF) can't pair. WizardConnect hands the dapp an xpub so it can derive addresses on its own, and a lone private key carries no chain code to build one from. Open this wallet's ⋯ menu and choose \"Promote to HD wallet\" — Aegis derives a proper wallet from your vault and sweeps this key into it.", }); emitStateForWallet(entry.id); return; @@ -744,6 +744,41 @@ async function mountWallet(entry) { } } +// Create a vault-derived wallet for a coin+network and mount it. Lifted out +// of the addWallet handler so "promote to HD" can build its destination +// through exactly the same path the Add flow uses — purpose allocation, the +// legacy-purpose carry-over and id numbering all stay in one place rather +// than being reimplemented slightly differently next to a money transfer. +async function createVaultWallet({ chain, network, label }) { + const meta = chainMeta(chain, network); + if (!meta) throw new Error("unknown chain/network"); + const list = walletEntries().slice(); + const netMeta = COINS[chain]?.networks?.[network] || {}; + const existingForCoin = list.filter((w) => w.chain === chain && w.network === network && !w.isLegacy); + let purpose, isLegacy = false; + if (netMeta.legacyFirstPurpose && existingForCoin.length === 0 + && !list.some((w) => w.purpose === netMeta.legacyFirstPurpose)) { + purpose = netMeta.legacyFirstPurpose; + isLegacy = true; + } else { + purpose = meta.purposePrefix + nextIndex(list, meta); + } + const id = makeWalletId(meta, nextIndex(list, meta)); + if (list.some((w) => w.id === id || w.purpose === purpose)) throw new Error("duplicate wallet"); + const entry = { + id, chain, network, purpose, isLegacy, + label: String(label || "").trim() || autoLabel(meta, list), + createdAt: Date.now(), + }; + list.push(entry); + writeWallets(ctx.api, list); + ctx.api.storage.set("selectedWalletId", id); + ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null }); + emitState(); + await mountWallet(entry); + return entry; +} + function unmountWallet(walletId) { const rt = ctx.runtimes.get(walletId); if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} } @@ -961,35 +996,9 @@ function registerPanelMessages(api) { fromPanel(m); const chain = String(p && p.chain || ""); const network = String(p && p.network || ""); - const meta = chainMeta(chain, network); - if (!meta) throw new Error("unknown chain/network"); - const list = walletEntries().slice(); - // If this coin+network declares a `legacyFirstPurpose` (SC does, to - // recover funds from the standalone siawallet addon) and no wallet of - // this coin+network exists yet, use that purpose verbatim. The bump - // to the /aegis-namespaced/ prefix only starts on the second sub-account. - const netMeta = COINS[chain]?.networks?.[network] || {}; - const existingForCoin = list.filter((w) => w.chain === chain && w.network === network && !w.isLegacy); - let purpose, isLegacy = false; - if (netMeta.legacyFirstPurpose && existingForCoin.length === 0 - && !list.some((w) => w.purpose === netMeta.legacyFirstPurpose)) { - purpose = netMeta.legacyFirstPurpose; - isLegacy = true; - } else { - const index = nextIndex(list, meta); - purpose = meta.purposePrefix + index; - } - const idIndex = nextIndex(list, meta); - const id = makeWalletId(meta, idIndex); - if (list.some((w) => w.id === id || w.purpose === purpose)) throw new Error("duplicate wallet"); - const label = String(p && p.label || "").trim() || autoLabel(meta, list); - const entry = { id, label, chain, network, purpose, isLegacy, createdAt: Date.now() }; - list.push(entry); - writeWallets(api, list); - api.storage.set("selectedWalletId", id); - ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null }); - emitState(); - await mountWallet(entry); + // Purpose allocation and mounting live in createVaultWallet — see there + // for why (legacyFirstPurpose carry-over, id numbering). + await createVaultWallet({ chain, network, label: String(p && p.label || "") }); return fullState(); }); // Vault lifecycle from inside the wallet panel — no more redirecting the @@ -1565,6 +1574,102 @@ function registerPanelMessages(api) { }; }); + // ---- promote an import to an HD wallet ---------------------------------- + // + // A wallet imported from a single private key cannot do WizardConnect: the + // handshake ships BIP32 xpubs so the dapp can derive addresses on its own, + // and a lone key has no chain code to build one from. Nothing Aegis can do + // locally fixes that — manufacturing a parent whose child equals a given + // key means inverting HMAC-SHA512. The way out is to stop being a + // single-key wallet: derive a proper HD wallet from the vault and sweep the + // imported key into it. + // + // Only BCH imports can do this. The BTC/DGB/ETH/TRX/SOL imported adapters + // still throw "read-only" from plan(), so there is no sweep to run. + function promotableImport(walletId) { + const entry = walletEntries().find((w) => w.id === String(walletId || "")); + if (!entry) throw new Error("unknown wallet"); + if (entry.kind !== "imported") throw new Error("this wallet is already derived from your vault"); + if (entry.chain !== "bch") { + throw new Error(`Imported ${String(entry.chain).toUpperCase()} wallets are still read-only, so there is nothing to sweep with yet. Only BCH imports can be promoted today.`); + } + const rt = ctx.runtimes.get(entry.id); + if (!rt?.adapter || rt.phase !== "ready") throw new Error("wallet is still loading — try again in a moment"); + return { entry, rt }; + } + + api.onMessage("promotePreview", async (p, m) => { + fromPanel(m); + const { entry, rt } = promotableImport(p && p.walletId); + const snap = rt.adapter.snapshot(); + const meta = chainMeta(entry.chain, entry.network); + // Cost the sweep WITHOUT creating the destination wallet first, so + // cancelling the preview leaves nothing behind. A send-max to our own + // address spends the same UTXOs into the same single P2PKH output, so + // the fee is identical to the real sweep — only the output's 20-byte + // hash differs, and that does not change the transaction's size. + let fee = null, net = null, error = null; + try { + const plan = await Promise.resolve(rt.adapter.plan({ to: snap.address, sendMax: true })); + fee = String(plan.fee ?? 0); + net = String(plan.recipients?.[0]?.value ?? 0); + } catch (e) { error = e?.message || String(e); } + return { + walletId: entry.id, label: entry.label, + chain: entry.chain, network: entry.network, + networkLabel: meta?.networkLabel || entry.network, + ticker: meta?.ticker || "", decimals: meta?.decimals || 8, + address: snap.address || null, + balance: snap.balance || null, + fee, net, error, + suggestedLabel: `${entry.label} (HD)`, + }; + }); + + api.onMessage("promoteToHd", async (p, m) => { + fromPanel(m); + const { entry, rt } = promotableImport(p && p.walletId); + const dest = await createVaultWallet({ + chain: entry.chain, network: entry.network, + label: String(p && p.label || "") || `${entry.label} (HD)`, + }); + const destRt = ctx.runtimes.get(dest.id); + const destAddr = destRt?.adapter?.snapshot?.()?.address; + if (!destAddr) throw new Error("the new wallet came up without a receive address — nothing was moved"); + + let plan; + try { + plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true })); + } catch (e) { + // Nothing was broadcast, so the empty wallet we just made is pure + // litter — take it back out. Past this point we keep it even on + // failure, because a transaction may already be on the wire and its + // destination must stay visible. + try { unmountWallet(dest.id); writeWallets(ctx.api, walletEntries().filter((w) => w.id !== dest.id)); } catch {} + ctx.api.storage.set("selectedWalletId", entry.id); + emitState(); + throw e; + } + + const sent = String(plan.recipients?.[0]?.value ?? 0); + const fee = String(plan.fee ?? 0); + const r = await rt.adapter.signAndBroadcast(plan); + // The import keeps its key on purpose. The sweep is unconfirmed for now, + // and anyone who still has the old address can pay into it — removing + // the key here would strand those coins. The panel offers removal as a + // separate step once the balance has actually gone to zero. + try { rt.adapter.refresh(false); } catch {} + try { destRt.adapter.refresh(false); } catch {} + emitState(); + return { + ok: true, txid: r?.txid || null, sent, fee, + fromWalletId: entry.id, fromLabel: entry.label, + newWalletId: dest.id, newWalletLabel: dest.label, newAddress: destAddr, + ticker: chainMeta(entry.chain, entry.network)?.ticker || "", + decimals: chainMeta(entry.chain, entry.network)?.decimals || 8, + }; + }); + api.onMessage("recovery", async (p, m) => { fromPanel(m); const id = String(p && p.id || selectedWalletId()); diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 0e8d21ff..90a300fe 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -1033,6 +1033,9 @@ function openWalletManageModal(w) { overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:99999;padding-top:24px"; const canRemove = !(w.isDefault || w.isLegacy); const canSetPath = ["bch", "btc", "dgb"].includes(w.chain); + // Only BCH imports can be promoted: the other imported adapters still + // throw "read-only" from plan(), so there is no sweep to run. + const canPromote = w.kind === "imported" && w.chain === "bch"; overlay.innerHTML = `