From 95b791d2adae12532e68f9fb653ea598029ab9f9 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 | 246 +++++++++++++++++++++++++++ btcutil/silentpayments/input_test.go | 89 ++++++++++ btcutil/silentpayments/output.go | 11 ++ 3 files changed, 346 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..7804ba8678 --- /dev/null +++ b/btcutil/silentpayments/input.go @@ -0,0 +1,246 @@ +package silentpayments + +import ( + "bytes" + "encoding/binary" + "fmt" + "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" + "sort" +) + +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") +) + +// 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 +} + +// 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 + + 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 use the BIP-0086 tweak and also take the + // even key. + if script.Class() == txscript.WitnessV1TaprootTy { + fakeScriptroot := []byte{} + a = txscript.TweakTaprootPrivKey(*a, fakeScriptroot) + + 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, fmt.Errorf("sum of input keys is zero") + } + 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 +} diff --git a/btcutil/silentpayments/input_test.go b/btcutil/silentpayments/input_test.go new file mode 100644 index 0000000000..51d230bc17 --- /dev/null +++ b/btcutil/silentpayments/input_test.go @@ -0,0 +1,89 @@ +package silentpayments + +import ( + "encoding/hex" + "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" + "testing" +) + +// 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 + t.Run(vector.Comment, func(tt *testing.T) { + runCreateOutputTest(tt, vector) + }) + } +} + +// 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) + + privKeyBytes, err := hex.DecodeString( + vin.PrivateKey, + ) + require.NoError(t, err) + privKey, _ := btcec.PrivKeyFromBytes( + privKeyBytes, + ) + + inputs = append(inputs, Input{ + OutPoint: *outpoint, + Utxo: *utxo, + PrivKey: *privKey, + }) + } + + 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) + 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])) + + for idx, output := range result { + resultBytes := schnorr.SerializePubKey( + output.OutputKey, + ) + + require.Equal( + t, sending.Expected.Outputs[0][idx], + hex.EncodeToString(resultBytes), + ) + } + } +} 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