feat(aegis): 0.11.0 — promote a WIF import to an HD wallet
A wallet imported from a single private key cannot do WizardConnect, and no amount of work inside Aegis changes that: the handshake ships BIP32 xpubs so the dapp derives addresses without further round-trips, and a lone key has no chain code to build one from. Manufacturing a parent whose child equals a given key means inverting HMAC-SHA512, and even solved for index 0 the dapp's next index lands elsewhere. The way out is to stop being a single-key wallet. Promote derives a fresh wallet from the vault on the import's own network and sweeps the key into it, after which WizardConnect works — and so does every other thing that assumes a key tree. It reuses plan() + signAndBroadcast(), the same pair the consolidate flow already spends through, rather than growing a second money path. Three things it deliberately does not do: - The preview costs the sweep by planning a send-max to the wallet's OWN address, so cancelling leaves nothing behind. Same inputs, same single P2PKH output, so the fee is identical to the real sweep. - The imported key is kept, not deleted. The sweep is unconfirmed when the call returns and anyone holding the old address can still pay into it; removing the key there would strand those coins. Removal stays a separate step the user takes once the balance reads zero. - A promote that fails in plan() takes the just-created wallet back out, since nothing was broadcast. Past that point the wallet is kept even on error, because a transaction may already be on the wire and its destination has to stay visible. BCH only: the BTC/DGB/ETH/TRX/SOL imported adapters still throw "read-only" from plan(), and the refusal now names the chain instead of failing vaguely. Wallet creation is lifted out of the addWallet handler into createVaultWallet so promote builds its destination through exactly the same purpose allocation, legacy-purpose carry-over and id numbering as the Add flow, instead of a near-copy sitting next to a transfer.
This commit is contained in:
parent
18f2839bd4
commit
e0246f8520
3 changed files with 173 additions and 31 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<div style="width:min(94vw,380px);background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
|
||||
|
|
@ -1050,6 +1053,11 @@ function openWalletManageModal(w) {
|
|||
<input type="text" id="mwPath" value="${esc(w.accountPath || "")}" spellcheck="false" placeholder="m/44'/…">
|
||||
<div class="hint">Advanced. Changing this switches to a different set of addresses under the same wallet seed.</div>
|
||||
</div>` : ""}
|
||||
${canPromote ? `<div style="border-top:1px solid var(--line);margin:12px 0 0;padding-top:10px">
|
||||
<div class="lbl" style="margin-bottom:4px">WizardConnect</div>
|
||||
<div class="hint" style="margin-bottom:8px">This wallet came from a single private key, so it can't pair with dapps — WizardConnect hands them an xpub to derive addresses from, and one key is not a key tree. Promoting derives a proper wallet from your vault and sweeps this one into it.</div>
|
||||
<button class="btn" id="mwPromote" type="button">Promote to HD wallet…</button>
|
||||
</div>` : ""}
|
||||
<div class="msg err" id="mwMsg" hidden></div>
|
||||
<div class="actions" style="justify-content:space-between;margin-top:12px">
|
||||
${canRemove ? `<button class="btn danger" id="mwRemove">Remove wallet</button>` : `<span class="hint">Default wallet — cannot be removed.</span>`}
|
||||
|
|
@ -1080,6 +1088,35 @@ function openWalletManageModal(w) {
|
|||
render();
|
||||
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
|
||||
});
|
||||
// Promote: preview the sweep (costed without creating anything), confirm
|
||||
// with the real numbers, then create + sweep in one host call. The confirm
|
||||
// carries the amounts because this moves the wallet's entire balance.
|
||||
if (canPromote) overlay.querySelector("#mwPromote").addEventListener("click", async () => {
|
||||
const msg = overlay.querySelector("#mwMsg"); msg.hidden = true;
|
||||
let pv;
|
||||
try { pv = await S.invoke("promotePreview", { walletId: w.id }); }
|
||||
catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; return; }
|
||||
if (pv.error) { msg.textContent = pv.error; msg.hidden = false; return; }
|
||||
const amt = (u) => fmtBig(u, pv.decimals);
|
||||
const ok = await aegisConfirm({
|
||||
title: "Promote to HD wallet?",
|
||||
confirmLabel: "Create and sweep",
|
||||
body: `Aegis will derive <b>${esc(pv.suggestedLabel)}</b> from your vault on ${esc(pv.networkLabel)}, then send this wallet's whole balance to it.`
|
||||
+ `<br><br><b>${esc(amt(pv.net))} ${esc(pv.ticker)}</b> arrives · <b>${esc(amt(pv.fee))} ${esc(pv.ticker)}</b> miner fee.`
|
||||
+ `<br><br>The imported key is kept rather than deleted: the sweep still has to confirm, and anyone holding the old address can still pay into it. Remove it yourself once its balance reads zero.`,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const r = await S.invoke("promoteToHd", { walletId: w.id, label: pv.suggestedLabel });
|
||||
close();
|
||||
fillPicker();
|
||||
render();
|
||||
await aegisAlert(
|
||||
`Sent ${amt(r.sent)} ${r.ticker} to "${r.newWalletLabel}". It can pair with WizardConnect once the sweep confirms.`
|
||||
+ (r.txid ? ` Txid ${r.txid}` : ""),
|
||||
{ title: "Promoted to HD wallet", icon: "✅", confirmLabel: "Done" });
|
||||
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
|
||||
});
|
||||
if (canRemove) overlay.querySelector("#mwRemove").addEventListener("click", async () => {
|
||||
const msg = overlay.querySelector("#mwMsg"); msg.hidden = true;
|
||||
const ok = await aegisConfirm({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue