feat(theseus/aegis): BTC send from BIP44 + BIP86 addresses

Aegis's Bitcoin adapter can now sign transactions from every BIP44/49/84/86
address it derives. Receive already worked on all four in the previous rev
— this closes the send side.

- BIP44 (legacy P2PKH, 1…): signAndBroadcast now fetches each spent UTXO's
  parent transaction via blockchain.transaction.get(txid, false) and hands
  the raw hex to PSBT as nonWitnessUtxo. Prev-tx calls fan out in parallel
  with Promise.all so a multi-input legacy send doesn't serialize the wait.
- BIP86 (Taproot key-path, bc1p…): signInput now uses a tap-tweaked
  signer — the internal ECPair, tweaked with sha256("TapTweak" ||
  internalPubkey) via ECPair.tweak(). bitcoinjs-lib matches the tweaked
  pubkey against the on-chain output key and signs with schnorr. The
  input carries tapInternalKey so the PSBT layer knows it's a key-path
  spend (no leaf script).
- The plan-time "not yet in this rev" refusal is gone. paymentFor()
  returns send: "p2pkh" / "p2tr" for the two families; every path in
  the picker signs today.
- Fee vsize model already covered p2pkh (148 vB per input) and p2tr
  (58 vB per input) — unchanged.
- Verified in scratchpad/verify-btc-send.mjs: all four families
  produce a fully-finalized wire tx (bitcoinjs-lib refuses to
  finalize an invalid signature, so a valid extractTransaction()
  result is proof the signing path is correct). Vsize per family:
  BIP44 222 vB, BIP49 165 vB, BIP84 141 vB, BIP86 142 vB — all
  match the input-count/vsize model in this file's fee estimator.
This commit is contained in:
Local Dev 2026-09-07 21:55:44 +02:00
parent af8e167120
commit 48cb497f59

View file

@ -79,7 +79,7 @@ module.exports = function makeBtcAdapter({
// 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" };
return { family: "bip44", address: p.address, output: Buffer.from(p.output), send: "p2pkh", needsPrevTx: true };
}
if (purpose === 49) {
// P2SH-wrapped SegWit. PSBT needs the redeem script (the inner p2wpkh
@ -89,12 +89,12 @@ module.exports = function makeBtcAdapter({
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
// BIP86 Taproot key-path. Signing goes through a tap-tweaked ECPair
// (see signerFor + signAndBroadcast); the internal 32-byte x-only
// pubkey is captured here so the PSBT input can carry it.
const internalPubkey = Buffer.from(pubkey.subarray(1, 33));
const p = payments.p2tr({ internalPubkey, network });
return { family: "bip86", address: p.address, output: Buffer.from(p.output), internalPubkey, send: null };
return { family: "bip86", address: p.address, output: Buffer.from(p.output), internalPubkey, send: "p2tr" };
}
// Default: BIP84 native SegWit.
const p = payments.p2wpkh({ pubkey, network });
@ -362,14 +362,8 @@ 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.`);
}
if (!cur.sendKind) throw new Error(`no sender for ${cur.family} — registry bug`);
const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
const change = this._changeEntry();
const kind = cur.sendKind;
@ -408,15 +402,28 @@ module.exports = function makeBtcAdapter({
}
async signAndBroadcast(plan) {
// BIP44 inputs need the whole previous transaction (nonWitnessUtxo)
// so PSBT can compute a legacy sighash; fetch each one in parallel
// before assembling the PSBT.
const needsPrev = plan._chosen.filter((u) => u.entry.family === "bip44");
const prevHex = new Map();
if (needsPrev.length) {
const results = await Promise.all(needsPrev.map((u) =>
this._client.call("blockchain.transaction.get", [u.txid, false])
));
needsPrev.forEach((u, i) => prevHex.set(u.txid, String(results[i])));
}
const psbt = new Psbt({ network: this._bjsNet });
for (const u of plan._chosen) {
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;
const inp = { hash: u.txid, index: u.vout };
const fam = u.entry.family;
if (fam === "bip44") {
inp.nonWitnessUtxo = Buffer.from(prevHex.get(u.txid), "hex");
} else {
inp.witnessUtxo = { script: u.entry.script, value: u.value };
if (fam === "bip49" && u.entry.redeemScript) inp.redeemScript = u.entry.redeemScript;
if (fam === "bip86" && u.entry.tapInternalKey) inp.tapInternalKey = u.entry.tapInternalKey;
}
psbt.addInput(inp);
}
const outputs = [{ address: plan._to, value: plan._sendMax ? plan.recipients[0].value : plan._value }];
@ -425,8 +432,21 @@ module.exports = function makeBtcAdapter({
}
for (const o of outputs) psbt.addOutput(o);
for (let i = 0; i < plan._chosen.length; i++) {
const signer = this._keys.signerFor(plan._chosen[i].entry);
psbt.signInput(i, signer);
const entry = plan._chosen[i].entry;
// Taproot key-path: bitcoinjs-lib matches the signer's publicKey
// against the tweaked output key, so the signer has to be the
// internal ECPair tweaked with sha256("TapTweak" || internalPubkey).
// ECPair.tweak() from the ecpair package does exactly that (its
// internal state becomes the tap-tweaked keypair) and its
// signSchnorr is what PSBT calls for a key-path spend.
if (entry.family === "bip86") {
const raw = ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: this._bjsNet });
const tweak = bitcoinjs.crypto.taggedHash("TapTweak", entry.tapInternalKey);
const tweaked = raw.tweak(tweak);
psbt.signInput(i, tweaked);
} else {
psbt.signInput(i, this._keys.signerFor(entry));
}
}
psbt.finalizeAllInputs();
const tx = psbt.extractTransaction();