feat(theseus/aegis): BTC address-family picker (BIP44/49/84/86 + Taproot)

BTC now matches DGB's family selector: pick BIP44 (1…), BIP49 (3…),
BIP84 (bc1q…, default) or BIP86 Taproot (bc1p…) from Settings, on
mainnet or testnet3 (paths shift coin type 0 → 1 automatically).

- lib/chain-btc.js: paymentFor(purpose, node, network) returns the
  right bitcoinjs-lib payment (p2pkh / p2sh(p2wpkh) / p2wpkh / p2tr)
  keyed off the derivation path's purpose. WalletKeys.entry captures
  the family, redeem script (BIP49) and internal x-only pubkey
  (BIP86) alongside the standard script/address fields.
  bitcoinjs.initEccLib(ecc) is called once at load so p2tr resolves.
- Registry: BTC + DGB address families are purpose-only now; a
  helper (addressFamiliesFor / defaultAccountPathFor) computes the
  concrete m/PURPOSE'/COIN'/0' per (chain, network) — coin type
  {mainnet:0, testnet:1} for BTC, always 20 for DGB. chainMeta
  expands the list so the panel doesn't need per-chain knowledge.
- Panel: #btcSettings block mirrors #dgbSettings (family select →
  path input auto-fill → Apply). The family-select listener + the
  fillFamilyPicker() helper are shared between DGB and BTC — the
  DOM prefix is the only per-chain input.
- Send is wired for BIP84 (default) and BIP49 (adds redeemScript to
  the PSBT input). BIP44 (needs nonWitnessUtxo prev-tx fetch) and
  BIP86 (needs tap-tweaked signer) throw a clear "not yet in this
  rev — sweep to BIP84" error so users hit it at plan time, not at
  broadcast time. Receive works on all four families today.
- Verified all four families derive the canonical BIP44/49/84/86
  spec test vectors for the standard abandon×11 mnemonic — see
  scratchpad/verify-btc-families.mjs. Byte-identical to the BIPs.
This commit is contained in:
Local Dev 2026-09-07 21:23:15 +02:00
parent a0a22bc69a
commit af8e167120
4 changed files with 203 additions and 49 deletions

View file

@ -173,14 +173,16 @@ const COINS = {
supportsPageInject: false,
// BIP44/49/84/86 address families — the picker lives in the DGB
// settings block. Default is BIP84 (dgb1q…), which matches modern
// DGB Core, DigiByte-Go, and the SilentCode web-wallet.
// DGB Core, DigiByte-Go, and the SilentCode web-wallet. `coinType`
// per chain feeds the path builder below.
addressFamilies: [
{ id: "bip84", purpose: 84, label: "Native SegWit (dgb1q…)", defaultAccountPath: "m/84'/20'/0'" },
{ id: "bip86", purpose: 86, label: "Taproot (dgb1p…)", defaultAccountPath: "m/86'/20'/0'" },
{ id: "bip49", purpose: 49, label: "Wrapped SegWit (S…)", defaultAccountPath: "m/49'/20'/0'" },
{ id: "bip44", purpose: 44, label: "Legacy P2PKH (D…)", defaultAccountPath: "m/44'/20'/0'" },
{ id: "bip84", purpose: 84, label: "Native SegWit (dgb1q…)" },
{ id: "bip86", purpose: 86, label: "Taproot (dgb1p…)" },
{ id: "bip49", purpose: 49, label: "Wrapped SegWit (S…)" },
{ id: "bip44", purpose: 44, label: "Legacy P2PKH (D…)" },
],
defaultAccountPath: "m/84'/20'/0'",
coinType: 20,
defaultPurpose: 84,
networks: {
mainnet: {
id: "mainnet", label: "Mainnet", testnet: false,
@ -240,10 +242,17 @@ const COINS = {
logo: "btc",
supportsMessageSign: true,
supportsPageInject: false,
// BIP84 by default. Family selector can be exposed later, same shape
// as DGB's — the adapter already reads accountPath, so a fresh entry
// with a different purpose (m/44'/0'/0' etc.) just works.
defaultAccountPath: "m/84'/0'/0'",
// BIP44/49/84/86 across bc1q… / bc1p… / 3… / 1… on mainnet and
// tb1q… / tb1p… / 2… / m/n… on testnet3. Coin type shifts per
// network (0 for mainnet, 1 for testnet — the BIP44 convention).
addressFamilies: [
{ id: "bip84", purpose: 84, label: "Native SegWit (bc1q… / tb1q…)" },
{ id: "bip86", purpose: 86, label: "Taproot (bc1p… / tb1p…)" },
{ id: "bip49", purpose: 49, label: "Wrapped SegWit (3… / 2…)" },
{ id: "bip44", purpose: 44, label: "Legacy P2PKH (1… / m…, n…)" },
],
coinType: { mainnet: 0, testnet: 1 },
defaultPurpose: 84,
networks: {
mainnet: {
id: "mainnet", label: "Mainnet", testnet: false,
@ -257,6 +266,30 @@ const COINS = {
},
};
function chainKey(chain, network) { return `${chain}:${network}`; }
// Coin type per (chain, network). A number literal on the COINS entry
// (DGB uses a single 20) or a per-network object ({mainnet: 0, testnet: 1}
// for BTC). Returns null when the chain doesn't declare a family picker.
function coinTypeFor(c, network) {
if (!c || c.coinType == null) return null;
return typeof c.coinType === "number" ? c.coinType : (c.coinType[network] ?? null);
}
// Full derivation paths per family for a given (chain, network) — expands
// the family list on the fly so each picker knows exactly which path a
// pick would produce.
function addressFamiliesFor(c, network) {
if (!c || !c.addressFamilies) return null;
const ct = coinTypeFor(c, network);
if (ct == null) return c.addressFamilies;
return c.addressFamilies.map((f) => ({
...f,
defaultAccountPath: `m/${f.purpose}'/${ct}'/0'`,
}));
}
function defaultAccountPathFor(c, network) {
const ct = coinTypeFor(c, network);
if (ct == null || c.defaultPurpose == null) return null;
return `m/${c.defaultPurpose}'/${ct}'/0'`;
}
function chainMeta(chain, network) {
const c = COINS[chain]; const n = c && c.networks[network];
if (!c || !n) return null;
@ -266,8 +299,8 @@ function chainMeta(chain, network) {
color: c.color, logo: c.logo, coinLabel: c.label, networkLabel: n.label, testnet: !!n.testnet,
purposePrefix: n.purposePrefix, startIndex: n.startIndex,
supportsMessageSign: !!c.supportsMessageSign, supportsPageInject: !!c.supportsPageInject,
addressFamilies: c.addressFamilies || null,
defaultAccountPath: c.defaultAccountPath || null,
addressFamilies: addressFamiliesFor(c, network),
defaultAccountPath: defaultAccountPathFor(c, network),
};
}
function coinsForPanel() {
@ -684,7 +717,11 @@ function registerPanelMessages(api) {
if (v && !/^m(\/\d+'?)+$/.test(v)) throw new Error("derivation path must look like m/84'/0'/0'");
const list = walletEntries();
const idx = list.findIndex((w) => w.id === id);
const dflt = entry.chain === "bch" ? "m/44'/145'/0'" : (entry.chain === "btc" ? "m/84'/0'/0'" : "m/84'/20'/0'");
// Per-network default: BTC/DGB come from the registry helper, BCH keeps
// its historical m/44'/145'/0'.
const dflt = entry.chain === "bch"
? "m/44'/145'/0'"
: (defaultAccountPathFor(COINS[entry.chain], entry.network) || "m/84'/0'/0'");
list[idx] = { ...list[idx], accountPath: v || dflt };
writeWallets(api, list);
const rt = ctx.runtimes.get(id);

View file

@ -47,6 +47,10 @@ module.exports = function makeBtcAdapter({
const { payments, Psbt, networks: bjsNetworks } = bitcoinjs;
const bip32 = bip32Factory(ecc);
const ECPair = ecpairFactory(ecc);
// Taproot (p2tr) address derivation needs bitcoinjs-lib's schnorr backend
// wired to a curve implementation — @bitcoinerlab/secp256k1 provides both
// ECDSA and schnorr, so initEccLib once at load makes p2tr resolve.
try { bitcoinjs.initEccLib && bitcoinjs.initEccLib(ecc); } catch {}
// Map our network id → bitcoinjs-lib Network object.
function bjsNetworkFor(id) {
@ -66,9 +70,45 @@ module.exports = function makeBtcAdapter({
};
}
// Address-family shape from the derivation-path purpose. Every field the
// PSBT layer might need for signing an input funded by this family is
// captured here so signAndBroadcast has one code path per family.
function paymentFor(purpose, node, network) {
const pubkey = Buffer.from(node.publicKey);
if (purpose === 44) {
// Legacy P2PKH. Signing needs the full previous transaction
// (nonWitnessUtxo) — one extra electrum call per input at send time.
const p = payments.p2pkh({ pubkey, network });
return { family: "bip44", address: p.address, output: Buffer.from(p.output), send: "p2pkh" };
}
if (purpose === 49) {
// P2SH-wrapped SegWit. PSBT needs the redeem script (the inner p2wpkh
// output) alongside the witnessUtxo.
const redeem = payments.p2wpkh({ pubkey, network });
const p = payments.p2sh({ redeem, network });
return { family: "bip49", address: p.address, output: Buffer.from(p.output), redeem: Buffer.from(redeem.output), send: "p2sh-p2wpkh" };
}
if (purpose === 86) {
// BIP86 Taproot key-path. Signing needs the tap-tweaked signer;
// ECPair from the current bitcoinjs stack doesn't tweak natively, so
// send is deferred — receive still works.
const internalPubkey = Buffer.from(pubkey.subarray(1, 33)); // x-only
const p = payments.p2tr({ internalPubkey, network });
return { family: "bip86", address: p.address, output: Buffer.from(p.output), internalPubkey, send: null };
}
// Default: BIP84 native SegWit.
const p = payments.p2wpkh({ pubkey, network });
return { family: "bip84", address: p.address, output: Buffer.from(p.output), send: "p2wpkh" };
}
function purposeOfPath(accountPath) {
const m = /^m\/(\d+)'\//.exec(String(accountPath || ""));
return m ? Number(m[1]) : 84;
}
class WalletKeys {
constructor(root32, accountPath, bjsNetwork) {
this._accountPath = /^m(\/\d+'?)+$/.test(accountPath) ? accountPath : "m/84'/0'/0'";
this._purpose = purposeOfPath(this._accountPath);
this._network = bjsNetwork;
this._root = bip32.fromSeed(Buffer.from(root32), bjsNetwork);
this._account = this._root.derivePath(this._accountPath);
@ -78,19 +118,22 @@ module.exports = function makeBtcAdapter({
get xpub() { return this._account.neutered().toBase58(); }
get xprv() { return this._account.toBase58(); }
get accountPath() { return this._accountPath; }
get purpose() { return this._purpose; }
entry(branch, index) {
const k = branch + "/" + index;
let e = this._cache.get(k);
if (!e) {
const node = this._branch[branch].derive(index);
const pubkey = Buffer.from(node.publicKey);
const p2wpkh = payments.p2wpkh({ pubkey, network: this._network });
const script = Buffer.from(p2wpkh.output);
const pay = paymentFor(this._purpose, node, this._network);
e = {
branch, index, path: this._accountPath + "/" + branch + "/" + index,
publicKey: pubkey, script, scriptHex: script.toString("hex"),
scripthash: scripthashOf(script),
address: p2wpkh.address,
publicKey: Buffer.from(node.publicKey),
family: pay.family, sendKind: pay.send,
script: pay.output, scriptHex: pay.output.toString("hex"),
scripthash: scripthashOf(pay.output),
address: pay.address,
redeemScript: pay.redeem || null,
tapInternalKey: pay.internalPubkey || null,
_node: node,
};
this._cache.set(k, e);
@ -109,11 +152,18 @@ module.exports = function makeBtcAdapter({
}
}
// Fee vsize model — same P2WPKH numbers as DGB (identical script shapes).
// Fee vsize model per family. Values are rounded vsize contributions from
// standard tx-size tables; the estimator is pessimistic enough to cover a
// real broadcast without underpaying.
const OVERHEAD_VB = 10.5;
const P2WPKH_INPUT_VB = 68;
const P2WPKH_OUTPUT_VB = 31;
const feeVb = (nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * P2WPKH_INPUT_VB + nOut * P2WPKH_OUTPUT_VB) * feePerVb);
const OUTPUT_VB = 31; // P2WPKH / P2SH / P2PKH outputs are all ~31 vB give or take
const INPUT_VB = {
p2pkh: 148, // (32+4)+1+107+4 legacy input
"p2sh-p2wpkh": 91, // 40 base + ~205/4 witness
p2wpkh: 68, // 41 base + 108/4 witness
p2tr: 58, // 41 base + 66/4 witness (key-path)
};
const feeVb = (kind, nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + nOut * OUTPUT_VB) * feePerVb);
class BtcWallet {
constructor(root32, networkId, {
@ -312,15 +362,24 @@ module.exports = function makeBtcAdapter({
const dest = String(to || "");
try { bitcoinjs.address.toOutputScript(dest, this._bjsNet); }
catch (e) { throw new Error(`bad Bitcoin address: ${e?.message || dest}`); }
// Family-aware refusal: this rev signs BIP84 + BIP49 natively. BIP44
// needs previous-tx fetches (nonWitnessUtxo) and BIP86 needs Taproot
// key-path tweaking — both are on the roadmap; error early so the
// user isn't surprised at broadcast time.
const cur = this.current();
if (!cur.sendKind) {
throw new Error(`Sending from ${cur.family.toUpperCase()} addresses isn't wired up yet in this Aegis rev. Receiving works; to spend, switch this wallet's address family to BIP84 (Native SegWit) in Settings and sweep funds there.`);
}
const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
const change = this._changeEntry();
const kind = cur.sendKind;
if (sendMax) {
const chosen = spendable;
const sum = chosen.reduce((a, u) => a + u.value, 0);
const fee = feeVb(chosen.length, 1, rate);
const fee = feeVb(kind, chosen.length, 1, rate);
if (sum <= fee) throw new Error("balance does not cover the fee");
return {
_chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change,
_chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change, _kind: kind,
recipients: [{ to: dest, value: sum - fee }],
fee, feeRate: rate, change: 0,
total: sum,
@ -331,13 +390,13 @@ module.exports = function makeBtcAdapter({
let sum = 0; const chosen = [];
for (const u of spendable) {
chosen.push(u); sum += u.value;
const withChange = feeVb(chosen.length, 2, rate);
const withChange = feeVb(kind, chosen.length, 2, rate);
if (sum >= value + withChange) {
const changeVal = sum - value - withChange;
const fee = changeVal > 546 ? withChange : sum - value;
return {
_chosen: chosen, _rate: rate, _to: dest, _value: value,
_change: change, _changeVal: changeVal > 546 ? changeVal : 0,
_change: change, _changeVal: changeVal > 546 ? changeVal : 0, _kind: kind,
recipients: [{ to: dest, value }],
fee, feeRate: rate,
change: changeVal > 546 ? changeVal : 0,
@ -351,10 +410,14 @@ module.exports = function makeBtcAdapter({
async signAndBroadcast(plan) {
const psbt = new Psbt({ network: this._bjsNet });
for (const u of plan._chosen) {
psbt.addInput({
const inp = {
hash: u.txid, index: u.vout,
witnessUtxo: { script: u.entry.script, value: u.value },
});
};
// BIP49: the redeem script (inner P2WPKH output) is required so
// bitcoinjs-lib can finalize the P2SH-wrapped SegWit witness.
if (u.entry.family === "bip49" && u.entry.redeemScript) inp.redeemScript = u.entry.redeemScript;
psbt.addInput(inp);
}
const outputs = [{ address: plan._to, value: plan._sendMax ? plan.recipients[0].value : plan._value }];
if (!plan._sendMax && plan._changeVal > 0) {

View file

@ -274,6 +274,31 @@
</div>
</div>
<div id="btcSettings" hidden>
<div class="field">
<div class="lbl">Address family</div>
<select id="setBtcFamily"></select>
<div class="hint">Picks the BIP purpose that shapes your Bitcoin addresses. Switching rebuilds the wallet against a different set of addresses under the same seed — old funds don't move; they still live under the family they were received on.</div>
</div>
<div class="field">
<div class="lbl">Derivation path (account)</div>
<input type="text" id="setBtcPath" spellcheck="false" placeholder="m/84'/0'/0'">
<div class="hint">Auto-filled from the family above. Edit only if you need a non-default account.</div>
</div>
<div class="actions">
<button class="btn primary" id="applyBtcPath">Apply</button>
</div>
<div class="card" style="margin-top:16px">
<div class="lbl">Recovery info</div>
<div class="hint">Keys come from your Theseus password vault under <span class="mono" id="btcPurpose"></span>. Any BIP39 tool at coin type 0 (mainnet) / 1 (testnet) and the same purpose can reproduce this wallet.</div>
<div class="kv" id="btcRecovery"></div>
<div class="actions">
<button class="btn" id="showBtcXpub">Show account xpub</button>
<button class="btn danger" id="showBtcXprv">Show account private key</button>
</div>
</div>
</div>
<div id="ethSettings" hidden>
<div class="field">
<div class="lbl">RPC URL</div>

View file

@ -477,8 +477,9 @@ function fillSettings() {
const s = sel(); if (!s) return;
$("bchSettings").hidden = chain() !== "bch";
$("trxSettings").hidden = chain() !== "trx";
$("scSettings").hidden = chain() !== "sc";
$("scSettings").hidden = chain() !== "sc";
$("dgbSettings").hidden = chain() !== "dgb";
$("btcSettings").hidden = chain() !== "btc";
$("ethSettings").hidden = chain() !== "eth";
$("solSettings").hidden = chain() !== "sol";
$("removeBtn").disabled = !!s.isLegacy && s.chain === "bch";
@ -507,24 +508,19 @@ function fillSettings() {
$("scPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("scRecovery").innerHTML = "";
} else if (chain() === "dgb") {
const families = s.meta?.addressFamilies || [];
if (!settingsFilled) {
$("setDgbPath").value = s.accountPath || "";
// Match the current path to a family, falling back to the default.
const current = String(s.accountPath || "");
let currentFamilyId = families.find((f) => f.defaultAccountPath === current)?.id;
if (!currentFamilyId) {
const m = /^m\/(\d+)'/.exec(current);
const purpose = m ? Number(m[1]) : null;
currentFamilyId = families.find((f) => f.purpose === purpose)?.id || families[0]?.id;
}
$("setDgbFamily").innerHTML = families.map((f) =>
`<option value="${esc(f.id)}" data-path="${esc(f.defaultAccountPath)}" ${f.id === currentFamilyId ? "selected" : ""}>${esc(f.label)}</option>`
).join("");
fillFamilyPicker("Dgb", s);
settingsFilled = true;
}
$("dgbPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("dgbRecovery").innerHTML = "";
} else if (chain() === "btc") {
if (!settingsFilled) {
fillFamilyPicker("Btc", s);
settingsFilled = true;
}
$("btcPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
$("btcRecovery").innerHTML = "";
} else if (chain() === "eth") {
if (!settingsFilled) {
$("setEthRpcUrl").value = s.rpcUrl || "";
@ -615,6 +611,7 @@ document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click
$("recovery").innerHTML = "";
$("scRecovery").innerHTML = "";
$("dgbRecovery").innerHTML = "";
$("btcRecovery").innerHTML = "";
$("ethRecovery").innerHTML = "";
$("solRecovery").innerHTML = "";
}
@ -637,13 +634,30 @@ $("showScSeed").addEventListener("click", async () => {
} catch (e) { $("scRecovery").textContent = cleanErr(e); }
});
// DGB-specific settings.
// Family select → auto-fill the derivation-path input with that family's default.
document.addEventListener("change", (e) => {
if (e.target && e.target.id === "setDgbFamily") {
const opt = e.target.options[e.target.selectedIndex];
if (opt && opt.dataset.path) $("setDgbPath").value = opt.dataset.path;
// Family-picker helper used by both DGB and BTC. Prefix is "Dgb" or "Btc":
// the DOM IDs are #set<Prefix>Family + #set<Prefix>Path.
function fillFamilyPicker(prefix, s) {
const families = s.meta?.addressFamilies || [];
const current = String(s.accountPath || "");
let currentId = families.find((f) => f.defaultAccountPath === current)?.id;
if (!currentId) {
const m = /^m\/(\d+)'/.exec(current);
const purpose = m ? Number(m[1]) : null;
currentId = families.find((f) => f.purpose === purpose)?.id || families[0]?.id;
}
$(`set${prefix}Path`).value = current || families[0]?.defaultAccountPath || "";
$(`set${prefix}Family`).innerHTML = families.map((f) =>
`<option value="${esc(f.id)}" data-path="${esc(f.defaultAccountPath)}" ${f.id === currentId ? "selected" : ""}>${esc(f.label)}</option>`
).join("");
}
// Any family select → auto-fill the sibling path input.
document.addEventListener("change", (e) => {
const t = e.target;
if (!t) return;
const m = /^set(Dgb|Btc)Family$/.exec(t.id || "");
if (!m) return;
const opt = t.options[t.selectedIndex];
if (opt && opt.dataset.path) $(`set${m[1]}Path`).value = opt.dataset.path;
});
$("applyDgbPath").addEventListener("click", async () => {
const msg = $("settingsMsg"); msg.hidden = true;
@ -652,6 +666,21 @@ $("applyDgbPath").addEventListener("click", async () => {
settingsFilled = false; fillSettings(); render(); flash($("applyDgbPath"), "Applied");
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
$("applyBtcPath").addEventListener("click", async () => {
const msg = $("settingsMsg"); msg.hidden = true;
try {
state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: $("setBtcPath").value.trim() });
settingsFilled = false; fillSettings(); render(); flash($("applyBtcPath"), "Applied");
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
});
$("showBtcXpub").addEventListener("click", async () => {
try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("btcRecovery").innerHTML = recoveryHtml(r); }
catch (e) { $("btcRecovery").textContent = cleanErr(e); }
});
$("showBtcXprv").addEventListener("click", async () => {
try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("btcRecovery").innerHTML = recoveryHtml(r); }
catch (e) { $("btcRecovery").textContent = cleanErr(e); }
});
// ETH / SOL: RPC URL.
$("applyEthRpc").addEventListener("click", async () => {