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:
parent
af8e167120
commit
48cb497f59
1 changed files with 42 additions and 22 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue