Skip to content

Commit c4025df

Browse files
committed
Use a taproot multisig wallet class
1 parent 230138f commit c4025df

File tree

1 file changed

+269
-69
lines changed

1 file changed

+269
-69
lines changed

test/integration/taproot.spec.ts

Lines changed: 269 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -530,100 +530,63 @@ describe('bitcoinjs-lib (transaction with taproot)', () => {
530530

531531
it('can create (and broadcast via 3PBP) a taproot script-path spend Transaction - OP_CHECKSIGADD (2-of-3) and verify unspendable internalKey', async () => {
532532
const leafKeys = [];
533-
const leafPubkeys = [];
533+
const leafPubkeys: Buffer[] = [];
534534
for (let i = 0; i < 3; i++) {
535535
const leafKey = bip32.fromSeed(rng(64), regtest);
536536
leafKeys.push(leafKey);
537-
leafPubkeys.push(toXOnly(leafKey.publicKey).toString('hex'));
537+
leafPubkeys.push(toXOnly(leafKey.publicKey));
538538
}
539539

540-
// This is just a visual way of creating the script for educational purposes.
541-
// In a production application there's no need to bother with this step, creating the binary
542-
// directly from buffers is fine.
543-
const leafScriptAsm = `${leafPubkeys[2]} OP_CHECKSIG ${leafPubkeys[1]} OP_CHECKSIGADD ${leafPubkeys[0]} OP_CHECKSIGADD OP_2 OP_GREATERTHANOREQUAL`;
544-
const leafScript = bitcoin.script.fromASM(leafScriptAsm);
545-
546-
// Taptree can also be a single TapLeaf
547-
// Since we only have one script, it's all we need.
548-
const scriptTree: Taptree = {
549-
output: leafScript,
550-
};
551-
const redeem = {
552-
output: leafScript,
553-
redeemVersion: LEAF_VERSION_TAPSCRIPT,
554-
};
555-
556-
// We don't pass in a shared nonce because our wallet doesn't care.
557-
// See the helper function's comments to understand why you might want to use a shared nonce.
558-
// All signers should verify that the internalPubkey of the script they're signing is unspendable.
559-
// Otherwise the person who made the script could be hiding a secret master key (for one-key-only spending).
560-
// If a nonce is used, that nonce should be shared among all signers.
561-
const internalPubkey = makeUnspendableInternalKey();
562-
563-
const { output, address, witness } = bitcoin.payments.p2tr({
564-
internalPubkey,
565-
scriptTree,
566-
redeem,
567-
network: regtest,
568-
});
540+
// The only thing that differs between the wallets is the private key.
541+
// So we will use the first wallet for all the Psbt stuff.
542+
const [wallet, wallet2, wallet3] = leafKeys.map(key =>
543+
new TaprootMultisigWallet(
544+
leafPubkeys,
545+
2, // Number of required signatures
546+
key.privateKey!,
547+
LEAF_VERSION_TAPSCRIPT,
548+
).setNetwork(regtest),
549+
);
569550

570551
// amount from faucet
571552
const amount = 42e4;
572553
// amount to send
573554
const sendAmount = amount - 1e4;
574555
// get faucet
575-
const unspent = await regtestUtils.faucetComplex(output!, amount);
556+
const unspent = await regtestUtils.faucetComplex(wallet.output, amount);
576557

577558
const psbt = new bitcoin.Psbt({ network: regtest });
578-
psbt.addInput({
579-
hash: unspent.txId,
580-
index: 0,
581-
witnessUtxo: { value: amount, script: output! },
582-
});
583-
psbt.updateInput(0, {
584-
tapLeafScript: [
585-
{
586-
leafVersion: redeem.redeemVersion,
587-
script: redeem.output,
588-
controlBlock: witness![witness!.length - 1],
589-
},
590-
],
591-
});
592559

593-
psbt.addOutput({ value: sendAmount, address: address! });
560+
// Adding an input is a bit special in this case,
561+
// So we contain it in the wallet class
562+
// Any wallet can do this, wallet2 or wallet3 could be used.
563+
wallet.addInput(psbt, unspent.txId, unspent.vout, unspent.value);
594564

595-
// random order for signers
596-
psbt.signInput(0, leafKeys[2]);
597-
psbt.signInput(0, leafKeys[0]);
565+
psbt.addOutput({ value: sendAmount, address: wallet.address });
598566

599-
// Before finalizing, every key that did not sign must have an empty signature
600-
// in place where their signature would be.
601-
// In order to do this currently we need to construct a dummy signature manually.
602-
const noSignatureKeyDummySig = {
603-
// This can be reused for each dummy signature
604-
leafHash: tapleafHash({
605-
output: leafScript,
606-
version: LEAF_VERSION_TAPSCRIPT,
607-
}),
608-
// This is the pubkey that didn't sign
609-
pubkey: toXOnly(leafKeys[1].publicKey),
610-
// This must be an empty Buffer.
611-
signature: Buffer.from([]),
612-
};
567+
// Sign with at least 2 of the 3 wallets.
568+
// Verify that there is a matching leaf script
569+
// (which includes the unspendable internalPubkey,
570+
// so we verify that no one can key-spend it)
571+
wallet3.verifyInputScript(psbt, 0);
572+
wallet2.verifyInputScript(psbt, 0);
573+
psbt.signInput(0, wallet3);
574+
psbt.signInput(0, wallet2);
613575

614-
// We know that the first input exists and we have added tapScriptSigs
615-
// so the tapScriptSig must exist.
616-
psbt.data.inputs[0].tapScriptSig!.push(noSignatureKeyDummySig);
576+
// Before finalizing, we need to add dummy signatures for all that did not sign.
577+
// Any wallet can do this, wallet2 or wallet3 could be used.
578+
wallet.addDummySigs(psbt);
617579

618-
psbt.finalizeInput(0);
580+
psbt.finalizeAllInputs();
619581
const tx = psbt.extractTransaction();
620582
const rawTx = tx.toBuffer();
621583
const hex = rawTx.toString('hex');
622584

623585
await regtestUtils.broadcast(hex);
624586
await regtestUtils.verify({
625587
txId: tx.getId(),
626-
address: address!,
588+
// Any wallet can do this, wallet2 or wallet3 could be used.
589+
address: wallet.address,
627590
vout: 0,
628591
value: sendAmount,
629592
});
@@ -837,3 +800,240 @@ function makeUnspendableInternalKey(provableNonce?: Buffer): Buffer {
837800
return Hx;
838801
}
839802
}
803+
804+
class TaprootMultisigWallet {
805+
private leafScriptCache: Buffer | null = null;
806+
private internalPubkeyCache: Buffer | null = null;
807+
private paymentCache: bitcoin.Payment | null = null;
808+
private readonly publicKeyCache: Buffer;
809+
network: bitcoin.Network;
810+
811+
constructor(
812+
/**
813+
* A list of all the (x-only) pubkeys in the multisig
814+
*/
815+
private readonly pubkeys: Buffer[],
816+
/**
817+
* The number of required signatures
818+
*/
819+
private readonly requiredSigs: number,
820+
/**
821+
* The private key you hold.
822+
*/
823+
private readonly privateKey: Buffer,
824+
/**
825+
* leaf version (0xc0 currently)
826+
*/
827+
readonly leafVersion: number,
828+
/**
829+
* Optional shared nonce. This should be used in wallets where
830+
* the fact that key-spend is unspendable should not be public,
831+
* BUT each signer must verify that it is unspendable to be safe.
832+
*/
833+
private readonly sharedNonce?: Buffer,
834+
) {
835+
this.network = bitcoin.networks.bitcoin;
836+
assert(pubkeys.length > 0, 'Need pubkeys');
837+
assert(
838+
pubkeys.every(p => p.length === 32),
839+
'Pubkeys must be 32 bytes (x-only)',
840+
);
841+
assert(
842+
requiredSigs > 0 && requiredSigs <= pubkeys.length,
843+
'Invalid requiredSigs',
844+
);
845+
846+
assert(
847+
leafVersion <= 0xff && (leafVersion & 1) === 0,
848+
'Invalid leafVersion',
849+
);
850+
851+
if (sharedNonce) {
852+
assert(
853+
sharedNonce.length === 32 && ecc.isPrivate(sharedNonce),
854+
'Invalid sharedNonce',
855+
);
856+
}
857+
858+
const pubkey = ecc.pointFromScalar(privateKey);
859+
assert(pubkey, 'Invalid pubkey');
860+
861+
this.publicKeyCache = Buffer.from(pubkey);
862+
assert(
863+
pubkeys.some(p => p.equals(toXOnly(this.publicKeyCache))),
864+
'At least one pubkey must match your private key',
865+
);
866+
867+
// IMPORTANT: Make sure the pubkeys are sorted (To prevent ordering issues between wallet signers)
868+
this.pubkeys.sort((a, b) => a.compare(b));
869+
}
870+
871+
setNetwork(network: bitcoin.Network): this {
872+
this.network = network;
873+
return this;
874+
}
875+
876+
// Required for Signer interface.
877+
// Prevent setting by using a getter.
878+
get publicKey(): Buffer {
879+
return this.publicKeyCache;
880+
}
881+
882+
/**
883+
* Lazily build the leafScript. A 2 of 3 would look like:
884+
* key1 OP_CHECKSIG key2 OP_CHECKSIGADD key3 OP_CHECKSIGADD OP_2 OP_GREATERTHANOREQUAL
885+
*/
886+
get leafScript(): Buffer {
887+
if (this.leafScriptCache) {
888+
return this.leafScriptCache;
889+
}
890+
const ops = [];
891+
this.pubkeys.forEach(pubkey => {
892+
if (ops.length === 0) {
893+
ops.push(pubkey);
894+
ops.push(bitcoin.opcodes.OP_CHECKSIG);
895+
} else {
896+
ops.push(pubkey);
897+
ops.push(bitcoin.opcodes.OP_CHECKSIGADD);
898+
}
899+
});
900+
if (this.requiredSigs > 16) {
901+
ops.push(bitcoin.script.number.encode(this.requiredSigs));
902+
} else {
903+
ops.push(bitcoin.opcodes.OP_1 - 1 + this.requiredSigs);
904+
}
905+
ops.push(bitcoin.opcodes.OP_GREATERTHANOREQUAL);
906+
907+
this.leafScriptCache = bitcoin.script.compile(ops);
908+
return this.leafScriptCache;
909+
}
910+
911+
get internalPubkey(): Buffer {
912+
if (this.internalPubkeyCache) {
913+
return this.internalPubkeyCache;
914+
}
915+
// See the helper function for explanation
916+
this.internalPubkeyCache = makeUnspendableInternalKey(this.sharedNonce);
917+
return this.internalPubkeyCache;
918+
}
919+
920+
get scriptTree(): Taptree {
921+
// If more complicated, maybe it should be cached.
922+
// (ie. if other scripts are created only to create the tree
923+
// and will only be stored in the tree.)
924+
return {
925+
output: this.leafScript,
926+
};
927+
}
928+
929+
get redeem(): {
930+
output: Buffer;
931+
redeemVersion: number;
932+
} {
933+
return {
934+
output: this.leafScript,
935+
redeemVersion: this.leafVersion,
936+
};
937+
}
938+
939+
private get payment(): bitcoin.Payment {
940+
if (this.paymentCache) {
941+
return this.paymentCache;
942+
}
943+
this.paymentCache = bitcoin.payments.p2tr({
944+
internalPubkey: this.internalPubkey,
945+
scriptTree: this.scriptTree,
946+
redeem: this.redeem,
947+
network: this.network,
948+
});
949+
return this.paymentCache;
950+
}
951+
952+
get output(): Buffer {
953+
return this.payment.output!;
954+
}
955+
956+
get address(): string {
957+
return this.payment.address!;
958+
}
959+
960+
get controlBlock(): Buffer {
961+
const witness = this.payment.witness!;
962+
return witness[witness.length - 1];
963+
}
964+
965+
verifyInputScript(psbt: bitcoin.Psbt, index: number) {
966+
if (index >= psbt.data.inputs.length)
967+
throw new Error('Invalid input index');
968+
const input = psbt.data.inputs[index];
969+
if (!input.tapLeafScript) throw new Error('Input has no tapLeafScripts');
970+
const hasMatch =
971+
input.tapLeafScript.length === 1 &&
972+
input.tapLeafScript[0].leafVersion === this.leafVersion &&
973+
input.tapLeafScript[0].script.equals(this.leafScript) &&
974+
input.tapLeafScript[0].controlBlock.equals(this.controlBlock);
975+
if (!hasMatch)
976+
throw new Error(
977+
'No matching leafScript, or extra leaf script. Refusing to sign.',
978+
);
979+
}
980+
981+
addInput(
982+
psbt: bitcoin.Psbt,
983+
hash: string | Buffer,
984+
index: number,
985+
value: number,
986+
) {
987+
psbt.addInput({
988+
hash,
989+
index,
990+
witnessUtxo: { value, script: this.output },
991+
});
992+
psbt.updateInput(psbt.inputCount - 1, {
993+
tapLeafScript: [
994+
{
995+
leafVersion: this.leafVersion,
996+
script: this.leafScript,
997+
controlBlock: this.controlBlock,
998+
},
999+
],
1000+
});
1001+
}
1002+
1003+
addDummySigs(psbt: bitcoin.Psbt) {
1004+
const leafHash = tapleafHash({
1005+
output: this.leafScript,
1006+
version: this.leafVersion,
1007+
});
1008+
for (const input of psbt.data.inputs) {
1009+
if (!input.tapScriptSig) continue;
1010+
const signedPubkeys = input.tapScriptSig
1011+
.filter(ts => ts.leafHash.equals(leafHash))
1012+
.map(ts => ts.pubkey);
1013+
for (const pubkey of this.pubkeys) {
1014+
if (signedPubkeys.some(sPub => sPub.equals(pubkey))) continue;
1015+
// Before finalizing, every key that did not sign must have an empty signature
1016+
// in place where their signature would be.
1017+
// In order to do this currently we need to construct a dummy signature manually.
1018+
input.tapScriptSig.push({
1019+
// This can be reused for each dummy signature
1020+
leafHash,
1021+
// This is the pubkey that didn't sign
1022+
pubkey,
1023+
// This must be an empty Buffer.
1024+
signature: Buffer.from([]),
1025+
});
1026+
}
1027+
}
1028+
}
1029+
1030+
// required for Signer interface
1031+
sign(hash: Buffer, _lowR?: boolean): Buffer {
1032+
return Buffer.from(ecc.sign(hash, this.privateKey));
1033+
}
1034+
1035+
// required for Signer interface
1036+
signSchnorr(hash: Buffer): Buffer {
1037+
return Buffer.from(ecc.signSchnorr(hash, this.privateKey));
1038+
}
1039+
}

0 commit comments

Comments
 (0)