From f8bf37655d4de0f05d78a26aea362b2325c4290e Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Sun, 1 Sep 2024 22:26:00 +0200 Subject: [PATCH] silentpayments: add send output support --- btcutil/silentpayments/input.go | 288 +++++++++++++++++++++++++++ btcutil/silentpayments/input_test.go | 237 ++++++++++++++++++++++ btcutil/silentpayments/output.go | 11 + 3 files changed, 536 insertions(+) create mode 100644 btcutil/silentpayments/input.go create mode 100644 btcutil/silentpayments/input_test.go create mode 100644 btcutil/silentpayments/output.go diff --git a/btcutil/silentpayments/input.go b/btcutil/silentpayments/input.go new file mode 100644 index 0000000000..b2755284b9 --- /dev/null +++ b/btcutil/silentpayments/input.go @@ -0,0 +1,288 @@ +package silentpayments + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "sort" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + secp "github.com/decred/dcrd/dcrec/secp256k1/v4" +) + +var ( + // TagBIP0352Inputs is the BIP-0352 tag for a inputs. + TagBIP0352Inputs = []byte("BIP0352/Inputs") + + // TagBIP0352SharedSecret is the BIP-0352 tag for a shared secret. + TagBIP0352SharedSecret = []byte("BIP0352/SharedSecret") + + // ErrInputKeyZero is returned if the sum of input keys is zero. + ErrInputKeyZero = errors.New("sum of input keys is zero") +) + +// Input describes a UTXO that should be spent in order to pay to one or +// multiple silent addresses. +type Input struct { + // OutPoint is the outpoint of the UTXO. + OutPoint wire.OutPoint + + // Utxo is script and amount of the UTXO. + Utxo wire.TxOut + + // PrivKey is the private key of the input. + // TODO(guggero): Find a way to do this in a remote signer setup where + // we don't have access to the raw private key. We could restrict the + // number of inputs to a single one, then we can do ECDH directly? Or + // is there a PSBT protocol for this? + PrivKey btcec.PrivateKey + + // SkipInput must be set to true if the input should be skipped because + // it meets one of the following conditions: + // - It is a P2TR input with a NUMS internal key. + // - It is a P2WPKH input with an uncompressed public key. + // - It is a P2SH (nested P2WPKH) input with an uncompressed public key. + SkipInput bool +} + +// CreateOutputs creates the outputs for a silent payment transaction. It +// returns the public keys of the outputs that should be used to create the +// transaction. +func CreateOutputs(inputs []Input, + recipients []Address) ([]OutputWithAddress, error) { + + if len(inputs) == 0 { + return nil, fmt.Errorf("no inputs provided") + } + + // We first sum up all the private keys of the inputs. + // + // Spec: Let a = a1 + a2 + ... + a_n, where each a_i has been negated if + // necessary. + var sumKey = new(btcec.ModNScalar) + for idx, input := range inputs { + a := &input.PrivKey + + // If we should skip the input because it is a P2TR input with a + // NUMS internal key, we can just continue here. + if input.SkipInput { + continue + } + + ok, script, err := InputCompatible(input.Utxo.PkScript) + if err != nil { + return nil, fmt.Errorf("unable check input %d for "+ + "silent payment transaction compatibility: %w", + idx, err) + } + + if !ok { + return nil, fmt.Errorf("input %d (%v) is not "+ + "compatible with silent payment transactions", + idx, script.Class().String()) + } + + // For P2TR we need to take the even key. + if script.Class() == txscript.WitnessV1TaprootTy { + pubKeyBytes := a.PubKey().SerializeCompressed() + if pubKeyBytes[0] == secp.PubKeyFormatCompressedOdd { + a.Key.Negate() + } + } + + sumKey = sumKey.Add(&a.Key) + } + + // Spec: If a = 0, fail. + if sumKey.IsZero() { + return nil, ErrInputKeyZero + } + sumPrivKey := btcec.PrivKeyFromScalar(sumKey) + + // Now we need to choose the smallest outpoint lexicographically. We can + // do that by sorting the inputs. + // + // Spec: Let input_hash = hashBIP0352/Inputs(outpointL || A), where + // outpointL is the smallest outpoint lexicographically used in the + // transaction and A = a·G + sort.Sort(sortableInputSlice(inputs)) + input := inputs[0] + + var inputPayload bytes.Buffer + err := wire.WriteOutPoint(&inputPayload, 0, 0, &input.OutPoint) + if err != nil { + return nil, err + } + _, err = inputPayload.Write(sumPrivKey.PubKey().SerializeCompressed()) + if err != nil { + return nil, err + } + inputHash := chainhash.TaggedHash( + TagBIP0352Inputs, inputPayload.Bytes(), + ) + + // Create a copy of the sum key and tweak it with the input hash. + // + // Spec: Let ecdh_shared_secret = input_hash·a·B_scan. + tweakedSumKey := *sumKey + var tweakScalar btcec.ModNScalar + tweakScalar.SetBytes((*[32]byte)(inputHash)) + tweakedSumKey = *(tweakedSumKey.Mul(&tweakScalar)) + + // Spec: For each B_m in the group: + results := make([]OutputWithAddress, 0, len(recipients)) + for _, recipients := range GroupByScanKey(recipients) { + // We grouped by scan key before, so we can just take the first + // one. + scanPubKey := recipients[0].ScanKey + + for idx, recipient := range recipients { + recipientSendKey := recipient.TweakedSpendKey() + + // TweakedSumKey is only input_hash·a, so we need to + // multiply it by B_scan. + // + // Spec: Let ecdh_shared_secret = input_hash·a·B_scan. + var scanKey, sharedSecret btcec.JacobianPoint + scanPubKey.AsJacobian(&scanKey) + btcec.ScalarMultNonConst( + &tweakedSumKey, &scanKey, &sharedSecret, + ) + sharedSecret.ToAffine() + + // Spec: Let tk = hashBIP0352/SharedSecret( + // serP(ecdh_shared_secret) || ser32(k)) + sharedSecretBytes := btcec.NewPublicKey( + &sharedSecret.X, &sharedSecret.Y, + ).SerializeCompressed() + + outputPayload := make([]byte, pubKeyLength+4) + copy(outputPayload[:], sharedSecretBytes) + + k := uint32(idx) + binary.BigEndian.PutUint32( + outputPayload[pubKeyLength:], k, + ) + + t := chainhash.TaggedHash( + TagBIP0352SharedSecret, outputPayload, + ) + + var tScalar btcec.ModNScalar + overflow := tScalar.SetBytes((*[32]byte)(t)) + + // Spec: If tk is not valid tweak, i.e., if tk = 0 or tk + // is larger or equal to the secp256k1 group order, + // fail. + if overflow == 1 { + return nil, fmt.Errorf("tagged hash overflow") + } + if tScalar.IsZero() { + return nil, fmt.Errorf("tagged hash is zero") + } + + // Spec: Let Pmn = Bm + tk·G + var sharedKey, sendKey btcec.JacobianPoint + recipientSendKey.AsJacobian(&sendKey) + btcec.ScalarBaseMultNonConst(&tScalar, &sharedKey) + btcec.AddNonConst(&sendKey, &sharedKey, &sharedKey) + sharedKey.ToAffine() + + results = append(results, OutputWithAddress{ + Address: recipient, + OutputKey: btcec.NewPublicKey( + &sharedKey.X, &sharedKey.Y, + ), + }) + } + } + + return results, nil +} + +// InputCompatible checks if a given pkScript is compatible with the silent +// payment protocol. +func InputCompatible(pkScript []byte) (bool, txscript.PkScript, error) { + script, err := txscript.ParsePkScript(pkScript) + if err != nil { + return false, txscript.PkScript{}, fmt.Errorf("error parsing "+ + "pkScript: %w", err) + } + + switch script.Class() { + case txscript.PubKeyHashTy, txscript.WitnessV0PubKeyHashTy, + txscript.WitnessV1TaprootTy: + + // These types are supported in any case. + return true, script, nil + + case txscript.ScriptHashTy: + // Only P2SH-P2WPKH is supported. Do we need further checks? + // Or do we just assume Nested P2WPKH is the only active use + // case of P2SH these days? + return true, script, nil + + default: + return false, script, nil + } +} + +// serializeOutpoint serializes an outpoint to a byte slice. +func serializeOutpoint(outpoint wire.OutPoint) []byte { + var buf bytes.Buffer + _ = wire.WriteOutPoint(&buf, 0, 0, &outpoint) + return buf.Bytes() +} + +// sortableInputSlice is a slice of inputs that can be sorted lexicographically. +type sortableInputSlice []Input + +// Len returns the number of inputs in the slice. +func (s sortableInputSlice) Len() int { return len(s) } + +// Swap swaps the inputs at the passed indices. +func (s sortableInputSlice) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +// Less returns whether the input at index i should be sorted before the input +// at index j lexicographically. +func (s sortableInputSlice) Less(i, j int) bool { + // Input hashes are the same, so compare the index. + iBytes := serializeOutpoint(s[i].OutPoint) + jBytes := serializeOutpoint(s[j].OutPoint) + + return bytes.Compare(iBytes[:], jBytes[:]) == -1 +} + +func receiverPermutations(arr []Address) [][]Address { + var helper func([]Address, int) + var res [][]Address + + helper = func(arr []Address, n int) { + if n == 1 { + tmp := make([]Address, len(arr)) + copy(tmp, arr) + res = append(res, tmp) + } else { + for i := 0; i < n; i++ { + helper(arr, n-1) + if n%2 == 1 { + tmp := arr[i] + arr[i] = arr[n-1] + arr[n-1] = tmp + } else { + tmp := arr[0] + arr[0] = arr[n-1] + arr[n-1] = tmp + } + } + } + } + helper(arr, len(arr)) + return res +} diff --git a/btcutil/silentpayments/input_test.go b/btcutil/silentpayments/input_test.go new file mode 100644 index 0000000000..42963fb127 --- /dev/null +++ b/btcutil/silentpayments/input_test.go @@ -0,0 +1,237 @@ +package silentpayments + +import ( + "bytes" + "encoding/hex" + "errors" + "github.com/btcsuite/btcd/txscript" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +var ( + // BIP0341NUMSPoint is an example NUMS point as defined in BIP-0341. + BIP0341NUMSPoint = []byte{ + 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, + 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, 0x5e, + 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, + 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0, + } +) + +// TestCreateOutputs tests the generation of silent payment outputs. +func TestCreateOutputs(t *testing.T) { + vectors, err := ReadTestVectors() + require.NoError(t, err) + + for _, vector := range vectors { + vector := vector + success := t.Run(vector.Comment, func(tt *testing.T) { + runCreateOutputTest(tt, vector) + }) + + if !success { + break + } + } +} + +// runCreateOutputTest tests the generation of silent payment outputs. +func runCreateOutputTest(t *testing.T, vector *TestVector) { + for _, sending := range vector.Sending { + inputs := make([]Input, 0, len(sending.Given.Vin)) + for _, vin := range sending.Given.Vin { + txid, err := chainhash.NewHashFromStr(vin.Txid) + require.NoError(t, err) + + outpoint := wire.NewOutPoint(txid, vin.Vout) + + pkScript, err := hex.DecodeString( + vin.PrevOut.ScriptPubKey.Hex, + ) + require.NoError(t, err) + utxo := wire.NewTxOut(0, pkScript) + + sigScript, err := hex.DecodeString(vin.ScriptSig) + require.NoError(t, err) + + witnessBytes, err := hex.DecodeString(vin.TxInWitness) + require.NoError(t, err) + + skip := shouldSkip(t, pkScript, sigScript, witnessBytes) + + privKeyBytes, err := hex.DecodeString( + vin.PrivateKey, + ) + require.NoError(t, err) + privKey, _ := btcec.PrivKeyFromBytes( + privKeyBytes, + ) + + inputs = append(inputs, Input{ + OutPoint: *outpoint, + Utxo: *utxo, + PrivKey: *privKey, + SkipInput: skip, + }) + } + + recipients := make( + []Address, 0, len(sending.Given.Recipients), + ) + for _, recipient := range sending.Given.Recipients { + addr, err := DecodeAddress(recipient) + require.NoError(t, err) + + recipients = append(recipients, *addr) + } + + result, err := CreateOutputs(inputs, recipients) + + // Special case for when the input keys add up to zero. + if errors.Is(err, ErrInputKeyZero) { + require.Empty(t, sending.Expected.Outputs[0]) + + continue + } + + require.NoError(t, err) + + if len(result) == 0 { + require.Empty(t, sending.Expected.Outputs[0]) + + continue + } + + require.Len(t, result, len(sending.Expected.Outputs[0])) + + resultStrings := make([]string, len(result)) + for idx, output := range result { + resultStrings[idx] = hex.EncodeToString( + schnorr.SerializePubKey(output.OutputKey), + ) + } + + resultsContained(t, sending.Expected.Outputs, resultStrings) + } +} + +func shouldSkip(t *testing.T, pkScript, sigScript, witnessBytes []byte) bool { + script, err := txscript.ParsePkScript(pkScript) + require.NoError(t, err) + + // Special case for P2PKH: + if script.Class() == txscript.PubKeyHashTy { + return checkPubKeyScriptSig(t, sigScript) + } + + // Special case for P2SH with script sig only: + if script.Class() == txscript.ScriptHashTy && len(witnessBytes) == 0 && + len(sigScript) != 0 { + + return checkPubKeyScriptSig(t, sigScript) + + } + + if len(witnessBytes) == 0 { + return false + } + + witness, err := parseWitness(witnessBytes) + require.NoError(t, err) + + if len(witness) == 0 { + return true + } + + switch script.Class() { + case txscript.WitnessV0PubKeyHashTy: + lastWitness := witness[len(witness)-1] + + return len(lastWitness) != btcec.PubKeyBytesLenCompressed + + case txscript.ScriptHashTy: + lastWitness := witness[len(witness)-1] + + return len(lastWitness) != btcec.PubKeyBytesLenCompressed + + case txscript.WitnessV1TaprootTy: + return isNUMSWitness(witnessBytes) + + default: + return true + } +} + +func checkPubKeyScriptSig(t *testing.T, sigScript []byte) bool { + // If the sigScript isn't set, we just assume a valid key. + if len(sigScript) == 0 { + return false + } + + tokenizer := txscript.MakeScriptTokenizer(0, sigScript) + for tokenizer.Next() { + if tokenizer.Opcode() == txscript.OP_DATA_33 && + len(tokenizer.Data()) == 33 { + + return false + } + } + if err := tokenizer.Err(); err != nil { + t.Fatalf("error tokenizing sigScript: %v", err) + } + + // If there was a sigScript set but there was no 33-byte + // compressed key push, we skip the input. + return true +} + +func isNUMSWitness(witnessBytes []byte) bool { + return bytes.Contains(witnessBytes, BIP0341NUMSPoint) +} + +func parseWitness(witnessBytes []byte) (wire.TxWitness, error) { + witnessReader := bytes.NewReader(witnessBytes) + witCount, err := wire.ReadVarInt(witnessReader, 0) + if err != nil { + return nil, err + } + + result := make(wire.TxWitness, witCount) + for j := uint64(0); j < witCount; j++ { + wit, err := wire.ReadVarBytes( + witnessReader, 0, txscript.MaxScriptSize, "witness", + ) + if err != nil { + return nil, err + } + result[j] = wit + } + + return result, nil +} + +func resultsContained(t *testing.T, expected [][]string, results []string) { + for _, expectedSet := range expected { + contained := false + for _, e := range expectedSet { + for _, r := range results { + if e == r { + contained = true + break + } + } + } + + if contained { + return + } + } + + require.Fail(t, "no expected output found in results") +} diff --git a/btcutil/silentpayments/output.go b/btcutil/silentpayments/output.go new file mode 100644 index 0000000000..2cf09ffb2f --- /dev/null +++ b/btcutil/silentpayments/output.go @@ -0,0 +1,11 @@ +package silentpayments + +import "github.com/btcsuite/btcd/btcec/v2" + +type OutputWithAddress struct { + // Address is the address of the output. + Address Address + + // OutputKey is the generated shared public key for the given address. + OutputKey *btcec.PublicKey +} \ No newline at end of file