diff --git a/api/firmware/btc_test.go b/api/firmware/btc_test.go index 8734b9b..7c2eee9 100644 --- a/api/firmware/btc_test.go +++ b/api/firmware/btc_test.go @@ -18,6 +18,7 @@ package firmware import ( "bytes" "errors" + "slices" "testing" "github.com/BitBoxSwiss/bitbox02-api-go/api/firmware/messages" @@ -74,6 +75,44 @@ func p2shPkScript(redeemScript []byte) []byte { return pkScript } +// P2WSH multisig witnessScript and pubkeyScript from these xpubs, derived at /<0;1>/*. +// The pubkeys will be sorted lexicographically. +func multisigP2WSH(threshold int, xpubs []string, change bool, index uint32) ([]byte, []byte) { + pubkeys := make([]*btcutil.AddressPubKey, len(xpubs)) + for i, xpubStr := range xpubs { + changeIndex := uint32(0) + if change { + changeIndex = 1 + } + xpub := mustXpub(xpubStr, changeIndex, index) + pubKey, err := xpub.ECPubKey() + if err != nil { + panic(err) + } + addrPubKey, err := btcutil.NewAddressPubKey(pubKey.SerializeCompressed(), &chaincfg.MainNetParams) + if err != nil { + panic(err) + } + pubkeys[i] = addrPubKey + } + slices.SortFunc(pubkeys, func(a, b *btcutil.AddressPubKey) int { + return bytes.Compare(a.ScriptAddress(), b.ScriptAddress()) + }) + witnessScript, err := txscript.MultiSigScript(pubkeys, threshold) + if err != nil { + panic(err) + } + addr, err := btcutil.NewAddressWitnessScriptHash(chainhash.HashB(witnessScript), &chaincfg.MainNetParams) + if err != nil { + panic(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + panic(err) + } + return witnessScript, pkScript +} + //nolint:unparam func mustOutpoint(s string) *wire.OutPoint { outPoint, err := wire.NewOutPointFromString(s) @@ -210,14 +249,27 @@ func TestSimulatorBTCAddress(t *testing.T) { }) } +func mustXpub(xpubStr string, keypath ...uint32) *hdkeychain.ExtendedKey { + xpub, err := hdkeychain.NewKeyFromString(xpubStr) + if err != nil { + panic(err) + } + for _, childIndex := range keypath { + xpub, err = xpub.Derive(childIndex) + if err != nil { + panic(err) + } + } + return xpub +} + func simulatorPub(t *testing.T, device *Device, keypath ...uint32) *btcec.PublicKey { t.Helper() xpubStr, err := device.BTCXPub(messages.BTCCoin_BTC, keypath, messages.BTCPubRequest_XPUB, false) require.NoError(t, err) - xpub, err := hdkeychain.NewKeyFromString(xpubStr) - require.NoError(t, err) + xpub := mustXpub(xpubStr) pubKey, err := xpub.ECPubKey() require.NoError(t, err) return pubKey @@ -937,3 +989,207 @@ func TestSimulatorSignBTCTransactionSendSelfDifferentAccount(t *testing.T) { ) }) } + +// 1-of-3 multisig registration and address display/verification. +func TestSimulatorBTCAddressMultisig(t *testing.T) { + testInitializedSimulators(t, func(t *testing.T, device *Device, stdOut *bytes.Buffer) { + t.Helper() + + coin := messages.BTCCoin_BTC + keypathAccount := []uint32{ + 48 + hardenedKeyStart, + 0 + hardenedKeyStart, + 0 + hardenedKeyStart, + 2 + hardenedKeyStart, + } + + receiveKeypath := append(append([]uint32{}, keypathAccount...), 0, 0) + + ourXPub, err := device.BTCXPub(coin, keypathAccount, messages.BTCPubRequest_XPUB, false) + require.NoError(t, err) + + xpubs := []string{ + ourXPub, + "xpub6Esa6esRHkbuXtbdDKqu3uWjQ1GpK39WW2hxbUAN4L3TxrwDyghEwBtUYZ8uK8LZh3tJ3pjWEpxng9tjfo7RT9BaZKV2T3EPvmZ6N1LgSdj", + "xpub6FJ6FAAFUzuWQAKyT98Ngs6UwsoPfPCdmepqX2aLLPT54M85ARsWzPciFd49foStMwhWgfiHP6PnMgPrWLrBJpUHgqw8vZPd5ov8uSfW2vo", + } + ourXPubIndex := uint32(0) + threshold := 1 + + scriptConfig, err := NewBTCScriptConfigMultisig(uint32(threshold), xpubs, ourXPubIndex) + require.NoError(t, err) + + // The multisig account has to be registered if not already. + registered, err := device.BTCIsScriptConfigRegistered(coin, scriptConfig, keypathAccount) + require.NoError(t, err) + require.False(t, registered) + + err = device.BTCRegisterScriptConfig(coin, scriptConfig, keypathAccount, "My multisig account") + require.NoError(t, err) + + address, err := device.BTCAddress( + coin, + receiveKeypath, + scriptConfig, + true, + ) + require.NoError(t, err) + require.Equal(t, "bc1qdhqnu2arm9al7uv687amuesk5det5nxx0k9ed30x2u8zjsfnsfyqzlsrsu", address) + if device.Version().AtLeast(semver.NewSemVer(9, 20, 0)) { + // Before simulator v9.20, address confirmation data was not written to stdout. + require.Contains(t, + stdOut.String(), + `TITLE: Register +BODY: 1-of-3 +Bitcoin multisig +CONFIRM SCREEN END +CONFIRM SCREEN START +TITLE: Register +BODY: My multisig account +CONFIRM SCREEN END +CONFIRM SCREEN START +TITLE: Register +BODY: p2wsh +at +m/48'/0'/0'/2' +CONFIRM SCREEN END +CONFIRM SCREEN START +TITLE: Register +BODY: Cosigner 1/3 (this device): Zpub74CYNJGx5QwGYeXket9qEbWbEhMNCL1d1Za3eXpABKXMWNVDhTZmovUkzBa74SCrZruMQLGQ6Zce9HzJUaLoF8QPkRU7CVfSSqNZ7Qy2BB5 +CONFIRM SCREEN END +CONFIRM SCREEN START +TITLE: Register +BODY: Cosigner 2/3: Zpub75SBqDwhA5FEf49Epht8JA3YTjbyQdp6eXQ55XDgC7ddhF8bFQQeGS4gPg1YsNsJjoBtRMvk3N4PZtjdQR6QC6fT8TzH2GLNMwxFj3Rnnzx +CONFIRM SCREEN END +CONFIRM SCREEN START +TITLE: Register +BODY: Cosigner 3/3: Zpub75rhyjEXMKYqXKsb4XAbw7dJ1c8YkysDv9Wx15deUB3EnjKSS9avKdnv6jvoE3ydQh174CuXBdVPFREkExqA3mxAFzSPVnVbWzKJGVWvYXJ +CONFIRM SCREEN END +STATUS SCREEN START +TITLE: Multisig account +registered +STATUS SCREEN END +CONFIRM SCREEN START +TITLE: Receive to +BODY: 1-of-3 +Bitcoin multisig +CONFIRM SCREEN END +CONFIRM SCREEN START +TITLE: Receive to +BODY: My multisig account +CONFIRM SCREEN END +CONFIRM SCREEN START +TITLE: Receive to +BODY: bc1qdhqnu2arm9al7uv687amuesk5det5nxx0k9ed30x2u8zjsfnsfyqzlsrsu +CONFIRM SCREEN END +`, + ) + } + }) +} + +// 1-of-3 P2WSH multisig spend +func TestSimulatorBTCSignMultisig(t *testing.T) { + testInitializedSimulators(t, func(t *testing.T, device *Device, stdOut *bytes.Buffer) { + t.Helper() + coin := messages.BTCCoin_BTC + + keypathAccount := []uint32{ + 48 + hardenedKeyStart, + 0 + hardenedKeyStart, + 0 + hardenedKeyStart, + 2 + hardenedKeyStart, + } + + changeKeypath := append(append([]uint32{}, keypathAccount...), 1, 0) + inputKeypath := append(append([]uint32{}, keypathAccount...), 0, 0) + + ourXPub, err := device.BTCXPub(coin, keypathAccount, messages.BTCPubRequest_XPUB, false) + require.NoError(t, err) + + xpubs := []string{ + ourXPub, + "xpub6Esa6esRHkbuXtbdDKqu3uWjQ1GpK39WW2hxbUAN4L3TxrwDyghEwBtUYZ8uK8LZh3tJ3pjWEpxng9tjfo7RT9BaZKV2T3EPvmZ6N1LgSdj", + "xpub6FJ6FAAFUzuWQAKyT98Ngs6UwsoPfPCdmepqX2aLLPT54M85ARsWzPciFd49foStMwhWgfiHP6PnMgPrWLrBJpUHgqw8vZPd5ov8uSfW2vo", + } + ourXPubIndex := uint32(0) + threshold := 1 + + _, inputPkScript := multisigP2WSH(threshold, xpubs, false, 0) + + scriptConfig, err := NewBTCScriptConfigMultisig(uint32(threshold), xpubs, ourXPubIndex) + require.NoError(t, err) + + // The multisig account has to be registered if not already. + registered, err := device.BTCIsScriptConfigRegistered(coin, scriptConfig, keypathAccount) + require.NoError(t, err) + require.False(t, registered) + + err = device.BTCRegisterScriptConfig(coin, scriptConfig, keypathAccount, "My multisig account") + require.NoError(t, err) + + prevTx := &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: *mustOutpoint("3131313131313131313131313131313131313131313131313131313131313131:0"), + Sequence: 0xFFFFFFFF, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 100_000_000, + PkScript: inputPkScript, + }, + }, + LockTime: 0, + } + convertedPrevTx := NewBTCPrevTxFromBtcd(prevTx) + + scriptConfigs := []*messages.BTCScriptConfigWithKeypath{ + { + ScriptConfig: scriptConfig, + Keypath: keypathAccount, + }, + } + require.True(t, BTCSignNeedsPrevTxs(scriptConfigs)) + + prevTxHash := prevTx.TxHash() + _, err = device.BTCSign( + coin, + scriptConfigs, + nil, + &BTCTx{ + Version: 2, + Inputs: []*BTCTxInput{ + { + Input: &messages.BTCSignInputRequest{ + PrevOutHash: prevTxHash[:], + PrevOutIndex: 0, + PrevOutValue: uint64(prevTx.TxOut[0].Value), + Sequence: 0xFFFFFFFF, + Keypath: inputKeypath, + ScriptConfigIndex: 0, + }, + PrevTx: convertedPrevTx, + }, + }, + Outputs: []*messages.BTCSignOutputRequest{ + { + Ours: true, + Value: 70_000_000, + Keypath: changeKeypath, + }, + { + Value: 20_000_000, + Payload: []byte("11111111111111111111111111111111"), + Type: messages.BTCOutputType_P2WSH, + }, + }, + Locktime: 0, + }, + messages.BTCSignInitRequest_DEFAULT, + ) + require.NoError(t, err) + }) +} diff --git a/api/firmware/psbt_test.go b/api/firmware/psbt_test.go index ba6d733..9641157 100644 --- a/api/firmware/psbt_test.go +++ b/api/firmware/psbt_test.go @@ -1028,3 +1028,127 @@ CONFIRM SCREEN END` require.NotContains(t, stdOut.String(), address.String()) }) } + +// 1-of-3 P2WSH multisig spend +func TestSimulatorBTCPSBTMultisig(t *testing.T) { + testInitializedSimulators(t, func(t *testing.T, device *Device, stdOut *bytes.Buffer) { + t.Helper() + + fingerprint, err := device.RootFingerprint() + require.NoError(t, err) + + coin := messages.BTCCoin_BTC + + keypathAccount := []uint32{ + 48 + hardenedKeyStart, + 0 + hardenedKeyStart, + 0 + hardenedKeyStart, + 2 + hardenedKeyStart, + } + + changeKeypath := append(append([]uint32{}, keypathAccount...), 1, 0) + inputKeypath := append(append([]uint32{}, keypathAccount...), 0, 0) + + inputPubKey := simulatorPub(t, device, inputKeypath...) + changePubKey := simulatorPub(t, device, changeKeypath...) + + ourXPub, err := device.BTCXPub(coin, keypathAccount, messages.BTCPubRequest_XPUB, false) + require.NoError(t, err) + + xpubs := []string{ + ourXPub, + "xpub6Esa6esRHkbuXtbdDKqu3uWjQ1GpK39WW2hxbUAN4L3TxrwDyghEwBtUYZ8uK8LZh3tJ3pjWEpxng9tjfo7RT9BaZKV2T3EPvmZ6N1LgSdj", + "xpub6FJ6FAAFUzuWQAKyT98Ngs6UwsoPfPCdmepqX2aLLPT54M85ARsWzPciFd49foStMwhWgfiHP6PnMgPrWLrBJpUHgqw8vZPd5ov8uSfW2vo", + } + ourXPubIndex := uint32(0) + threshold := 1 + + inputWitnessScript, inputPkScript := multisigP2WSH(threshold, xpubs, false, 0) + changeWitnessScript, changePkScript := multisigP2WSH(threshold, xpubs, true, 0) + + scriptConfig, err := NewBTCScriptConfigMultisig(uint32(threshold), xpubs, ourXPubIndex) + require.NoError(t, err) + + // The multisig account has to be registered if not already. + registered, err := device.BTCIsScriptConfigRegistered(coin, scriptConfig, keypathAccount) + require.NoError(t, err) + require.False(t, registered) + + err = device.BTCRegisterScriptConfig(coin, scriptConfig, keypathAccount, "My multisig account") + require.NoError(t, err) + + // Previous transaction with mixed outputs + prevTx := &wire.MsgTx{ + Version: 2, + LockTime: 0, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: *mustOutpoint("3131313131313131313131313131313131313131313131313131313131313131:0"), + Sequence: 0xFFFFFFFF, + }}, + TxOut: []*wire.TxOut{ + { + Value: 100_000_000, + PkScript: inputPkScript, + }, + }, + } + + // Spending transaction + tx := &wire.MsgTx{ + Version: 2, + LockTime: 0, + TxIn: []*wire.TxIn{ + {PreviousOutPoint: wire.OutPoint{Hash: prevTx.TxHash(), Index: 0}}, + }, + TxOut: []*wire.TxOut{ + { // Change output (P2WSH multisig) + Value: 70_000_000, + PkScript: changePkScript, + }, + { // External output + Value: 20_000_000, + // random private key: + // 9dbb534622a6100a39b73dece43c6d4db14b9a612eb46a6c64c2bb849e283ce8 + PkScript: p2trPkScript(unhex("e4adbb12c3426ec71ebb10688d8ae69d531ca822a2b790acee216a7f1b95b576")), + }, + }, + } + + psbt_, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + // Setup PSBT inputs + // Input 0 (P2WSH multisig) + psbt_.Inputs[0].NonWitnessUtxo = prevTx + psbt_.Inputs[0].WitnessUtxo = prevTx.TxOut[0] + psbt_.Inputs[0].WitnessScript = inputWitnessScript + psbt_.Inputs[0].Bip32Derivation = []*psbt.Bip32Derivation{{ + PubKey: inputPubKey.SerializeCompressed(), + MasterKeyFingerprint: binary.LittleEndian.Uint32(fingerprint), + Bip32Path: inputKeypath, + }} + + // Setup change output (P2WSH multisig) + psbt_.Outputs[0].WitnessScript = changeWitnessScript + psbt_.Outputs[0].Bip32Derivation = []*psbt.Bip32Derivation{{ + PubKey: changePubKey.SerializeCompressed(), + MasterKeyFingerprint: binary.LittleEndian.Uint32(fingerprint), + Bip32Path: changeKeypath, + }} + + signOptions := &PSBTSignOptions{ + ForceScriptConfig: &messages.BTCScriptConfigWithKeypath{ + ScriptConfig: scriptConfig, + Keypath: keypathAccount, + }, + } + needsPrevTxs, err := device.BTCSignNeedsNonWitnessUTXOs(psbt_, signOptions) + require.NoError(t, err) + require.True(t, needsPrevTxs) + + // Sign & validate + require.NoError(t, device.BTCSignPSBT(coin, psbt_, signOptions)) + require.NoError(t, psbt.MaybeFinalizeAll(psbt_)) + require.NoError(t, txValidityCheck(psbt_)) + }) +}