From 17a6b48475c254ec8cc42da964cf7f51bfadd241 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Fri, 17 Apr 2026 04:28:21 -0700 Subject: [PATCH 01/25] feat(zuul/memory): multi-form key indexing A key stored via PutKey is now simultaneously indexed under the P2PKH, P2WPKH, and BIP-86 P2TR addresses that derive from its public key. LookupKeyByAddr and GetKey succeed for any of the three forms without requiring the caller to know which address type was used at enrollment. PurgeKey recomputes all three addresses from the stored public key and removes every entry before zeroing the private key, so purging via any address form fully evicts the key. The PutKey all-or-nothing check fails with ErrKeyExists if any of the target addresses already points at a stored key, preventing partial inserts on collision. --- bitcoin/wallet/zuul/memory/memory.go | 111 +++++++- .../zuul/memory/memory_multiaddr_test.go | 247 ++++++++++++++++++ 2 files changed, 349 insertions(+), 9 deletions(-) create mode 100644 bitcoin/wallet/zuul/memory/memory_multiaddr_test.go diff --git a/bitcoin/wallet/zuul/memory/memory.go b/bitcoin/wallet/zuul/memory/memory.go index 15a1d23e4..8f9188b31 100644 --- a/bitcoin/wallet/zuul/memory/memory.go +++ b/bitcoin/wallet/zuul/memory/memory.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Hemi Labs, Inc. +// Copyright (c) 2025-2026 Hemi Labs, Inc. // Use of this source code is governed by the MIT License, // which can be found in the LICENSE file. @@ -10,13 +10,20 @@ import ( "sync" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul" ) // memoryZuul is an in-memory implementation of [zuul.Zuul]. +// +// Keys are indexed under every address type that derives from their +// public key, so LookupKeyByAddr succeeds regardless of which address +// form a caller presents. Currently indexes P2PKH, P2WPKH, and +// BIP-86 P2TR addresses. type memoryZuul struct { mtx sync.Mutex params *chaincfg.Params @@ -34,24 +41,82 @@ func New(params *chaincfg.Params) (zuul.Zuul, error) { return m, nil } +// addressesForPubKey returns the set of addresses that derive from the +// given compressed public key: P2PKH, P2WPKH, and BIP-86 P2TR. The +// zuul stores the same NamedKey under each of these so callers may +// look up keys by whichever address form they encounter. +func addressesForPubKey(params *chaincfg.Params, pubCompressed []byte) ([]string, error) { + addrs := make([]string, 0, 3) + + // P2PKH (legacy). + pkHash := btcutil.Hash160(pubCompressed) + p2pkh, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + return nil, fmt.Errorf("p2pkh address: %w", err) + } + addrs = append(addrs, p2pkh.EncodeAddress()) + + // P2WPKH (native segwit v0). + p2wpkh, err := btcutil.NewAddressWitnessPubKeyHash(pkHash, params) + if err != nil { + return nil, fmt.Errorf("p2wpkh address: %w", err) + } + addrs = append(addrs, p2wpkh.EncodeAddress()) + + // BIP-86 P2TR (key-path only, no script commitment). + pub, err := btcec.ParsePubKey(pubCompressed) + if err != nil { + return nil, fmt.Errorf("parse pubkey: %w", err) + } + outputKey := txscript.ComputeTaprootKeyNoScript(pub) + p2tr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(outputKey), params, + ) + if err != nil { + return nil, fmt.Errorf("p2tr address: %w", err) + } + addrs = append(addrs, p2tr.EncodeAddress()) + + return addrs, nil +} + +// PutKey enrols a local private key. The key is indexed under every +// address form that derives from its public key — currently P2PKH, +// P2WPKH, and BIP-86 P2TR — so callers can later look it up by any +// of those addresses. +// +// All-or-nothing: if any of the derived addresses already maps to +// a stored key (local or TSS-controlled), the call returns +// ErrKeyExists without mutating state. This prevents a single +// PutKey from partially populating the index when a collision +// exists on one address form but not others. func (m *memoryZuul) PutKey(nk *zuul.NamedKey) error { - // Generate address for lookup pubBytes := nk.PrivateKey.PubKey().SerializeCompressed() - btcAddress, err := btcutil.NewAddressPubKey(pubBytes, m.params) + addrs, err := addressesForPubKey(m.params, pubBytes) if err != nil { - return fmt.Errorf("new address: %w", err) + return err } - addr := btcAddress.AddressPubKeyHash().String() m.mtx.Lock() defer m.mtx.Unlock() - if _, ok := m.keys[addr]; ok { - return zuul.ErrKeyExists + + // All-or-nothing: if any address already points at a stored key, + // refuse the put without mutating. + for _, a := range addrs { + if _, ok := m.keys[a]; ok { + return zuul.ErrKeyExists + } + } + for _, a := range addrs { + m.keys[a] = nk } - m.keys[addr] = nk return nil } +// GetKey returns the local NamedKey indexed under addr. Addr may be +// any of the address forms under which the key was enrolled (P2PKH, +// P2WPKH, P2TR-BIP86); the same key is returned for all of them. +// Returns ErrKeyDoesntExist if addr is unknown or is TSS-controlled. func (m *memoryZuul) GetKey(addr btcutil.Address) (*zuul.NamedKey, error) { m.mtx.Lock() defer m.mtx.Unlock() @@ -63,6 +128,19 @@ func (m *memoryZuul) GetKey(addr btcutil.Address) (*zuul.NamedKey, error) { return nk, nil } +// PurgeKey removes the key indexed under addr from every address +// form it was stored under and zeroes the underlying scalar. +// +// SECURITY CONTRACT: PurgeKey zeroes the private key scalar in +// place. Any goroutine still holding a pointer to this key from a +// prior GetKey/LookupKeyByAddr call will observe the zeroed scalar +// and produce invalid signatures. Callers must guarantee no +// outstanding signing operation is in flight before invoking +// PurgeKey — the zuul mutex only serialises concurrent zuul calls, +// not the signing operations that run outside it. The zeroing is +// deliberate: a caller invoking PurgeKey wants the secret gone, +// and leaving it in memory for late signers to use would defeat +// that intent. func (m *memoryZuul) PurgeKey(addr btcutil.Address) error { m.mtx.Lock() defer m.mtx.Unlock() @@ -71,12 +149,27 @@ func (m *memoryZuul) PurgeKey(addr btcutil.Address) error { if !ok { return zuul.ErrKeyDoesntExist } - delete(m.keys, addr.String()) + + // Remove every address form this key was indexed under. + pubBytes := nk.PrivateKey.PubKey().SerializeCompressed() + addrs, err := addressesForPubKey(m.params, pubBytes) + if err != nil { + return fmt.Errorf("derive addresses: %w", err) + } + for _, a := range addrs { + delete(m.keys, a) + } + nk.PrivateKey.Zero() nk.PrivateKey = nil return nil } +// LookupKeyByAddr returns the private key for a local-key address. +// Designed for signing: returns (priv, true, nil) when the address is +// a locally-enrolled key, and (nil, false, ErrKeyDoesntExist) when +// the address is unknown or TSS-controlled. Addr may be any of the +// indexed address forms (P2PKH, P2WPKH, P2TR-BIP86). func (m *memoryZuul) LookupKeyByAddr(addr btcutil.Address) (*btcec.PrivateKey, bool, error) { m.mtx.Lock() defer m.mtx.Unlock() diff --git a/bitcoin/wallet/zuul/memory/memory_multiaddr_test.go b/bitcoin/wallet/zuul/memory/memory_multiaddr_test.go new file mode 100644 index 000000000..a2ee33812 --- /dev/null +++ b/bitcoin/wallet/zuul/memory/memory_multiaddr_test.go @@ -0,0 +1,247 @@ +// Copyright (c) 2025-2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package memory + +import ( + "errors" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + + "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul" +) + +// TestMultiAddressIndexing verifies that a key put under one address +// form is discoverable via all address forms that derive from the same +// public key (P2PKH, P2WPKH, BIP-86 P2TR). +func TestMultiAddressIndexing(t *testing.T) { + params := &chaincfg.TestNet3Params + + m, err := New(params) + if err != nil { + t.Fatal(err) + } + + // Generate a deterministic key. + xprv := "xprv9s21ZrQH143K3ScRXhao5KSyozmph3B3Bop8C1iqnyCgXSpUDE8oYDsz2hDp897fwwqdsTFYKNQVg5jn5nLH2QkZWeF9MZeMwkbkN8uAafy" + ek, err := hdkeychain.NewKeyFromString(xprv) + if err != nil { + t.Fatal(err) + } + priv, err := ek.ECPrivKey() + if err != nil { + t.Fatal(err) + } + + err = m.PutKey(&zuul.NamedKey{ + Name: "test", + PrivateKey: priv, + }) + if err != nil { + t.Fatal(err) + } + + // Derive each address form that zuul should now recognize. + pubCompressed := priv.PubKey().SerializeCompressed() + pkHash := btcutil.Hash160(pubCompressed) + + p2pkh, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + p2wpkh, err := btcutil.NewAddressWitnessPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + btcecPub, err := btcec.ParsePubKey(pubCompressed) + if err != nil { + t.Fatal(err) + } + outputKey := txscript.ComputeTaprootKeyNoScript(btcecPub) + p2tr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(outputKey), params, + ) + if err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + addr btcutil.Address + }{ + {"P2PKH", p2pkh}, + {"P2WPKH", p2wpkh}, + {"P2TR-BIP86", p2tr}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := m.GetKey(tc.addr) + if err != nil { + t.Fatalf("GetKey: %v", err) + } + if got.PrivateKey == nil || !got.PrivateKey.Key.Equals(&priv.Key) { + t.Fatalf("GetKey returned wrong key") + } + + gotPriv, ok, err := m.LookupKeyByAddr(tc.addr) + if err != nil { + t.Fatalf("LookupKeyByAddr: %v", err) + } + if !ok { + t.Fatal("LookupKeyByAddr: not found") + } + if !gotPriv.Key.Equals(&priv.Key) { + t.Fatal("LookupKeyByAddr returned wrong key") + } + }) + } + + // Purging via any address form must remove all three entries. + err = m.PurgeKey(p2wpkh) // use segwit address to exercise non-legacy path + if err != nil { + t.Fatalf("PurgeKey: %v", err) + } + + for _, tc := range []struct { + name string + addr btcutil.Address + }{ + {"P2PKH", p2pkh}, + {"P2WPKH", p2wpkh}, + {"P2TR-BIP86", p2tr}, + } { + t.Run("PurgedNotFound_"+tc.name, func(t *testing.T) { + _, err := m.GetKey(tc.addr) + if err == nil || !errors.Is(err, zuul.ErrKeyDoesntExist) { + t.Fatalf("expected ErrKeyDoesntExist, got %v", err) + } + }) + } +} + +// TestPutDuplicateAcrossAddressForms verifies that attempting to +// re-insert the same key (with any of its address forms already +// indexed) returns ErrKeyExists and does not mutate state. +func TestPutDuplicateAcrossAddressForms(t *testing.T) { + params := &chaincfg.TestNet3Params + m, err := New(params) + if err != nil { + t.Fatal(err) + } + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + err = m.PutKey(&zuul.NamedKey{Name: "first", PrivateKey: priv}) + if err != nil { + t.Fatal(err) + } + + // Re-insert: every address form already maps to a key, so the + // insert must fail with ErrKeyExists. + err = m.PutKey(&zuul.NamedKey{Name: "dup", PrivateKey: priv}) + if err == nil || !errors.Is(err, zuul.ErrKeyExists) { + t.Fatalf("expected ErrKeyExists, got %v", err) + } + + // Ensure the original key is still retrievable and unchanged. + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + p2pkh, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + nk, err := m.GetKey(p2pkh) + if err != nil { + t.Fatal(err) + } + if nk.Name != "first" { + t.Fatalf("original key was overwritten: got name %q", nk.Name) + } +} + +// TestPurgeKeyUnknownAddress verifies PurgeKey returns +// ErrKeyDoesntExist when asked to purge an address that was never +// enrolled. Without this guard the caller would have no feedback +// that the purge was a no-op. +func TestPurgeKeyUnknownAddress(t *testing.T) { + params := &chaincfg.TestNet3Params + m, err := New(params) + if err != nil { + t.Fatal(err) + } + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + + err = m.PurgeKey(addr) + if err == nil || !errors.Is(err, zuul.ErrKeyDoesntExist) { + t.Fatalf("PurgeKey on unknown addr: expected ErrKeyDoesntExist, got %v", err) + } +} + +// TestPurgeKeyZeroesOutstandingReference demonstrates the +// documented contract for PurgeKey: a caller holding a pointer to +// the private key observes zeroed scalar bytes after the purge. +// This is not a bug, it is the security guarantee — PurgeKey +// destroys the key material rather than leaving dangling +// references usable. The test exists so a future maintainer who +// accidentally removes the Zero() call sees this fail and +// rediscovers why the zeroing is there. +func TestPurgeKeyZeroesOutstandingReference(t *testing.T) { + params := &chaincfg.TestNet3Params + m, err := New(params) + if err != nil { + t.Fatal(err) + } + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + err = m.PutKey(&zuul.NamedKey{Name: "sacrificial", PrivateKey: priv}) + if err != nil { + t.Fatal(err) + } + + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + + nk, err := m.GetKey(addr) + if err != nil { + t.Fatal(err) + } + // Hold an outstanding reference to the underlying scalar. + outstandingPriv := nk.PrivateKey + if outstandingPriv.Key.IsZero() { + t.Fatal("private key was zero before purge — test setup broken") + } + + if err := m.PurgeKey(addr); err != nil { + t.Fatalf("PurgeKey: %v", err) + } + + // The outstanding reference now points at a zeroed scalar. A + // sign attempt using it would produce garbage. The contract + // is working as documented. + if !outstandingPriv.Key.IsZero() { + t.Fatal("PurgeKey did not zero the outstanding private key scalar") + } +} From c636f9e00588d7b30449c96e52face917ee4e7d1 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Fri, 17 Apr 2026 04:30:43 -0700 Subject: [PATCH 02/25] refactor(wallet): carry amounts in prevOuts Change the prevOuts return type from map[string][]byte to PrevOuts (map[string]*wire.TxOut) so the previous output's amount travels alongside its pkScript. BIP-143 (segwit v0) and BIP-341 (taproot) sighash algorithms commit to the spent amount. Without the amount available at signing time, TransactionSign cannot support witness-based inputs. TransactionCreate and PoPTransactionCreate now emit the richer structure; TransactionSign consumes it. Internal callers (service/popm) treat prevOuts as opaque and need no changes. No signing behaviour changes; this commit only makes the amount available for subsequent signing-path additions. --- bitcoin/wallet/wallet.go | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/bitcoin/wallet/wallet.go b/bitcoin/wallet/wallet.go index 4c355d75d..f0f41f12f 100644 --- a/bitcoin/wallet/wallet.go +++ b/bitcoin/wallet/wallet.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Hemi Labs, Inc. +// Copyright (c) 2025-2026 Hemi Labs, Inc. // Use of this source code is governed by the MIT License, // which can be found in the LICENSE file. @@ -57,7 +57,19 @@ func UtxoPickerSingle(amount, fee btcutil.Amount, utxos []*tbcapi.UTXO) (*tbcapi return nil, errors.New("no suitable utxo found") } -func TransactionCreate(locktime uint32, amount btcutil.Amount, satsPerByte float64, address btcutil.Address, utxos []*tbcapi.UTXO, script []byte) (*wire.MsgTx, map[string][]byte, error) { +// PrevOuts maps an outpoint string to the TxOut that produced it, carrying +// both the previous output's pkScript and its amount. Witness sighash +// algorithms (BIP-143 for segwit v0, BIP-341 for taproot) commit to the +// spent amount, so the amount must be available at signing time. +type PrevOuts map[string]*wire.TxOut + +// TransactionCreate builds an unsigned bitcoin transaction sending amount to +// address, funded from utxos. The fee is estimated from satsPerByte and the +// transaction's virtual size. Change, if non-dust, is returned to the +// address encoded in script. The returned PrevOuts map is keyed by outpoint +// string and carries the pkScript and value needed for witness sighash +// computation. +func TransactionCreate(locktime uint32, amount btcutil.Amount, satsPerByte float64, address btcutil.Address, utxos []*tbcapi.UTXO, script []byte) (*wire.MsgTx, PrevOuts, error) { // Create TxOut payToScript, err := txscript.PayToAddrScript(address) if err != nil { @@ -85,11 +97,11 @@ func TransactionCreate(locktime uint32, amount btcutil.Amount, satsPerByte float // Assemble transaction tx := wire.NewMsgTx(2) // Latest supported version tx.LockTime = locktime - prevOuts := make(map[string][]byte, len(utxoList)) + prevOuts := make(PrevOuts, len(utxoList)) for _, utxo := range utxoList { outpoint := wire.NewOutPoint(&utxo.TxId, utxo.OutIndex) tx.AddTxIn(wire.NewTxIn(outpoint, script, nil)) - prevOuts[outpoint.String()] = script + prevOuts[outpoint.String()] = wire.NewTxOut(int64(utxo.Value), script) } // Change @@ -104,7 +116,10 @@ func TransactionCreate(locktime uint32, amount btcutil.Amount, satsPerByte float return tx, prevOuts, nil } -func PoPTransactionCreate(l2keystone *hemi.L2Keystone, locktime uint32, satsPerByte float64, utxos []*tbcapi.UTXO, script []byte) (*wire.MsgTx, map[string][]byte, error) { +// PoPTransactionCreate builds an unsigned Proof-of-Proof transaction +// embedding l2keystone in an OP_RETURN output. The transaction is +// funded from utxos with change returned to the address in script. +func PoPTransactionCreate(l2keystone *hemi.L2Keystone, locktime uint32, satsPerByte float64, utxos []*tbcapi.UTXO, script []byte) (*wire.MsgTx, PrevOuts, error) { // Create OP_RETURN aks := hemi.L2KeystoneAbbreviate(*l2keystone) popTx := pop.TransactionL2{L2Keystone: aks} @@ -133,7 +148,7 @@ func PoPTransactionCreate(l2keystone *hemi.L2Keystone, locktime uint32, satsPerB // Return previous outs to caller so that they can be signed. // This is a bit odd but in a real transaction we have to return all // the scripts (and somehow obtain them). Think about this some more. - prevOuts := map[string][]byte{outpoint.String(): script} + prevOuts := PrevOuts{outpoint.String(): wire.NewTxOut(int64(utxo.Value), script)} // Change change := utxo.Value - fee @@ -148,15 +163,15 @@ func PoPTransactionCreate(l2keystone *hemi.L2Keystone, locktime uint32, satsPerB return tx, prevOuts, nil } -func TransactionSign(params *chaincfg.Params, z zuul.Zuul, tx *wire.MsgTx, prevOuts map[string][]byte) error { +func TransactionSign(params *chaincfg.Params, z zuul.Zuul, tx *wire.MsgTx, prevOuts PrevOuts) error { for i, txIn := range tx.TxIn { - prevPkScript, ok := prevOuts[txIn.PreviousOutPoint.String()] + prev, ok := prevOuts[txIn.PreviousOutPoint.String()] if !ok { return fmt.Errorf("previous out not found: %v", txIn.PreviousOutPoint) } sigScript, err := txscript.SignTxOutput(params, tx, i, - prevPkScript, txscript.SigHashAll, + prev.PkScript, txscript.SigHashAll, txscript.KeyClosure(z.LookupKeyByAddr), nil, nil) if err != nil { return err From 25785efd2811944c35cc9bdd6dbe0baa08c63e0b Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Fri, 17 Apr 2026 04:36:13 -0700 Subject: [PATCH 03/25] feat(wallet): sign p2wpkh inputs TransactionSign now dispatches per input based on the previous output's script class. Inputs with a WitnessV0PubKeyHashTy pkScript are signed through txscript.WitnessSignature using the BIP-143 sighash; all other classes continue to flow through txscript.SignTxOutput and produce a SignatureScript. The BIP-143 sighash midstate (TxSigHashes) is computed once per transaction via NewTxSigHashes and shared across witness inputs, matching standard practice for multi-witness transactions. prevOutsFetcher adapts PrevOuts to txscript.PrevOutputFetcher. signP2WPKH extracts the native segwit address from the witness program, looks up the key in zuul (which indexes keys under both legacy and segwit forms), and emits the two-element witness stack (sig, pubkey). The existing PoP signing path (TestIntegration) is unchanged in behaviour and continues to pass: P2PKH inputs take the default branch and reach SignTxOutput exactly as before. --- bitcoin/wallet/wallet.go | 92 +++++++++-- bitcoin/wallet/wallet_p2wpkh_test.go | 225 +++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 bitcoin/wallet/wallet_p2wpkh_test.go diff --git a/bitcoin/wallet/wallet.go b/bitcoin/wallet/wallet.go index f0f41f12f..44ed4a917 100644 --- a/bitcoin/wallet/wallet.go +++ b/bitcoin/wallet/wallet.go @@ -163,21 +163,93 @@ func PoPTransactionCreate(l2keystone *hemi.L2Keystone, locktime uint32, satsPerB return tx, prevOuts, nil } +// TransactionSign signs every input of tx using keys looked up in z. +// Inputs are dispatched by the script class of their previous output: +// legacy (P2PKH, P2SH, etc.) inputs produce a SignatureScript via the +// standard txscript.SignTxOutput path; native segwit v0 (P2WPKH) inputs +// produce a witness via BIP-143 sighash. All signatures use SigHashAll. func TransactionSign(params *chaincfg.Params, z zuul.Zuul, tx *wire.MsgTx, prevOuts PrevOuts) error { + // Validate every input has a matching prev-out before any + // sighash computation. Without this pre-check a caller- + // supplied incomplete PrevOuts would surface as a nil-deref + // panic deep inside NewTxSigHashes when it tries to fetch + // the missing amount for witness sighash midstate. for i, txIn := range tx.TxIn { - prev, ok := prevOuts[txIn.PreviousOutPoint.String()] - if !ok { - return fmt.Errorf("previous out not found: %v", - txIn.PreviousOutPoint) + if _, ok := prevOuts[txIn.PreviousOutPoint.String()]; !ok { + return fmt.Errorf("previous out not found: input %d outpoint %v", + i, txIn.PreviousOutPoint) } - sigScript, err := txscript.SignTxOutput(params, tx, i, - prev.PkScript, txscript.SigHashAll, - txscript.KeyClosure(z.LookupKeyByAddr), nil, nil) - if err != nil { - return err + } + + // BIP-143 sighash midstate is reused across all witness inputs in + // the tx. Compute once. + sigHashes := txscript.NewTxSigHashes(tx, prevOutsFetcher(prevOuts)) + + for i, txIn := range tx.TxIn { + prev := prevOuts[txIn.PreviousOutPoint.String()] + + switch txscript.GetScriptClass(prev.PkScript) { + case txscript.WitnessV0PubKeyHashTy: + if err := signP2WPKH(params, z, tx, i, prev, sigHashes); err != nil { + return fmt.Errorf("sign p2wpkh input %d: %w", i, err) + } + + default: + sigScript, err := txscript.SignTxOutput(params, tx, i, + prev.PkScript, txscript.SigHashAll, + txscript.KeyClosure(z.LookupKeyByAddr), nil, nil) + if err != nil { + return err + } + tx.TxIn[i].SignatureScript = sigScript } - tx.TxIn[i].SignatureScript = sigScript } return nil } + +// signP2WPKH signs a witness v0 pubkey hash input. The witness program +// is the 20-byte HASH160 of the pubkey; the sighash is computed over +// the P2PKH-equivalent script per BIP-143. The caller's zuul must +// hold the key for the address derived from the witness program. +func signP2WPKH(params *chaincfg.Params, z zuul.Zuul, tx *wire.MsgTx, idx int, prev *wire.TxOut, sigHashes *txscript.TxSigHashes) error { + _, addrs, _, err := txscript.ExtractPkScriptAddrs(prev.PkScript, params) + if err != nil || len(addrs) != 1 { + return fmt.Errorf("extract p2wpkh address: %w", err) + } + + priv, ok, err := z.LookupKeyByAddr(addrs[0]) + if err != nil || !ok { + return fmt.Errorf("lookup key for %s: %w", addrs[0], err) + } + + witness, err := txscript.WitnessSignature(tx, sigHashes, idx, + prev.Value, prev.PkScript, txscript.SigHashAll, priv, true) + if err != nil { + return fmt.Errorf("witness signature: %w", err) + } + tx.TxIn[idx].Witness = witness + return nil +} + +// prevOutsFetcher adapts PrevOuts to txscript.PrevOutputFetcher as +// required by NewTxSigHashes for segwit and taproot sighash calculation. +// +// PrevOuts keys are produced by wire.OutPoint.String; parsing back via +// wire.NewOutPointFromString is a lossless round-trip for well-formed +// keys. A parse failure means the caller constructed PrevOuts with a +// manually-forged key that does not match any real outpoint, which +// would cause NewTxSigHashes to dereference a nil TxOut downstream. +// Panic with the offending key rather than silently dropping the +// entry and producing a corrupt sighash midstate. +func prevOutsFetcher(p PrevOuts) txscript.PrevOutputFetcher { + m := make(map[wire.OutPoint]*wire.TxOut, len(p)) + for k, v := range p { + op, err := wire.NewOutPointFromString(k) + if err != nil { + panic(fmt.Sprintf("prevOutsFetcher: malformed outpoint key %q: %v", k, err)) + } + m[*op] = v + } + return txscript.NewMultiPrevOutFetcher(m) +} diff --git a/bitcoin/wallet/wallet_p2wpkh_test.go b/bitcoin/wallet/wallet_p2wpkh_test.go new file mode 100644 index 000000000..6c2a50159 --- /dev/null +++ b/bitcoin/wallet/wallet_p2wpkh_test.go @@ -0,0 +1,225 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package wallet + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + + "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul" + "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul/memory" +) + +// verifyInput runs the script engine on a single input and returns the +// result. For witness inputs the engine walks both the witness and +// the prev pkScript and applies BIP-143 sighash verification. +func verifyInput(tx *wire.MsgTx, idx int, prev *wire.TxOut) error { + flags := txscript.StandardVerifyFlags + fetcher := txscript.NewCannedPrevOutputFetcher(prev.PkScript, prev.Value) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + vm, err := txscript.NewEngine(prev.PkScript, tx, idx, flags, nil, + sigHashes, prev.Value, fetcher) + if err != nil { + return err + } + return vm.Execute() +} + +// TestSignP2WPKHInput exercises witness v0 pubkey hash signing through +// the public TransactionSign entry point and verifies the resulting +// witness satisfies the script engine. +func TestSignP2WPKHInput(t *testing.T) { + params := &chaincfg.TestNet3Params + + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + err = m.PutKey(&zuul.NamedKey{Name: "p2wpkh", PrivateKey: priv}) + if err != nil { + t.Fatal(err) + } + + // Build the P2WPKH pkScript for the previous output. + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + p2wpkhAddr, err := btcutil.NewAddressWitnessPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + p2wpkhScript, err := txscript.PayToAddrScript(p2wpkhAddr) + if err != nil { + t.Fatal(err) + } + + // Synthesize a funding outpoint; the hash value is arbitrary — + // the engine only checks signatures against the tx and prev TxOut. + fundHash := chainhash.DoubleHashH([]byte("test-funding-txid-00000000000000")) + fundOutpoint := wire.NewOutPoint(&fundHash, 0) + const fundValue int64 = 100_000 + + // Build a tx that spends the P2WPKH input and sends half to a + // throwaway P2PKH output. + destPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + destPKHash := btcutil.Hash160(destPriv.PubKey().SerializeCompressed()) + destAddr, err := btcutil.NewAddressPubKeyHash(destPKHash, params) + if err != nil { + t.Fatal(err) + } + destScript, err := txscript.PayToAddrScript(destAddr) + if err != nil { + t.Fatal(err) + } + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(fundOutpoint, nil, nil)) + tx.AddTxOut(wire.NewTxOut(fundValue/2, destScript)) + + prevOuts := PrevOuts{ + fundOutpoint.String(): wire.NewTxOut(fundValue, p2wpkhScript), + } + + err = TransactionSign(params, m, tx, prevOuts) + if err != nil { + t.Fatalf("TransactionSign: %v", err) + } + + if len(tx.TxIn[0].Witness) != 2 { + t.Fatalf("expected 2-element witness, got %d", len(tx.TxIn[0].Witness)) + } + if len(tx.TxIn[0].SignatureScript) != 0 { + t.Fatalf("expected empty SignatureScript for segwit input, got %d bytes", + len(tx.TxIn[0].SignatureScript)) + } + + err = verifyInput(tx, 0, prevOuts[fundOutpoint.String()]) + if err != nil { + t.Fatalf("script engine rejected signed P2WPKH input: %v", err) + } +} + +// TestSignMixedP2PKHAndP2WPKH verifies that a single transaction with +// one P2PKH input and one P2WPKH input, signed by different keys, is +// accepted by the script engine for both inputs. +func TestSignMixedP2PKHAndP2WPKH(t *testing.T) { + params := &chaincfg.TestNet3Params + + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + + legacyPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + segwitPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + err = m.PutKey(&zuul.NamedKey{Name: "legacy", PrivateKey: legacyPriv}) + if err != nil { + t.Fatal(err) + } + err = m.PutKey(&zuul.NamedKey{Name: "segwit", PrivateKey: segwitPriv}) + if err != nil { + t.Fatal(err) + } + + legacyPKHash := btcutil.Hash160(legacyPriv.PubKey().SerializeCompressed()) + legacyAddr, err := btcutil.NewAddressPubKeyHash(legacyPKHash, params) + if err != nil { + t.Fatal(err) + } + legacyScript, err := txscript.PayToAddrScript(legacyAddr) + if err != nil { + t.Fatal(err) + } + + segwitPKHash := btcutil.Hash160(segwitPriv.PubKey().SerializeCompressed()) + segwitAddr, err := btcutil.NewAddressWitnessPubKeyHash(segwitPKHash, params) + if err != nil { + t.Fatal(err) + } + segwitScript, err := txscript.PayToAddrScript(segwitAddr) + if err != nil { + t.Fatal(err) + } + + var h1, h2 chainhash.Hash + copy(h1[:], []byte("legacy-funding-input-0000000000000")) + copy(h2[:], []byte("segwit-funding-input-0000000000000")) + op1 := wire.NewOutPoint(&h1, 0) + op2 := wire.NewOutPoint(&h2, 0) + + const v1 int64 = 50_000 + const v2 int64 = 80_000 + + // Throwaway destination. + destPKHash := btcutil.Hash160(legacyPriv.PubKey().SerializeCompressed()) + destAddr, err := btcutil.NewAddressPubKeyHash(destPKHash, params) + if err != nil { + t.Fatal(err) + } + destScript, err := txscript.PayToAddrScript(destAddr) + if err != nil { + t.Fatal(err) + } + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op1, nil, nil)) + tx.AddTxIn(wire.NewTxIn(op2, nil, nil)) + tx.AddTxOut(wire.NewTxOut((v1+v2)/2, destScript)) + + prevOuts := PrevOuts{ + op1.String(): wire.NewTxOut(v1, legacyScript), + op2.String(): wire.NewTxOut(v2, segwitScript), + } + + err = TransactionSign(params, m, tx, prevOuts) + if err != nil { + t.Fatalf("TransactionSign: %v", err) + } + + // Legacy input must produce SignatureScript only. + if len(tx.TxIn[0].SignatureScript) == 0 { + t.Fatal("P2PKH input missing SignatureScript") + } + if len(tx.TxIn[0].Witness) != 0 { + t.Fatalf("P2PKH input has unexpected witness length %d", len(tx.TxIn[0].Witness)) + } + + // Segwit input must produce Witness only. + if len(tx.TxIn[1].Witness) != 2 { + t.Fatalf("P2WPKH witness wrong length: got %d, want 2", + len(tx.TxIn[1].Witness)) + } + if len(tx.TxIn[1].SignatureScript) != 0 { + t.Fatalf("P2WPKH input has SignatureScript: %d bytes", + len(tx.TxIn[1].SignatureScript)) + } + + if err := verifyInput(tx, 0, prevOuts[op1.String()]); err != nil { + t.Fatalf("engine rejected P2PKH input: %v", err) + } + if err := verifyInput(tx, 1, prevOuts[op2.String()]); err != nil { + t.Fatalf("engine rejected P2WPKH input: %v", err) + } +} From c15c90e16238986a1114e6193f537e644d3f2af6 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Fri, 17 Apr 2026 04:38:42 -0700 Subject: [PATCH 04/25] test(wallet): add pop regression tests PoP transactions are critical-path and must remain functional as signing paths are added. These tests lock down the current PoP flow against future regressions. TestPoPTransactionStructure verifies PoPTransactionCreate emits exactly one input, a zero-value OP_RETURN output carrying the abbreviated keystone, and a PrevOuts entry holding both value and pkScript. The OP_RETURN round-trips through pop.ParseTransactionL2FromOpReturn back to the same keystone. TestPoPTransactionSignValidates signs a PoP tx and runs the script engine against the signed input, proving the legacy P2PKH dispatch in TransactionSign still produces valid signatures after the script-class refactor. TestPoPNoWitnessDataLeak asserts a signed P2PKH PoP tx has no witness data on any input. Witness data on a legacy input is a protocol violation that some nodes reject. TestPoPSighashCacheSafeOnLegacyOnly is a regression guard for the unconditional NewTxSigHashes call introduced in the P2WPKH work. For legacy-only transactions that call must not panic, must not corrupt the fetcher, and must leave the P2PKH path correct across repeated iterations. TestPoPPrevOutsFetcherRoundTrip exercises prevOutsFetcher directly against a PoP-shaped prevOuts map. A silent key-parse failure in the adapter would cause NewTxSigHashes to dereference a nil TxOut and panic; this test asserts the fetcher returns the expected amount and pkScript for the PoP funding input. --- bitcoin/wallet/wallet_pop_test.go | 287 ++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 bitcoin/wallet/wallet_pop_test.go diff --git a/bitcoin/wallet/wallet_pop_test.go b/bitcoin/wallet/wallet_pop_test.go new file mode 100644 index 000000000..6196cc5ae --- /dev/null +++ b/bitcoin/wallet/wallet_pop_test.go @@ -0,0 +1,287 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package wallet + +import ( + "bytes" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + + "github.com/hemilabs/heminetwork/v2/api/tbcapi" + "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul" + "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul/memory" + "github.com/hemilabs/heminetwork/v2/hemi" + "github.com/hemilabs/heminetwork/v2/hemi/pop" + "github.com/hemilabs/heminetwork/v2/internal/testutil" +) + +// popFixture sets up a P2PKH key in zuul, a single funding UTXO, +// and a fresh L2Keystone suitable for PoP construction. +type popFixture struct { + params *chaincfg.Params + zuul zuul.Zuul + priv *btcec.PrivateKey + pkScript []byte + utxo *tbcapi.UTXO + keystone *hemi.L2Keystone + outpoint wire.OutPoint +} + +func newPoPFixture(t *testing.T) *popFixture { + t.Helper() + params := &chaincfg.TestNet3Params + + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + if err := m.PutKey(&zuul.NamedKey{Name: "pop", PrivateKey: priv}); err != nil { + t.Fatal(err) + } + + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("pop-regression-funding-txid-000")) + utxo := &tbcapi.UTXO{ + TxId: fundHash, + OutIndex: 0, + Value: btcutil.Amount(500_000), + } + + keystone := &hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 0xbadc0ffe, + L2BlockNumber: 0xdeadbeef, + ParentEPHash: testutil.SHA256([]byte{1, 1, 3, 7}), + PrevKeystoneEPHash: testutil.SHA256([]byte{0x04, 0x20, 69}), + StateRoot: testutil.SHA256([]byte("Hello, world!")), + EPHash: testutil.SHA256([]byte{0xaa, 0x55}), + } + + return &popFixture{ + params: params, + zuul: m, + priv: priv, + pkScript: pkScript, + utxo: utxo, + keystone: keystone, + outpoint: wire.OutPoint{Hash: fundHash, Index: 0}, + } +} + +// TestPoPTransactionStructure verifies PoPTransactionCreate produces +// the expected shape: exactly one input, an OP_RETURN output carrying +// the abbreviated keystone, and a PrevOuts entry for the single input +// holding both the UTXO value and pkScript. +func TestPoPTransactionStructure(t *testing.T) { + f := newPoPFixture(t) + + popTx, prevOuts, err := PoPTransactionCreate(f.keystone, + uint32(time.Now().Unix()), 10.0, + []*tbcapi.UTXO{f.utxo}, f.pkScript) + if err != nil { + t.Fatalf("PoPTransactionCreate: %v", err) + } + + if got := len(popTx.TxIn); got != 1 { + t.Fatalf("PoP tx must have exactly 1 input, got %d", got) + } + if got := popTx.TxIn[0].PreviousOutPoint; got != f.outpoint { + t.Fatalf("input outpoint mismatch: got %v want %v", got, f.outpoint) + } + + // Locate the OP_RETURN output. + var opReturnScript []byte + opReturnIdx := -1 + for i, out := range popTx.TxOut { + if len(out.PkScript) > 0 && out.PkScript[0] == txscript.OP_RETURN { + opReturnScript = out.PkScript + opReturnIdx = i + break + } + } + if opReturnIdx < 0 { + t.Fatal("PoP tx missing OP_RETURN output") + } + if popTx.TxOut[opReturnIdx].Value != 0 { + t.Fatalf("OP_RETURN output must have zero value, got %d", + popTx.TxOut[opReturnIdx].Value) + } + + // Round-trip: the OP_RETURN must parse back to the same abbreviated + // keystone we started with. + parsed, err := pop.ParseTransactionL2FromOpReturn(opReturnScript) + if err != nil { + t.Fatalf("ParseTransactionL2FromOpReturn: %v", err) + } + want := hemi.L2KeystoneAbbreviate(*f.keystone) + gotBytes := parsed.L2Keystone.Serialize() + wantBytes := want.Serialize() + if !bytes.Equal(gotBytes[:], wantBytes[:]) { + t.Fatalf("abbreviated keystone round-trip mismatch") + } + + // PrevOuts must carry the funding UTXO's value and script so + // that any future signing path (including witness sighash) + // has the amount available. + prev, ok := prevOuts[f.outpoint.String()] + if !ok { + t.Fatal("prevOuts missing funding outpoint") + } + if prev.Value != int64(f.utxo.Value) { + t.Fatalf("prevOuts value mismatch: got %d want %d", + prev.Value, int64(f.utxo.Value)) + } + if !bytes.Equal(prev.PkScript, f.pkScript) { + t.Fatalf("prevOuts pkScript mismatch") + } +} + +// TestPoPTransactionSignValidates runs a PoP transaction through the +// full construction + signing + verification path. The signed input +// must satisfy the script engine with its previous pkScript, proving +// the signature is well-formed and matches the right key. +// +// This guards against regressions in the script-class dispatch added +// to TransactionSign: if the P2PKH legacy path is ever broken, this +// test fails. +func TestPoPTransactionSignValidates(t *testing.T) { + f := newPoPFixture(t) + + popTx, prevOuts, err := PoPTransactionCreate(f.keystone, + uint32(time.Now().Unix()), 10.0, + []*tbcapi.UTXO{f.utxo}, f.pkScript) + if err != nil { + t.Fatalf("PoPTransactionCreate: %v", err) + } + + err = TransactionSign(f.params, f.zuul, popTx, prevOuts) + if err != nil { + t.Fatalf("TransactionSign: %v", err) + } + + // A signed P2PKH input has a non-empty SignatureScript and no witness. + if len(popTx.TxIn[0].SignatureScript) == 0 { + t.Fatal("PoP input missing SignatureScript after signing") + } + if len(popTx.TxIn[0].Witness) != 0 { + t.Fatalf("PoP P2PKH input must not have witness data, got %d elements", + len(popTx.TxIn[0].Witness)) + } + + prev := prevOuts[f.outpoint.String()] + if err := verifyInput(popTx, 0, prev); err != nil { + t.Fatalf("script engine rejected signed PoP input: %v", err) + } +} + +// TestPoPNoWitnessDataLeak verifies that a signed P2PKH PoP transaction +// has zero witness data across all inputs. A witness on a legacy input +// is a protocol violation that some nodes would reject; the refactored +// signing path must never emit witness data for non-segwit inputs. +func TestPoPNoWitnessDataLeak(t *testing.T) { + f := newPoPFixture(t) + + popTx, prevOuts, err := PoPTransactionCreate(f.keystone, + uint32(time.Now().Unix()), 10.0, + []*tbcapi.UTXO{f.utxo}, f.pkScript) + if err != nil { + t.Fatal(err) + } + if err := TransactionSign(f.params, f.zuul, popTx, prevOuts); err != nil { + t.Fatal(err) + } + + if popTx.HasWitness() { + t.Fatal("P2PKH PoP transaction must not have witness data") + } + for i, txIn := range popTx.TxIn { + if len(txIn.Witness) != 0 { + t.Fatalf("input %d has witness data: %d elements", i, len(txIn.Witness)) + } + } +} + +// TestPoPSighashCacheSafeOnLegacyOnly is a regression guard for the +// sighash cache computation introduced in the P2WPKH work. +// +// TransactionSign unconditionally calls txscript.NewTxSigHashes to +// precompute the BIP-143 midstate for witness inputs. For legacy-only +// transactions (the PoP case) that call must not panic, must not +// corrupt the prevOuts fetcher, and must still allow the P2PKH path +// to sign correctly. Running the full PoP flow multiple times in a +// tight loop catches any hidden state dependency that would surface +// as non-determinism. +func TestPoPSighashCacheSafeOnLegacyOnly(t *testing.T) { + for i := 0; i < 5; i++ { + f := newPoPFixture(t) + + popTx, prevOuts, err := PoPTransactionCreate(f.keystone, + uint32(time.Now().Unix()), 10.0, + []*tbcapi.UTXO{f.utxo}, f.pkScript) + if err != nil { + t.Fatalf("iteration %d: PoPTransactionCreate: %v", i, err) + } + + err = TransactionSign(f.params, f.zuul, popTx, prevOuts) + if err != nil { + t.Fatalf("iteration %d: TransactionSign: %v", i, err) + } + + prev := prevOuts[f.outpoint.String()] + if err := verifyInput(popTx, 0, prev); err != nil { + t.Fatalf("iteration %d: engine rejected signed input: %v", i, err) + } + } +} + +// TestPoPPrevOutsFetcherRoundTrip exercises prevOutsFetcher directly +// against a PoP-shaped prevOuts map. Silent loss of an entry would +// cause NewTxSigHashes to dereference a nil TxOut and panic; this +// test asserts the fetcher returns the expected amount and pkScript +// for the one and only PoP input. +func TestPoPPrevOutsFetcherRoundTrip(t *testing.T) { + f := newPoPFixture(t) + + _, prevOuts, err := PoPTransactionCreate(f.keystone, + uint32(time.Now().Unix()), 10.0, + []*tbcapi.UTXO{f.utxo}, f.pkScript) + if err != nil { + t.Fatal(err) + } + + fetcher := prevOutsFetcher(prevOuts) + got := fetcher.FetchPrevOutput(f.outpoint) + if got == nil { + t.Fatal("fetcher returned nil for known outpoint (key parse failure)") + } + if got.Value != int64(f.utxo.Value) { + t.Fatalf("fetcher value mismatch: got %d want %d", + got.Value, int64(f.utxo.Value)) + } + if !bytes.Equal(got.PkScript, f.pkScript) { + t.Fatalf("fetcher pkScript mismatch") + } +} From f2542dfdab8c56fcaa33cd1a03a2acbc8ae29dea Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Fri, 17 Apr 2026 04:47:58 -0700 Subject: [PATCH 05/25] feat(wallet): sign p2tr key-path inputs TransactionSign now handles WitnessV1TaprootTy inputs via BIP-86 key-path spends. signP2TRKeyPath looks up the untweaked internal key in zuul (indexed under the BIP-86 P2TR address) and calls txscript.RawTxInTaprootSignature with a nil script root. The helper must pass the untweaked key because RawTxInTaprootSignature applies the BIP-341 tweak internally. Double-tweaking produces a signature for the wrong output key that the script engine rejects with ErrTaprootSigInvalid. Script-path taproot spends (tapscript with a committed envelope or other leaf script) are out of scope for this entry point. They require the caller to supply the leaf script and control block, which the wallet does not carry. Such inputs must be signed by a dedicated entry point before calling TransactionSign. verifyInput in the test suite now takes the full PrevOuts map instead of a single TxOut. Taproot sighash commits to every input's pkScript and amount, so a single-input fetcher produces a sighash mismatch on mixed-input transactions. All test call sites updated. PoP regression tests pass: legacy P2PKH inputs still flow through the default SignTxOutput branch with no witness data, and the sighash cache computation remains safe for legacy-only transactions. --- bitcoin/wallet/wallet.go | 100 ++++++++++--- bitcoin/wallet/wallet_p2tr_test.go | 207 +++++++++++++++++++++++++++ bitcoin/wallet/wallet_p2wpkh_test.go | 37 +++-- bitcoin/wallet/wallet_pop_test.go | 6 +- 4 files changed, 318 insertions(+), 32 deletions(-) create mode 100644 bitcoin/wallet/wallet_p2tr_test.go diff --git a/bitcoin/wallet/wallet.go b/bitcoin/wallet/wallet.go index 44ed4a917..61946bbed 100644 --- a/bitcoin/wallet/wallet.go +++ b/bitcoin/wallet/wallet.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/mempool" @@ -166,8 +167,10 @@ func PoPTransactionCreate(l2keystone *hemi.L2Keystone, locktime uint32, satsPerB // TransactionSign signs every input of tx using keys looked up in z. // Inputs are dispatched by the script class of their previous output: // legacy (P2PKH, P2SH, etc.) inputs produce a SignatureScript via the -// standard txscript.SignTxOutput path; native segwit v0 (P2WPKH) inputs -// produce a witness via BIP-143 sighash. All signatures use SigHashAll. +// standard txscript.SignTxOutput path with SigHashAll; native segwit +// v0 (P2WPKH) inputs produce a witness via BIP-143 sighash with +// SigHashAll; taproot v1 (P2TR) inputs produce a witness via BIP-341 +// key-path sighash with SigHashDefault. func TransactionSign(params *chaincfg.Params, z zuul.Zuul, tx *wire.MsgTx, prevOuts PrevOuts) error { // Validate every input has a matching prev-out before any // sighash computation. Without this pre-check a caller- @@ -193,36 +196,71 @@ func TransactionSign(params *chaincfg.Params, z zuul.Zuul, tx *wire.MsgTx, prevO if err := signP2WPKH(params, z, tx, i, prev, sigHashes); err != nil { return fmt.Errorf("sign p2wpkh input %d: %w", i, err) } - + case txscript.WitnessV1TaprootTy: + if err := signP2TRKeyPath(params, z, tx, i, prev, sigHashes); err != nil { + return fmt.Errorf("sign p2tr input %d: %w", i, err) + } default: - sigScript, err := txscript.SignTxOutput(params, tx, i, - prev.PkScript, txscript.SigHashAll, - txscript.KeyClosure(z.LookupKeyByAddr), nil, nil) - if err != nil { - return err + if err := signLegacy(params, z, tx, i, prev); err != nil { + return fmt.Errorf("sign legacy input %d: %w", i, err) } - tx.TxIn[i].SignatureScript = sigScript } } return nil } +// signLegacy signs a legacy (P2PKH, P2SH, etc.) input via the +// classic txscript.SignTxOutput path with SigHashAll. Extracted +// from TransactionSign to keep each dispatch arm at a single line +// and each signing strategy in its own named function. +func signLegacy(params *chaincfg.Params, z zuul.Zuul, tx *wire.MsgTx, idx int, prev *wire.TxOut) error { + sigScript, err := txscript.SignTxOutput(params, tx, idx, + prev.PkScript, txscript.SigHashAll, + txscript.KeyClosure(z.LookupKeyByAddr), nil, nil) + if err != nil { + return fmt.Errorf("sign tx output: %w", err) + } + tx.TxIn[idx].SignatureScript = sigScript + return nil +} + +// resolveInputSigningKey extracts the single address from pkScript via +// the configured network params and looks up its private key in z. +// Shared by signP2WPKH and signP2TRKeyPath: both script classes +// encode exactly one address that derives directly from the signer's +// public key, so the address-extract + zuul-lookup dance is identical. +// +// Returns the key on success. Errors on pkScript that does not +// resolve to exactly one address, lookup failures, and missing keys +// — each with the failing address in context for debugging. +func resolveInputSigningKey(params *chaincfg.Params, z zuul.Zuul, pkScript []byte) (*btcec.PrivateKey, error) { + _, addrs, _, err := txscript.ExtractPkScriptAddrs(pkScript, params) + if err != nil { + return nil, fmt.Errorf("extract address: %w", err) + } + if len(addrs) != 1 { + return nil, fmt.Errorf("pkScript extracted %d addresses, expected 1", len(addrs)) + } + priv, ok, err := z.LookupKeyByAddr(addrs[0]) + if err != nil { + return nil, fmt.Errorf("lookup key for %s: %w", addrs[0], err) + } + if !ok { + return nil, fmt.Errorf("lookup key for %s: %w", addrs[0], zuul.ErrKeyDoesntExist) + } + return priv, nil +} + // signP2WPKH signs a witness v0 pubkey hash input. The witness program // is the 20-byte HASH160 of the pubkey; the sighash is computed over // the P2PKH-equivalent script per BIP-143. The caller's zuul must // hold the key for the address derived from the witness program. func signP2WPKH(params *chaincfg.Params, z zuul.Zuul, tx *wire.MsgTx, idx int, prev *wire.TxOut, sigHashes *txscript.TxSigHashes) error { - _, addrs, _, err := txscript.ExtractPkScriptAddrs(prev.PkScript, params) - if err != nil || len(addrs) != 1 { - return fmt.Errorf("extract p2wpkh address: %w", err) - } - - priv, ok, err := z.LookupKeyByAddr(addrs[0]) - if err != nil || !ok { - return fmt.Errorf("lookup key for %s: %w", addrs[0], err) + priv, err := resolveInputSigningKey(params, z, prev.PkScript) + if err != nil { + return err } - witness, err := txscript.WitnessSignature(tx, sigHashes, idx, prev.Value, prev.PkScript, txscript.SigHashAll, priv, true) if err != nil { @@ -232,6 +270,32 @@ func signP2WPKH(params *chaincfg.Params, z zuul.Zuul, tx *wire.MsgTx, idx int, p return nil } +// signP2TRKeyPath signs a witness v1 taproot input using the BIP-86 +// key-path spend. The stored private key is the untweaked internal +// key with no script commitment; RawTxInTaprootSignature applies the +// BIP-341 tweak internally, so callers must pass the untweaked key. +// +// Script-path spends (tapscript with an envelope or other committed +// script) are not handled here — they require the caller to provide +// the leaf script and control block, which the wallet does not carry. +// Callers with script-path inputs must sign those inputs before +// calling TransactionSign, or use a dedicated script-path entry point. +func signP2TRKeyPath(params *chaincfg.Params, z zuul.Zuul, tx *wire.MsgTx, idx int, prev *wire.TxOut, sigHashes *txscript.TxSigHashes) error { + priv, err := resolveInputSigningKey(params, z, prev.PkScript) + if err != nil { + return err + } + // Pass the untweaked key; RawTxInTaprootSignature applies the + // BIP-341 tweak with the provided script root (nil for BIP-86). + sig, err := txscript.RawTxInTaprootSignature(tx, sigHashes, idx, + prev.Value, prev.PkScript, nil, txscript.SigHashDefault, priv) + if err != nil { + return fmt.Errorf("taproot signature: %w", err) + } + tx.TxIn[idx].Witness = wire.TxWitness{sig} + return nil +} + // prevOutsFetcher adapts PrevOuts to txscript.PrevOutputFetcher as // required by NewTxSigHashes for segwit and taproot sighash calculation. // diff --git a/bitcoin/wallet/wallet_p2tr_test.go b/bitcoin/wallet/wallet_p2tr_test.go new file mode 100644 index 000000000..a92579759 --- /dev/null +++ b/bitcoin/wallet/wallet_p2tr_test.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package wallet + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + + "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul" + "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul/memory" +) + +// TestSignP2TRKeyPath verifies that a BIP-86 key-path taproot input +// signed via TransactionSign satisfies the script engine. +func TestSignP2TRKeyPath(t *testing.T) { + params := &chaincfg.TestNet3Params + + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + err = m.PutKey(&zuul.NamedKey{Name: "taproot", PrivateKey: priv}) + if err != nil { + t.Fatal(err) + } + + // Build the BIP-86 P2TR pkScript matching the stored internal key. + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + p2trAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(outputKey), params) + if err != nil { + t.Fatal(err) + } + p2trScript, err := txscript.PayToAddrScript(p2trAddr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("p2tr-keypath-funding-txid-00000")) + fundOutpoint := wire.NewOutPoint(&fundHash, 0) + const fundValue int64 = 100_000 + + // Throwaway destination: another P2TR just to keep the output valid. + destPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + destOutputKey := txscript.ComputeTaprootKeyNoScript(destPriv.PubKey()) + destAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(destOutputKey), params) + if err != nil { + t.Fatal(err) + } + destScript, err := txscript.PayToAddrScript(destAddr) + if err != nil { + t.Fatal(err) + } + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(fundOutpoint, nil, nil)) + tx.AddTxOut(wire.NewTxOut(fundValue/2, destScript)) + + prevOuts := PrevOuts{ + fundOutpoint.String(): wire.NewTxOut(fundValue, p2trScript), + } + + err = TransactionSign(params, m, tx, prevOuts) + if err != nil { + t.Fatalf("TransactionSign: %v", err) + } + + // Key-path witness is a single 64-byte schnorr signature (or 65 + // with a non-default sighash byte appended). + if len(tx.TxIn[0].Witness) != 1 { + t.Fatalf("expected 1-element witness, got %d", len(tx.TxIn[0].Witness)) + } + sigLen := len(tx.TxIn[0].Witness[0]) + if sigLen != 64 && sigLen != 65 { + t.Fatalf("unexpected schnorr sig length: %d", sigLen) + } + if len(tx.TxIn[0].SignatureScript) != 0 { + t.Fatalf("expected empty SignatureScript for taproot input, got %d bytes", + len(tx.TxIn[0].SignatureScript)) + } + + if err := verifyInput(tx, 0, prevOuts); err != nil { + t.Fatalf("script engine rejected signed P2TR key-path input: %v", err) + } +} + +// TestSignMixedP2PKHAndP2TR proves a transaction mixing a legacy +// P2PKH input with a BIP-86 taproot input signs and verifies +// correctly for both inputs. This is the shape btcwine's send- +// ordinal transaction takes: a taproot input holding the ordinal +// plus a P2PKH input funding the fee. +func TestSignMixedP2PKHAndP2TR(t *testing.T) { + params := &chaincfg.TestNet3Params + + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + + legacyPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + taprootPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + if err := m.PutKey(&zuul.NamedKey{Name: "legacy", PrivateKey: legacyPriv}); err != nil { + t.Fatal(err) + } + if err := m.PutKey(&zuul.NamedKey{Name: "taproot", PrivateKey: taprootPriv}); err != nil { + t.Fatal(err) + } + + // Legacy pkScript. + legacyPKHash := btcutil.Hash160(legacyPriv.PubKey().SerializeCompressed()) + legacyAddr, err := btcutil.NewAddressPubKeyHash(legacyPKHash, params) + if err != nil { + t.Fatal(err) + } + legacyScript, err := txscript.PayToAddrScript(legacyAddr) + if err != nil { + t.Fatal(err) + } + + // Taproot pkScript. + taprootKey := txscript.ComputeTaprootKeyNoScript(taprootPriv.PubKey()) + taprootAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(taprootKey), params) + if err != nil { + t.Fatal(err) + } + taprootScript, err := txscript.PayToAddrScript(taprootAddr) + if err != nil { + t.Fatal(err) + } + + var h1, h2 chainhash.Hash + copy(h1[:], []byte("mixed-legacy-input-000000000000000")) + copy(h2[:], []byte("mixed-taproot-input-00000000000000")) + op1 := wire.NewOutPoint(&h1, 0) + op2 := wire.NewOutPoint(&h2, 0) + + const v1 int64 = 50_000 // legacy funding + const v2 int64 = 10_000 // taproot (ordinal-shaped) + + destScript := taprootScript // send to ourselves for brevity + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op1, nil, nil)) + tx.AddTxIn(wire.NewTxIn(op2, nil, nil)) + tx.AddTxOut(wire.NewTxOut((v1+v2)/2, destScript)) + + prevOuts := PrevOuts{ + op1.String(): wire.NewTxOut(v1, legacyScript), + op2.String(): wire.NewTxOut(v2, taprootScript), + } + + err = TransactionSign(params, m, tx, prevOuts) + if err != nil { + t.Fatalf("TransactionSign: %v", err) + } + + // Legacy input: SignatureScript only. + if len(tx.TxIn[0].SignatureScript) == 0 { + t.Fatal("P2PKH input missing SignatureScript") + } + if len(tx.TxIn[0].Witness) != 0 { + t.Fatalf("P2PKH input unexpectedly has witness") + } + + // Taproot input: single witness element, no sigScript. + if len(tx.TxIn[1].Witness) != 1 { + t.Fatalf("taproot witness wrong length: got %d, want 1", + len(tx.TxIn[1].Witness)) + } + if len(tx.TxIn[1].SignatureScript) != 0 { + t.Fatal("P2TR input unexpectedly has SignatureScript") + } + + if err := verifyInput(tx, 0, prevOuts); err != nil { + t.Fatalf("engine rejected legacy input: %v", err) + } + if err := verifyInput(tx, 1, prevOuts); err != nil { + t.Fatalf("engine rejected taproot input: %v", err) + } +} diff --git a/bitcoin/wallet/wallet_p2wpkh_test.go b/bitcoin/wallet/wallet_p2wpkh_test.go index 6c2a50159..706948d65 100644 --- a/bitcoin/wallet/wallet_p2wpkh_test.go +++ b/bitcoin/wallet/wallet_p2wpkh_test.go @@ -5,6 +5,8 @@ package wallet import ( + "errors" + "fmt" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -19,18 +21,33 @@ import ( ) // verifyInput runs the script engine on a single input and returns the -// result. For witness inputs the engine walks both the witness and -// the prev pkScript and applies BIP-143 sighash verification. -func verifyInput(tx *wire.MsgTx, idx int, prev *wire.TxOut) error { - flags := txscript.StandardVerifyFlags - fetcher := txscript.NewCannedPrevOutputFetcher(prev.PkScript, prev.Value) +// result. The supplied prevOuts must cover every input of tx, not just +// idx: taproot sighash (BIP-341) commits to all prev scripts and +// amounts, so a single-input fetcher would produce a sighash mismatch +// on mixed-input transactions. +func verifyInput(tx *wire.MsgTx, idx int, prevOuts PrevOuts) error { + fetcher := prevOutsFetcher(prevOuts) sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + prev := prevOuts[tx.TxIn[idx].PreviousOutPoint.String()] + if prev == nil { + return fmt.Errorf("prevOuts missing entry for input %d", idx) + } + + flags := txscript.StandardVerifyFlags vm, err := txscript.NewEngine(prev.PkScript, tx, idx, flags, nil, sigHashes, prev.Value, fetcher) if err != nil { - return err + return fmt.Errorf("new engine: %w", err) + } + if err := vm.Execute(); err != nil { + var se txscript.Error + if errors.As(err, &se) { + return fmt.Errorf("execute: code=%v desc=%q", se.ErrorCode, se.Description) + } + return fmt.Errorf("execute: %w", err) } - return vm.Execute() + return nil } // TestSignP2WPKHInput exercises witness v0 pubkey hash signing through @@ -108,7 +125,7 @@ func TestSignP2WPKHInput(t *testing.T) { len(tx.TxIn[0].SignatureScript)) } - err = verifyInput(tx, 0, prevOuts[fundOutpoint.String()]) + err = verifyInput(tx, 0, prevOuts) if err != nil { t.Fatalf("script engine rejected signed P2WPKH input: %v", err) } @@ -216,10 +233,10 @@ func TestSignMixedP2PKHAndP2WPKH(t *testing.T) { len(tx.TxIn[1].SignatureScript)) } - if err := verifyInput(tx, 0, prevOuts[op1.String()]); err != nil { + if err := verifyInput(tx, 0, prevOuts); err != nil { t.Fatalf("engine rejected P2PKH input: %v", err) } - if err := verifyInput(tx, 1, prevOuts[op2.String()]); err != nil { + if err := verifyInput(tx, 1, prevOuts); err != nil { t.Fatalf("engine rejected P2WPKH input: %v", err) } } diff --git a/bitcoin/wallet/wallet_pop_test.go b/bitcoin/wallet/wallet_pop_test.go index 6196cc5ae..cadf7d220 100644 --- a/bitcoin/wallet/wallet_pop_test.go +++ b/bitcoin/wallet/wallet_pop_test.go @@ -191,8 +191,7 @@ func TestPoPTransactionSignValidates(t *testing.T) { len(popTx.TxIn[0].Witness)) } - prev := prevOuts[f.outpoint.String()] - if err := verifyInput(popTx, 0, prev); err != nil { + if err := verifyInput(popTx, 0, prevOuts); err != nil { t.Fatalf("script engine rejected signed PoP input: %v", err) } } @@ -250,8 +249,7 @@ func TestPoPSighashCacheSafeOnLegacyOnly(t *testing.T) { t.Fatalf("iteration %d: TransactionSign: %v", i, err) } - prev := prevOuts[f.outpoint.String()] - if err := verifyInput(popTx, 0, prev); err != nil { + if err := verifyInput(popTx, 0, prevOuts); err != nil { t.Fatalf("iteration %d: engine rejected signed input: %v", i, err) } } From 78dd7b2259037d632f4a27ed52a15fdb45b0d3ec Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Sat, 18 Apr 2026 05:56:12 -0700 Subject: [PATCH 06/25] feat(gozer): add TxByID to fetch a tx by id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gozer interface now exposes TxByID(ctx, txid) returning *tbcapi.Tx, filling in the obvious sibling to the existing BroadcastTx — both are transaction-oriented, and tbcd already exposes the backing TxByIdRequest / TxByIdResponse pair. Prior to this change any Gozer consumer that needed to inspect an on-chain transaction (for example to decode the tapscript witness of an Ordinals reveal) had to either reach outside the abstraction to a block explorer REST API, or open its own WebSocket against tbcd for a single call. Neither is acceptable for a library whose whole job is to be the wallet's source of chain data. TxByID closes the gap. tbcgozer implementation mirrors BroadcastTx line for line: build a tbcapi.TxByIdRequest, callTBC, type-assert the response, surface the protocol.Error if any, otherwise return the Tx. Nil txid returns a clean error instead of panicking on the dereference inside the request struct. blockstream implementation is a "not supported yet" stub, consistent with BlocksByL2AbrevHashes and KeystonesByHeight. Blockstream exposes GET /tx/{txid} and GET /tx/{txid}/hex which could be mapped onto *tbcapi.Tx, but no current consumer uses blockstream for tx introspection — fill in when that changes. Adding a method to Gozer is a breaking change for any external implementer of the interface. There are none in this repo and none known externally; tbcGozer and blockstreamGozer are the only concrete types. No new dependencies. Existing bitcoin/wallet/... test suite passes: wallet, blockstream, tbcgozer, vinzclortho, zuul/memory. --- .../wallet/gozer/blockstream/blockstream.go | 13 +++++++++ bitcoin/wallet/gozer/gozer.go | 1 + bitcoin/wallet/gozer/tbcgozer/tbcgozer.go | 27 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/bitcoin/wallet/gozer/blockstream/blockstream.go b/bitcoin/wallet/gozer/blockstream/blockstream.go index 6fde8a436..8691815bf 100644 --- a/bitcoin/wallet/gozer/blockstream/blockstream.go +++ b/bitcoin/wallet/gozer/blockstream/blockstream.go @@ -214,6 +214,19 @@ func (bs *blockstreamGozer) KeystonesByHeight(ctx context.Context, height uint32 }, err } +// TxByID is not yet implemented for Blockstream. +// TxByID is a stub — blockstream support for transaction lookup is +// not yet implemented. The only consumer today is ectoplasm which +// uses tbcGozer. +func (bs *blockstreamGozer) TxByID(ctx context.Context, txid *chainhash.Hash) (*tbcapi.Tx, error) { + // Blockstream exposes GET /tx/{txid}/hex and GET /tx/{txid}, + // either of which could be mapped onto *tbcapi.Tx. Left as + // a stub here because the only consumer today is ectoplasm + // which uses tbcGozer; fill this in when blockstream-backed + // deployments need the ordinal viewer. + return nil, errors.New("not supported yet") +} + func (bs *blockstreamGozer) Run(_ context.Context, _ func()) error { return nil } diff --git a/bitcoin/wallet/gozer/gozer.go b/bitcoin/wallet/gozer/gozer.go index 26df31b84..ddc5f1d98 100644 --- a/bitcoin/wallet/gozer/gozer.go +++ b/bitcoin/wallet/gozer/gozer.go @@ -34,6 +34,7 @@ type Gozer interface { KeystonesByHeight(ctx context.Context, height uint32, depth int) (*KeystonesByHeightResponse, error) BroadcastTx(ctx context.Context, tx *wire.MsgTx) (*chainhash.Hash, error) BestHeightHashTime(ctx context.Context) (uint64, *chainhash.Hash, time.Time, error) + TxByID(ctx context.Context, txid *chainhash.Hash) (*tbcapi.Tx, error) Run(ctx context.Context, connected func()) error Connected() bool // ready to use diff --git a/bitcoin/wallet/gozer/tbcgozer/tbcgozer.go b/bitcoin/wallet/gozer/tbcgozer/tbcgozer.go index 87f7f297e..8435dd6a4 100644 --- a/bitcoin/wallet/gozer/tbcgozer/tbcgozer.go +++ b/bitcoin/wallet/gozer/tbcgozer/tbcgozer.go @@ -154,6 +154,33 @@ func (t *tbcGozer) BroadcastTx(ctx context.Context, tx *wire.MsgTx) (*chainhash. return buResp.TxID, nil } +// TxByID returns the transaction identified by txid from TBC. +// TxByID fetches a transaction by its hash from the connected TBC +// server. Returns an error if txid is nil or the server is +// unreachable. +func (t *tbcGozer) TxByID(ctx context.Context, txid *chainhash.Hash) (*tbcapi.Tx, error) { + if txid == nil { + return nil, errors.New("txid is nil") + } + req := &tbcapi.TxByIdRequest{TxID: *txid} + + res, err := t.callTBC(ctx, DefaultRequestTimeout, req) + if err != nil { + return nil, err + } + + resp, ok := res.(*tbcapi.TxByIdResponse) + if !ok { + return nil, fmt.Errorf("not a TxByIdResponse: %T", res) + } + + if resp.Error != nil { + return nil, resp.Error + } + + return resp.Tx, nil +} + func (t *tbcGozer) UtxosByAddress(ctx context.Context, filterMempool bool, addr btcutil.Address, start, count uint) ([]*tbcapi.UTXO, error) { maxCount := uint(1000) if count > maxCount { From e92331dc311b4eacd8daa23b5ed32acb6e840fbf Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Wed, 22 Apr 2026 09:23:05 -0700 Subject: [PATCH 07/25] test(wallet): add TxByID coverage across mock, unit, and integration tests Add CmdTxByIdRequest handler to mock TBC server so tbcGozer.TxByID can be exercised without a live TBC instance. tbcgozer: three new tests cover nil txid, not-connected, and happy path (including concurrent queue-depth exercise). blockstream: confirm stub returns not-supported-yet. wallet: extend TestIntegration to call TxByID after BroadcastTx and verify the returned transaction fields. --- .../gozer/blockstream/blockstream_test.go | 13 ++ .../wallet/gozer/tbcgozer/tbcgozer_test.go | 137 ++++++++++++++++++ bitcoin/wallet/wallet_test.go | 17 +++ internal/testutil/mock/tbc.go | 23 +++ 4 files changed, 190 insertions(+) diff --git a/bitcoin/wallet/gozer/blockstream/blockstream_test.go b/bitcoin/wallet/gozer/blockstream/blockstream_test.go index 3baf1227a..0134afb91 100644 --- a/bitcoin/wallet/gozer/blockstream/blockstream_test.go +++ b/bitcoin/wallet/gozer/blockstream/blockstream_test.go @@ -131,3 +131,16 @@ func TestBlockstreamGozer(t *testing.T) { } t.Logf("BTC tip height: %v", height) } + +func TestBlockstreamGozerTxByIDNotSupported(t *testing.T) { + b := &blockstreamGozer{url: "http://localhost:0"} + + txid := chainhash.Hash{0x01} + _, err := b.TxByID(t.Context(), &txid) + if err == nil { + t.Fatal("expected error for unsupported TxByID") + } + if err.Error() != "not supported yet" { + t.Fatalf("expected 'not supported yet', got: %v", err) + } +} diff --git a/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go b/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go index 8cfee029f..05ee19e0a 100644 --- a/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go +++ b/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go @@ -398,3 +398,140 @@ func TestTBCGozerMempoolUtxos(t *testing.T) { } }) } +func TestTBCGozerTxByIDNilTxid(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + kssMap, kssList := testutil.MakeSharedKeystones(10) + btcTip := uint(kssList[len(kssList)-1].L1BlockNumber) + + mtbc := mock.NewMockTBC(ctx, nil, nil, kssMap, btcTip, 100) + defer mtbc.Shutdown() + + DefaultRequestTimeout = 10 * time.Second + b := New("ws" + strings.TrimPrefix(mtbc.URL(), "http")) + if err := b.Run(ctx, nil); err != nil { + t.Fatal(err) + } + + tg, ok := b.(*tbcGozer) + if !ok { + t.Fatal("expected gozer to be of type tbcGozer") + } + + for !tg.Connected() { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.Tick(50 * time.Millisecond): + } + } + + _, err := b.TxByID(ctx, nil) + if err == nil { + t.Fatal("expected error for nil txid") + } + if err.Error() != "txid is nil" { + t.Fatalf("expected 'txid is nil', got: %v", err) + } +} + +func TestTBCGozerTxByIDNotConnected(t *testing.T) { + b := New("ws://127.0.0.1:0/v1/ws") + tg := b.(*tbcGozer) + + if tg.Connected() { + t.Fatal("expected gozer to not be connected") + } + + txid := chainhash.Hash{0x01} + _, err := b.TxByID(t.Context(), &txid) + if err == nil { + t.Fatal("expected error for not connected") + } + if err.Error() != "not connected to tbc" { + t.Fatalf("expected 'not connected to tbc', got: %v", err) + } +} + +func TestTBCGozerTxByID(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 45*time.Second) + defer cancel() + + kssMap, kssList := testutil.MakeSharedKeystones(10) + btcTip := uint(kssList[len(kssList)-1].L1BlockNumber) + + mtbc := mock.NewMockTBC(ctx, nil, nil, kssMap, btcTip, 100) + defer mtbc.Shutdown() + + DefaultRequestTimeout = 10 * time.Second + b := New("ws" + strings.TrimPrefix(mtbc.URL(), "http")) + if err := b.Run(ctx, nil); err != nil { + t.Fatal(err) + } + + tg, ok := b.(*tbcGozer) + if !ok { + t.Fatal("expected gozer to be of type tbcGozer") + } + + for !tg.Connected() { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.Tick(50 * time.Millisecond): + } + } + + txid := chainhash.Hash{0xaa, 0xbb, 0xcc} + tx, err := b.TxByID(ctx, &txid) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tx == nil { + t.Fatal("expected non-nil tx") + } + if tx.Version != 2 { + t.Fatalf("expected version 2, got %v", tx.Version) + } + if len(tx.TxIn) != 1 { + t.Fatalf("expected 1 txin, got %v", len(tx.TxIn)) + } + if tx.TxIn[0].PreviousOutPoint.Hash != txid { + t.Fatalf("expected txin outpoint hash to match txid") + } + if len(tx.TxOut) != 1 { + t.Fatalf("expected 1 txout, got %v", len(tx.TxOut)) + } + if tx.TxOut[0].Value != 50000 { + t.Fatalf("expected txout value 50000, got %v", tx.TxOut[0].Value) + } + + // Repeat concurrently to exercise queue depth. + var ( + wg sync.WaitGroup + ccMtx sync.Mutex + cc int + ) + qd := DefaultCommandQueueDepth * 10 + for range qd { + wg.Go(func() { + rtx, err := b.TxByID(ctx, &txid) + if err != nil { + panic(fmt.Sprintf("TxByID: %v", err)) + } + if rtx.Version != tx.Version { + panic(fmt.Sprintf("version %v != %v", + rtx.Version, tx.Version)) + } + ccMtx.Lock() + cc++ + ccMtx.Unlock() + }) + } + + wg.Wait() + if cc != qd { + t.Fatalf("cc %v != qd %v", cc, qd) + } +} diff --git a/bitcoin/wallet/wallet_test.go b/bitcoin/wallet/wallet_test.go index d987159f7..866ee6e96 100644 --- a/bitcoin/wallet/wallet_test.go +++ b/bitcoin/wallet/wallet_test.go @@ -222,4 +222,21 @@ func TestIntegration(t *testing.T) { } t.Logf("txID: %v", txID) + + // Verify we can look up the broadcast transaction by ID. + lookedUp, err := tg.TxByID(ctx, txID) + if err != nil { + t.Fatalf("TxByID after broadcast: %v", err) + } + if lookedUp == nil { + t.Fatal("TxByID returned nil tx") + } + if len(lookedUp.TxIn) == 0 { + t.Fatal("TxByID returned tx with no inputs") + } + if len(lookedUp.TxOut) == 0 { + t.Fatal("TxByID returned tx with no outputs") + } + t.Logf("TxByID: version=%v txin=%d txout=%d", + lookedUp.Version, len(lookedUp.TxIn), len(lookedUp.TxOut)) } diff --git a/internal/testutil/mock/tbc.go b/internal/testutil/mock/tbc.go index 638466466..f81eb9f00 100644 --- a/internal/testutil/mock/tbc.go +++ b/internal/testutil/mock/tbc.go @@ -20,6 +20,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/coder/websocket" "github.com/hemilabs/heminetwork/v2/api/protocol" @@ -207,6 +208,28 @@ func (f *TBCMockHandler) handle(c protocol.APIConn, utxos []tbcd.Utxo, mp *tbc.M {Blocks: 10, SatsPerByte: 1}, }, } + case tbcapi.CmdTxByIdRequest: + pl, ok := payload.(*tbcapi.TxByIdRequest) + if !ok { + panic(fmt.Errorf("unexpected payload format: %v", payload)) + } + resp = &tbcapi.TxByIdResponse{ + Tx: &tbcapi.Tx{ + Version: 2, + LockTime: 0, + TxIn: []*tbcapi.TxIn{{ + PreviousOutPoint: tbcapi.OutPoint{ + Hash: pl.TxID, + Index: 0, + }, + Sequence: wire.MaxTxInSequenceNum, + }}, + TxOut: []*tbcapi.TxOut{{ + Value: 50000, + PkScript: []byte{0x00, 0x14, 0x01, 0x02, 0x03}, + }}, + }, + } case tbcapi.CmdKeystonesByHeightRequest: pl, ok := payload.(*tbcapi.KeystonesByHeightRequest) if !ok { From 53593972a80c746a68da7f03188c8bbc603d7eca Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Wed, 22 Apr 2026 09:27:23 -0700 Subject: [PATCH 08/25] test(wallet): add TxByID negative tests and fuzz coverage mock: zero hash now triggers error response for TxByIdRequest, enabling callers to exercise the resp.Error path. tbcgozer: two new negative tests cover TBC error response propagation (zero hash) and context cancellation before RPC. tbcgozer: FuzzTBCGozerTxByID exercises TxByID with arbitrary txid bytes through the mock to catch marshaling edge cases. --- .../wallet/gozer/tbcgozer/tbcgozer_test.go | 122 ++++++++++++++++++ internal/testutil/mock/tbc.go | 39 +++--- 2 files changed, 145 insertions(+), 16 deletions(-) diff --git a/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go b/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go index 05ee19e0a..07e47f4c3 100644 --- a/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go +++ b/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go @@ -535,3 +535,125 @@ func TestTBCGozerTxByID(t *testing.T) { t.Fatalf("cc %v != qd %v", cc, qd) } } + +func TestTBCGozerTxByIDErrorResponse(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + kssMap, kssList := testutil.MakeSharedKeystones(10) + btcTip := uint(kssList[len(kssList)-1].L1BlockNumber) + + mtbc := mock.NewMockTBC(ctx, nil, nil, kssMap, btcTip, 100) + defer mtbc.Shutdown() + + DefaultRequestTimeout = 10 * time.Second + b := New("ws" + strings.TrimPrefix(mtbc.URL(), "http")) + if err := b.Run(ctx, nil); err != nil { + t.Fatal(err) + } + + tg := b.(*tbcGozer) + for !tg.Connected() { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.Tick(50 * time.Millisecond): + } + } + + // Zero hash triggers error response from mock. + zeroHash := chainhash.Hash{} + _, err := b.TxByID(ctx, &zeroHash) + if err == nil { + t.Fatal("expected error for zero hash (not-found)") + } + if !strings.Contains(err.Error(), "tx not found") { + t.Fatalf("expected 'tx not found' in error, got: %v", err) + } +} + +func TestTBCGozerTxByIDContextCancelled(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + kssMap, kssList := testutil.MakeSharedKeystones(10) + btcTip := uint(kssList[len(kssList)-1].L1BlockNumber) + + mtbc := mock.NewMockTBC(ctx, nil, nil, kssMap, btcTip, 100) + defer mtbc.Shutdown() + + DefaultRequestTimeout = 10 * time.Second + b := New("ws" + strings.TrimPrefix(mtbc.URL(), "http")) + if err := b.Run(ctx, nil); err != nil { + t.Fatal(err) + } + + tg := b.(*tbcGozer) + for !tg.Connected() { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.Tick(50 * time.Millisecond): + } + } + + // Cancel context before calling TxByID. + cancelledCtx, cancelNow := context.WithCancel(ctx) + cancelNow() + + txid := chainhash.Hash{0xde, 0xad} + _, err := b.TxByID(cancelledCtx, &txid) + if err == nil { + t.Fatal("expected error for cancelled context") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got: %v", err) + } +} + +func FuzzTBCGozerTxByID(f *testing.F) { + f.Add(make([]byte, 32)) // zero hash + f.Add([]byte{0xaa, 0xbb, 0xcc}) // short + f.Add(make([]byte, 33)) // oversize + f.Add([]byte{}) // empty + f.Add([]byte{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + }) // all-ones + + f.Fuzz(func(t *testing.T, data []byte) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + kssMap, kssList := testutil.MakeSharedKeystones(2) + btcTip := uint(kssList[len(kssList)-1].L1BlockNumber) + + mtbc := mock.NewMockTBC(ctx, nil, nil, kssMap, btcTip, 1) + defer mtbc.Shutdown() + + DefaultRequestTimeout = 5 * time.Second + b := New("ws" + strings.TrimPrefix(mtbc.URL(), "http")) + if err := b.Run(ctx, nil); err != nil { + t.Skip("failed to start gozer:", err) + } + + tg := b.(*tbcGozer) + for !tg.Connected() { + select { + case <-ctx.Done(): + t.Skip("timeout waiting for connection") + case <-time.Tick(50 * time.Millisecond): + } + } + + // Pad or truncate to exactly 32 bytes. + var hashBytes [32]byte + copy(hashBytes[:], data) + txid := chainhash.Hash(hashBytes) + + // Must not panic for any input. Errors are acceptable. + _, _ = b.TxByID(ctx, &txid) + }) +} diff --git a/internal/testutil/mock/tbc.go b/internal/testutil/mock/tbc.go index f81eb9f00..12e19f236 100644 --- a/internal/testutil/mock/tbc.go +++ b/internal/testutil/mock/tbc.go @@ -213,22 +213,29 @@ func (f *TBCMockHandler) handle(c protocol.APIConn, utxos []tbcd.Utxo, mp *tbc.M if !ok { panic(fmt.Errorf("unexpected payload format: %v", payload)) } - resp = &tbcapi.TxByIdResponse{ - Tx: &tbcapi.Tx{ - Version: 2, - LockTime: 0, - TxIn: []*tbcapi.TxIn{{ - PreviousOutPoint: tbcapi.OutPoint{ - Hash: pl.TxID, - Index: 0, - }, - Sequence: wire.MaxTxInSequenceNum, - }}, - TxOut: []*tbcapi.TxOut{{ - Value: 50000, - PkScript: []byte{0x00, 0x14, 0x01, 0x02, 0x03}, - }}, - }, + // Zero hash signals "not found" for testing error paths. + if pl.TxID == (chainhash.Hash{}) { + resp = &tbcapi.TxByIdResponse{ + Error: protocol.RequestErrorf("tx not found"), + } + } else { + resp = &tbcapi.TxByIdResponse{ + Tx: &tbcapi.Tx{ + Version: 2, + LockTime: 0, + TxIn: []*tbcapi.TxIn{{ + PreviousOutPoint: tbcapi.OutPoint{ + Hash: pl.TxID, + Index: 0, + }, + Sequence: wire.MaxTxInSequenceNum, + }}, + TxOut: []*tbcapi.TxOut{{ + Value: 50000, + PkScript: []byte{0x00, 0x14, 0x01, 0x02, 0x03}, + }}, + }, + } } case tbcapi.CmdKeystonesByHeightRequest: pl, ok := payload.(*tbcapi.KeystonesByHeightRequest) From 8bd467b59979e9cb3d747e52fbd577369783484b Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Mon, 11 May 2026 12:16:35 +0100 Subject: [PATCH 09/25] test(wallet): cover error paths in signing and utxo selection Add targeted tests for error-return branches reachable from the public API: - UtxoPickerMultiple / UtxoPickerSingle no-match and skip-too-small paths. - TransactionSign error-wrap paths for unknown P2WPKH and P2TR keys, confirming the per-class dispatch propagates resolveInput- SigningKey failures with input index and class in the wrapping. - TransactionSign pre-validation: missing PrevOuts entry returns a clean error naming the offending input instead of panicking in witness sighash midstate computation. - prevOutsFetcher defensive panic on a malformed outpoint key. --- bitcoin/wallet/coverage_gaps_test.go | 235 +++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 bitcoin/wallet/coverage_gaps_test.go diff --git a/bitcoin/wallet/coverage_gaps_test.go b/bitcoin/wallet/coverage_gaps_test.go new file mode 100644 index 000000000..6a50341fc --- /dev/null +++ b/bitcoin/wallet/coverage_gaps_test.go @@ -0,0 +1,235 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package wallet + +import ( + "errors" + "strings" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + + "github.com/hemilabs/heminetwork/v2/api/tbcapi" + "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul" + "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul/memory" +) + +// TestUtxoPickerMultipleEmpty verifies the all-utxos-too-small path: +// if the caller's wallet cannot cover amount+fee from any single utxo +// and the running total never reaches the required amount, the +// picker returns "no suitable utxos found". +func TestUtxoPickerMultipleEmpty(t *testing.T) { + // Request more than the sum of all inputs. + utxos := []*tbcapi.UTXO{ + {Value: 1_000}, + {Value: 2_000}, + } + _, err := UtxoPickerMultiple(10_000, 100, utxos) + if err == nil { + t.Fatal("expected error when utxos cannot cover amount+fee") + } + if !strings.Contains(err.Error(), "no suitable utxos found") { + t.Fatalf("expected 'no suitable utxos found', got: %v", err) + } +} + +// TestUtxoPickerSingleNoneLargeEnough verifies the per-utxo skip path: +// when no single utxo is large enough, the picker continues past all +// of them and returns the not-found error. +func TestUtxoPickerSingleNoneLargeEnough(t *testing.T) { + utxos := []*tbcapi.UTXO{ + {Value: 1_000}, + {Value: 2_000}, + {Value: 3_000}, + } + _, err := UtxoPickerSingle(100_000, 100, utxos) + if err == nil { + t.Fatal("expected error when no single utxo is large enough") + } + if !strings.Contains(err.Error(), "no suitable utxo found") { + t.Fatalf("expected 'no suitable utxo found', got: %v", err) + } +} + +// TestUtxoPickerSingleFirstFit verifies the picker skips undersized +// utxos and returns the first one large enough. Exercises the +// skip-too-small continuation branch alongside the successful +// return. +func TestUtxoPickerSingleFirstFit(t *testing.T) { + utxos := []*tbcapi.UTXO{ + {Value: 500}, + {Value: 999}, + {Value: 50_000}, // first one large enough + {Value: 100_000}, // should not be picked + } + u, err := UtxoPickerSingle(10_000, 100, utxos) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.Value != 50_000 { + t.Fatalf("expected first-fit utxo of 50000, got %d", u.Value) + } +} + +// TestTransactionSignPrevOutNotFound verifies TransactionSign +// pre-validates PrevOuts against every input before invoking +// NewTxSigHashes, returning a clean error naming the offending +// input. Without this guard a caller-supplied incomplete PrevOuts +// panics deep inside witness sighash midstate computation. +func TestTransactionSignPrevOutNotFound(t *testing.T) { + params := &chaincfg.TestNet3Params + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("prev-out-not-found")) + outpoint := wire.NewOutPoint(&fundHash, 0) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(outpoint, nil, nil)) + tx.AddTxOut(wire.NewTxOut(1000, []byte{txscript.OP_RETURN})) + + // Intentionally empty PrevOuts — the pre-validation loop + // should catch this before NewTxSigHashes is reached. + empty := PrevOuts{} + + err = TransactionSign(params, m, tx, empty) + if err == nil { + t.Fatal("expected error when PrevOuts missing input entry") + } + if !strings.Contains(err.Error(), "previous out not found") { + t.Fatalf("expected 'previous out not found', got: %v", err) + } + if !strings.Contains(err.Error(), "input 0") { + t.Fatalf("expected error to name 'input 0', got: %v", err) + } +} + +// TestTransactionSignUnknownP2WPKHKey verifies that TransactionSign +// wraps signP2WPKH failures with the input index and class. The key +// is missing from zuul so resolveInputSigningKey fails through +// signP2WPKH, which propagates out of TransactionSign. +func TestTransactionSignUnknownP2WPKHKey(t *testing.T) { + params := &chaincfg.TestNet3Params + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + + // Build a P2WPKH pkScript for a key that zuul does NOT hold. + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressWitnessPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("unknown-p2wpkh-key")) + outpoint := wire.NewOutPoint(&fundHash, 0) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(outpoint, nil, nil)) + tx.AddTxOut(wire.NewTxOut(1000, []byte{txscript.OP_RETURN})) + + prev := wire.NewTxOut(50_000, pkScript) + prevOuts := PrevOuts{outpoint.String(): prev} + + err = TransactionSign(params, m, tx, prevOuts) + if err == nil { + t.Fatal("expected error when zuul has no key for P2WPKH input") + } + if !strings.Contains(err.Error(), "sign p2wpkh input 0") { + t.Fatalf("expected error to reference 'sign p2wpkh input 0', got: %v", + err) + } + if !errors.Is(err, zuul.ErrKeyDoesntExist) { + t.Fatalf("expected error to wrap ErrKeyDoesntExist, got: %v", err) + } +} + +// TestTransactionSignUnknownP2TRKey is the P2TR sibling of +// TestTransactionSignUnknownP2WPKHKey: proves the P2TR dispatch arm +// correctly wraps signP2TRKeyPath failures. +func TestTransactionSignUnknownP2TRKey(t *testing.T) { + params := &chaincfg.TestNet3Params + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + addr, err := btcutil.NewAddressTaproot( + outputKey.SerializeCompressed()[1:], params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("unknown-p2tr-key")) + outpoint := wire.NewOutPoint(&fundHash, 0) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(outpoint, nil, nil)) + tx.AddTxOut(wire.NewTxOut(1000, []byte{txscript.OP_RETURN})) + + prev := wire.NewTxOut(50_000, pkScript) + prevOuts := PrevOuts{outpoint.String(): prev} + + err = TransactionSign(params, m, tx, prevOuts) + if err == nil { + t.Fatal("expected error when zuul has no key for P2TR input") + } + if !strings.Contains(err.Error(), "sign p2tr input 0") { + t.Fatalf("expected error to reference 'sign p2tr input 0', got: %v", + err) + } +} + +// TestPrevOutsFetcherPanicsOnMalformedKey verifies the defensive +// panic fires when PrevOuts carries an outpoint string the wire +// parser can't decode. A silent drop would cause NewTxSigHashes to +// deref a nil TxOut later in witness sighash computation. +func TestPrevOutsFetcherPanicsOnMalformedKey(t *testing.T) { + bad := PrevOuts{ + "this-is-not-a-valid-outpoint-string": wire.NewTxOut(0, nil), + } + + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic on malformed outpoint key") + } + msg, ok := r.(string) + if !ok { + t.Fatalf("expected string panic, got %T: %v", r, r) + } + if !strings.Contains(msg, "malformed outpoint key") { + t.Fatalf("expected panic message about malformed key, got: %s", + msg) + } + }() + + _ = prevOutsFetcher(bad) +} From 22f7821ce4718191b84556449983bc1fa655d4b5 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Mon, 11 May 2026 10:27:09 +0100 Subject: [PATCH 10/25] fix(wallet): clear scriptSig for native segwit inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit signP2WPKH and signP2TRKeyPath set the witness but never cleared SignatureScript. TransactionCreate pre-populates SignatureScript with the pkScript for all inputs. For native segwit (P2WPKH, P2TR), scriptSig must be empty — a non-empty scriptSig causes the script engine to reject the transaction with a clean-stack violation. Add regression tests that pre-populate SignatureScript the way TransactionCreate does and verify it is cleared after signing. --- .../wallet/gozer/tbcgozer/tbcgozer_test.go | 1 + bitcoin/wallet/wallet.go | 2 + bitcoin/wallet/wallet_p2tr_test.go | 82 +++++++++++++++++++ bitcoin/wallet/wallet_p2wpkh_test.go | 81 ++++++++++++++++++ 4 files changed, 166 insertions(+) diff --git a/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go b/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go index 07e47f4c3..f770269ee 100644 --- a/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go +++ b/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go @@ -398,6 +398,7 @@ func TestTBCGozerMempoolUtxos(t *testing.T) { } }) } + func TestTBCGozerTxByIDNilTxid(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() diff --git a/bitcoin/wallet/wallet.go b/bitcoin/wallet/wallet.go index 61946bbed..ebb342c0a 100644 --- a/bitcoin/wallet/wallet.go +++ b/bitcoin/wallet/wallet.go @@ -267,6 +267,7 @@ func signP2WPKH(params *chaincfg.Params, z zuul.Zuul, tx *wire.MsgTx, idx int, p return fmt.Errorf("witness signature: %w", err) } tx.TxIn[idx].Witness = witness + tx.TxIn[idx].SignatureScript = nil // native segwit: scriptSig must be empty return nil } @@ -293,6 +294,7 @@ func signP2TRKeyPath(params *chaincfg.Params, z zuul.Zuul, tx *wire.MsgTx, idx i return fmt.Errorf("taproot signature: %w", err) } tx.TxIn[idx].Witness = wire.TxWitness{sig} + tx.TxIn[idx].SignatureScript = nil // native segwit: scriptSig must be empty return nil } diff --git a/bitcoin/wallet/wallet_p2tr_test.go b/bitcoin/wallet/wallet_p2tr_test.go index a92579759..1f3ef35b3 100644 --- a/bitcoin/wallet/wallet_p2tr_test.go +++ b/bitcoin/wallet/wallet_p2tr_test.go @@ -205,3 +205,85 @@ func TestSignMixedP2PKHAndP2TR(t *testing.T) { t.Fatalf("engine rejected taproot input: %v", err) } } + +// TestSignP2TRKeyPathClearsScriptSig verifies that TransactionSign +// clears SignatureScript for taproot inputs. TransactionCreate +// pre-populates SignatureScript with the pkScript for fee estimation; +// the signing path must clear it because native segwit (P2WPKH, P2TR) +// requires an empty scriptSig. +func TestSignP2TRKeyPathClearsScriptSig(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + if err := m.PutKey(&zuul.NamedKey{ + Name: "test-key", + PrivateKey: priv, + }); err != nil { + t.Fatal(err) + } + + // BIP-86 key-path: taproot address derived from tweaked output key. + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + taprootAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(outputKey), params) + if err != nil { + t.Fatal(err) + } + p2trScript, err := txscript.PayToAddrScript(taprootAddr) + if err != nil { + t.Fatal(err) + } + + destPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + destPKHash := btcutil.Hash160(destPriv.PubKey().SerializeCompressed()) + destAddr, err := btcutil.NewAddressPubKeyHash(destPKHash, params) + if err != nil { + t.Fatal(err) + } + destScript, err := txscript.PayToAddrScript(destAddr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("test-p2tr-scriptsig-clearing")) + fundOutpoint := wire.NewOutPoint(&fundHash, 0) + const fundValue int64 = 100_000 + + // Build tx the way TransactionCreate does: pre-populate + // SignatureScript with the pkScript. + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(fundOutpoint, p2trScript, nil)) + tx.AddTxOut(wire.NewTxOut(fundValue/2, destScript)) + + if len(tx.TxIn[0].SignatureScript) == 0 { + t.Fatal("precondition: SignatureScript should be pre-populated") + } + + prevOuts := PrevOuts{ + fundOutpoint.String(): wire.NewTxOut(fundValue, p2trScript), + } + + if err := TransactionSign(params, m, tx, prevOuts); err != nil { + t.Fatalf("TransactionSign: %v", err) + } + + if len(tx.TxIn[0].SignatureScript) != 0 { + t.Fatalf("SignatureScript not cleared for P2TR input: %d bytes remain", + len(tx.TxIn[0].SignatureScript)) + } + + if err := verifyInput(tx, 0, prevOuts); err != nil { + t.Fatalf("script engine rejected signed input: %v", err) + } +} diff --git a/bitcoin/wallet/wallet_p2wpkh_test.go b/bitcoin/wallet/wallet_p2wpkh_test.go index 706948d65..c2a15f9cc 100644 --- a/bitcoin/wallet/wallet_p2wpkh_test.go +++ b/bitcoin/wallet/wallet_p2wpkh_test.go @@ -240,3 +240,84 @@ func TestSignMixedP2PKHAndP2WPKH(t *testing.T) { t.Fatalf("engine rejected P2WPKH input: %v", err) } } + +// TestSignP2WPKHClearsScriptSig verifies that TransactionSign clears +// SignatureScript for native segwit inputs. TransactionCreate +// pre-populates SignatureScript with the pkScript for fee estimation; +// the signing path must clear it because native segwit (P2WPKH, P2TR) +// requires an empty scriptSig. +func TestSignP2WPKHClearsScriptSig(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + if err := m.PutKey(&zuul.NamedKey{ + Name: "test-key", + PrivateKey: priv, + }); err != nil { + t.Fatal(err) + } + + pubCompressed := priv.PubKey().SerializeCompressed() + pubHash := btcutil.Hash160(pubCompressed) + p2wpkhAddr, err := btcutil.NewAddressWitnessPubKeyHash(pubHash, params) + if err != nil { + t.Fatal(err) + } + p2wpkhScript, err := txscript.PayToAddrScript(p2wpkhAddr) + if err != nil { + t.Fatal(err) + } + + destPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + destPKHash := btcutil.Hash160(destPriv.PubKey().SerializeCompressed()) + destAddr, err := btcutil.NewAddressPubKeyHash(destPKHash, params) + if err != nil { + t.Fatal(err) + } + destScript, err := txscript.PayToAddrScript(destAddr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("test-scriptsig-clearing")) + fundOutpoint := wire.NewOutPoint(&fundHash, 0) + const fundValue int64 = 100_000 + + // Build tx the way TransactionCreate does: pre-populate + // SignatureScript with the pkScript. + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(fundOutpoint, p2wpkhScript, nil)) + tx.AddTxOut(wire.NewTxOut(fundValue/2, destScript)) + + if len(tx.TxIn[0].SignatureScript) == 0 { + t.Fatal("precondition: SignatureScript should be pre-populated") + } + + prevOuts := PrevOuts{ + fundOutpoint.String(): wire.NewTxOut(fundValue, p2wpkhScript), + } + + if err := TransactionSign(params, m, tx, prevOuts); err != nil { + t.Fatalf("TransactionSign: %v", err) + } + + if len(tx.TxIn[0].SignatureScript) != 0 { + t.Fatalf("SignatureScript not cleared for P2WPKH input: %d bytes remain", + len(tx.TxIn[0].SignatureScript)) + } + + if err := verifyInput(tx, 0, prevOuts); err != nil { + t.Fatalf("script engine rejected signed input: %v", err) + } +} From 1cc3840e2595fa1b40620f2046a58f36ed439985 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Mon, 11 May 2026 12:35:20 +0100 Subject: [PATCH 11/25] docs(changelog): add wallet segwit signing entries --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 428af9c46..179fd0c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#987](https://github.com/hemilabs/heminetwork/pull/987)). - Add filtered transaction notifications to TBC for commerce (TxWatch/TxUnwatch API) ([#986](https://github.com/hemilabs/heminetwork/pull/986)). +- Add native P2WPKH and BIP-86 P2TR key-path signing to + `wallet.TransactionSign`, with BIP-143/BIP-341 sighash handling + ([#971](https://github.com/hemilabs/heminetwork/pull/971)). +- Add multi-form key indexing to `zuul/memory`: `PutKey` now derives + P2PKH, P2WPKH, and BIP-86 P2TR addresses from a single compressed + public key and indexes the key under all three + ([#971](https://github.com/hemilabs/heminetwork/pull/971)). +- Add `TxByID` to the `gozer.Gozer` interface with `tbcGozer` + implementation backed by TBC RPC + ([#971](https://github.com/hemilabs/heminetwork/pull/971)). ### Changed +- **Breaking:** `wallet.TransactionCreate` and `wallet.TransactionSign` now use + `PrevOuts` (`map[string]*wire.TxOut`) instead of `map[string][]byte`. + Witness sighash algorithms require the spent output's value; the old type + carried only the pkScript + ([#971](https://github.com/hemilabs/heminetwork/pull/971)). + - Update required Go version to [Go 1.26](https://tip.golang.org/doc/go1.26) ([#673](https://github.com/hemilabs/heminetwork/pull/673), [#698](https://github.com/hemilabs/heminetwork/pull/698), [#896](https://github.com/hemilabs/heminetwork/pull/896)). @@ -53,6 +69,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix bug that led to delayed request processing in tbcgozer ([#969](https://github.com/hemilabs/heminetwork/pull/969)) +- Fix `signP2WPKH` and `signP2TRKeyPath` not clearing `SignatureScript` after + signing. `TransactionCreate` pre-populates `SignatureScript` for fee + estimation; native segwit requires an empty scriptSig + ([#971](https://github.com/hemilabs/heminetwork/pull/971)). + - Fix tbcd requesting witness-stripped blocks and txs from peers (BIP-144); on-disk blocks are now witness-inclusive after a v5 upgrade plus resync ([#972](https://github.com/hemilabs/heminetwork/pull/972)). From 2d07ca94d438fa4b72e5f005f40b565ba3bcd441 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Tue, 12 May 2026 08:15:15 +0100 Subject: [PATCH 12/25] style(wallet): clean up test literal style Drop underscore digit separators from numeric literals to match codebase convention. Unwrap multiline PutKey if-init statements into separate assignment and error check. --- bitcoin/wallet/coverage_gaps_test.go | 26 +++++++++++++------------- bitcoin/wallet/wallet_p2tr_test.go | 13 +++++++------ bitcoin/wallet/wallet_p2wpkh_test.go | 13 +++++++------ bitcoin/wallet/wallet_pop_test.go | 2 +- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/bitcoin/wallet/coverage_gaps_test.go b/bitcoin/wallet/coverage_gaps_test.go index 6a50341fc..7ab93012d 100644 --- a/bitcoin/wallet/coverage_gaps_test.go +++ b/bitcoin/wallet/coverage_gaps_test.go @@ -28,10 +28,10 @@ import ( func TestUtxoPickerMultipleEmpty(t *testing.T) { // Request more than the sum of all inputs. utxos := []*tbcapi.UTXO{ - {Value: 1_000}, - {Value: 2_000}, + {Value: 1000}, + {Value: 2000}, } - _, err := UtxoPickerMultiple(10_000, 100, utxos) + _, err := UtxoPickerMultiple(10000, 100, utxos) if err == nil { t.Fatal("expected error when utxos cannot cover amount+fee") } @@ -45,11 +45,11 @@ func TestUtxoPickerMultipleEmpty(t *testing.T) { // of them and returns the not-found error. func TestUtxoPickerSingleNoneLargeEnough(t *testing.T) { utxos := []*tbcapi.UTXO{ - {Value: 1_000}, - {Value: 2_000}, - {Value: 3_000}, + {Value: 1000}, + {Value: 2000}, + {Value: 3000}, } - _, err := UtxoPickerSingle(100_000, 100, utxos) + _, err := UtxoPickerSingle(100000, 100, utxos) if err == nil { t.Fatal("expected error when no single utxo is large enough") } @@ -66,14 +66,14 @@ func TestUtxoPickerSingleFirstFit(t *testing.T) { utxos := []*tbcapi.UTXO{ {Value: 500}, {Value: 999}, - {Value: 50_000}, // first one large enough - {Value: 100_000}, // should not be picked + {Value: 50000}, // first one large enough + {Value: 100000}, // should not be picked } - u, err := UtxoPickerSingle(10_000, 100, utxos) + u, err := UtxoPickerSingle(10000, 100, utxos) if err != nil { t.Fatalf("unexpected error: %v", err) } - if u.Value != 50_000 { + if u.Value != 50000 { t.Fatalf("expected first-fit utxo of 50000, got %d", u.Value) } } @@ -146,7 +146,7 @@ func TestTransactionSignUnknownP2WPKHKey(t *testing.T) { tx.AddTxIn(wire.NewTxIn(outpoint, nil, nil)) tx.AddTxOut(wire.NewTxOut(1000, []byte{txscript.OP_RETURN})) - prev := wire.NewTxOut(50_000, pkScript) + prev := wire.NewTxOut(50000, pkScript) prevOuts := PrevOuts{outpoint.String(): prev} err = TransactionSign(params, m, tx, prevOuts) @@ -194,7 +194,7 @@ func TestTransactionSignUnknownP2TRKey(t *testing.T) { tx.AddTxIn(wire.NewTxIn(outpoint, nil, nil)) tx.AddTxOut(wire.NewTxOut(1000, []byte{txscript.OP_RETURN})) - prev := wire.NewTxOut(50_000, pkScript) + prev := wire.NewTxOut(50000, pkScript) prevOuts := PrevOuts{outpoint.String(): prev} err = TransactionSign(params, m, tx, prevOuts) diff --git a/bitcoin/wallet/wallet_p2tr_test.go b/bitcoin/wallet/wallet_p2tr_test.go index 1f3ef35b3..7d2074f6f 100644 --- a/bitcoin/wallet/wallet_p2tr_test.go +++ b/bitcoin/wallet/wallet_p2tr_test.go @@ -53,7 +53,7 @@ func TestSignP2TRKeyPath(t *testing.T) { fundHash := chainhash.DoubleHashH([]byte("p2tr-keypath-funding-txid-00000")) fundOutpoint := wire.NewOutPoint(&fundHash, 0) - const fundValue int64 = 100_000 + const fundValue int64 = 100000 // Throwaway destination: another P2TR just to keep the output valid. destPriv, err := btcec.NewPrivateKey() @@ -161,8 +161,8 @@ func TestSignMixedP2PKHAndP2TR(t *testing.T) { op1 := wire.NewOutPoint(&h1, 0) op2 := wire.NewOutPoint(&h2, 0) - const v1 int64 = 50_000 // legacy funding - const v2 int64 = 10_000 // taproot (ordinal-shaped) + const v1 int64 = 50000 // legacy funding + const v2 int64 = 10000 // taproot (ordinal-shaped) destScript := taprootScript // send to ourselves for brevity @@ -223,10 +223,11 @@ func TestSignP2TRKeyPathClearsScriptSig(t *testing.T) { if err != nil { t.Fatal(err) } - if err := m.PutKey(&zuul.NamedKey{ + err = m.PutKey(&zuul.NamedKey{ Name: "test-key", PrivateKey: priv, - }); err != nil { + }) + if err != nil { t.Fatal(err) } @@ -258,7 +259,7 @@ func TestSignP2TRKeyPathClearsScriptSig(t *testing.T) { fundHash := chainhash.DoubleHashH([]byte("test-p2tr-scriptsig-clearing")) fundOutpoint := wire.NewOutPoint(&fundHash, 0) - const fundValue int64 = 100_000 + const fundValue int64 = 100000 // Build tx the way TransactionCreate does: pre-populate // SignatureScript with the pkScript. diff --git a/bitcoin/wallet/wallet_p2wpkh_test.go b/bitcoin/wallet/wallet_p2wpkh_test.go index c2a15f9cc..4c37776e0 100644 --- a/bitcoin/wallet/wallet_p2wpkh_test.go +++ b/bitcoin/wallet/wallet_p2wpkh_test.go @@ -86,7 +86,7 @@ func TestSignP2WPKHInput(t *testing.T) { // the engine only checks signatures against the tx and prev TxOut. fundHash := chainhash.DoubleHashH([]byte("test-funding-txid-00000000000000")) fundOutpoint := wire.NewOutPoint(&fundHash, 0) - const fundValue int64 = 100_000 + const fundValue int64 = 100000 // Build a tx that spends the P2WPKH input and sends half to a // throwaway P2PKH output. @@ -186,8 +186,8 @@ func TestSignMixedP2PKHAndP2WPKH(t *testing.T) { op1 := wire.NewOutPoint(&h1, 0) op2 := wire.NewOutPoint(&h2, 0) - const v1 int64 = 50_000 - const v2 int64 = 80_000 + const v1 int64 = 50000 + const v2 int64 = 80000 // Throwaway destination. destPKHash := btcutil.Hash160(legacyPriv.PubKey().SerializeCompressed()) @@ -258,10 +258,11 @@ func TestSignP2WPKHClearsScriptSig(t *testing.T) { if err != nil { t.Fatal(err) } - if err := m.PutKey(&zuul.NamedKey{ + err = m.PutKey(&zuul.NamedKey{ Name: "test-key", PrivateKey: priv, - }); err != nil { + }) + if err != nil { t.Fatal(err) } @@ -292,7 +293,7 @@ func TestSignP2WPKHClearsScriptSig(t *testing.T) { fundHash := chainhash.DoubleHashH([]byte("test-scriptsig-clearing")) fundOutpoint := wire.NewOutPoint(&fundHash, 0) - const fundValue int64 = 100_000 + const fundValue int64 = 100000 // Build tx the way TransactionCreate does: pre-populate // SignatureScript with the pkScript. diff --git a/bitcoin/wallet/wallet_pop_test.go b/bitcoin/wallet/wallet_pop_test.go index cadf7d220..76f29ef13 100644 --- a/bitcoin/wallet/wallet_pop_test.go +++ b/bitcoin/wallet/wallet_pop_test.go @@ -67,7 +67,7 @@ func newPoPFixture(t *testing.T) *popFixture { utxo := &tbcapi.UTXO{ TxId: fundHash, OutIndex: 0, - Value: btcutil.Amount(500_000), + Value: btcutil.Amount(500000), } keystone := &hemi.L2Keystone{ From 399b0d204713b31275b6bc4e464e46f2c1ef983c Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Tue, 12 May 2026 08:28:52 +0100 Subject: [PATCH 13/25] tbc: add CPFP mempool resolution and MaxResponseSize tests Add unit tests for three tbc fixes that landed without coverage: CPFP mempool resolution (parseTx fallback): - TestTxOutByOutpoint: parent output resolved from mempool - TestTxOutByOutpointNotFound: missing txid returns nil - TestTxOutByOutpointBadIndex: out-of-range index returns nil - TestParseTxCPFP: child tx resolves input value from unconfirmed parent in mempool when block database has no record - TestParseTxCPFPNoMempool: fails when no mempool provided - TestParseTxCPFPParentNotInMempool: fails when parent absent from both database and mempool MaxResponseSize: - TestMaxResponseSize: assert the websocket read limit constant is 16 MiB, large enough for worst-case block hex payloads --- service/tbc/cpfp_test.go | 377 ++++++++++++++++++++++++++++++++ service/tbc/maxresponse_test.go | 23 ++ 2 files changed, 400 insertions(+) create mode 100644 service/tbc/cpfp_test.go create mode 100644 service/tbc/maxresponse_test.go diff --git a/service/tbc/cpfp_test.go b/service/tbc/cpfp_test.go new file mode 100644 index 000000000..46e17292a --- /dev/null +++ b/service/tbc/cpfp_test.go @@ -0,0 +1,377 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package tbc + +import ( + "context" + "errors" + "math/big" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + + "github.com/hemilabs/heminetwork/v2/database/tbcd" +) + +// stubDB implements tbcd.Database by returning errors for every +// method that parseTx's call chain can reach. This lets us verify +// the CPFP mempool fallback without a real database. +type stubDB struct{} + +func (stubDB) Close() error { return nil } + +// BlockHashByTxId is the first thing txOutFromOutPoint calls. +// Returning an error simulates "parent not in block db", which +// is the trigger for the CPFP mempool fallback. +func (stubDB) BlockHashByTxId(context.Context, chainhash.Hash) (*chainhash.Hash, error) { + return nil, errors.New("not found") +} + +// The remaining methods satisfy the tbcd.Database interface but +// should never be reached by parseTx when BlockHashByTxId fails. +func (stubDB) Version(context.Context) (int, error) { panic("stub") } +func (stubDB) MetadataDel(context.Context, []byte) error { panic("stub") } +func (stubDB) MetadataGet(context.Context, []byte) ([]byte, error) { panic("stub") } +func (stubDB) MetadataPut(context.Context, []byte, []byte) error { panic("stub") } +func (stubDB) MetadataBatchGet(context.Context, bool, [][]byte) ([]tbcd.Row, error) { + panic("stub") +} +func (stubDB) MetadataBatchPut(context.Context, []tbcd.Row) error { panic("stub") } +func (stubDB) BlockHeaderBest(context.Context) (*tbcd.BlockHeader, error) { + panic("stub") +} + +func (stubDB) BlockHeaderByHash(context.Context, chainhash.Hash) (*tbcd.BlockHeader, error) { + panic("stub") +} + +func (stubDB) BlockHeaderGenesisInsert(context.Context, wire.BlockHeader, uint64, *big.Int) error { + panic("stub") +} +func (stubDB) BlockHeaderCacheStats() tbcd.CacheStats { panic("stub") } +func (stubDB) BlockHeadersByHeight(context.Context, uint64) ([]tbcd.BlockHeader, error) { + panic("stub") +} + +func (stubDB) BlockHeadersInsert(context.Context, *wire.MsgHeaders, tbcd.BatchHook) (tbcd.InsertType, *tbcd.BlockHeader, *tbcd.BlockHeader, int, error) { + panic("stub") +} + +func (stubDB) BlockHeadersRemove(context.Context, *wire.MsgHeaders, *wire.BlockHeader, tbcd.BatchHook) (tbcd.RemoveType, *tbcd.BlockHeader, error) { + panic("stub") +} + +func (stubDB) BlocksMissing(context.Context, int) ([]tbcd.BlockIdentifier, error) { + panic("stub") +} + +func (stubDB) BlockMissingDelete(context.Context, int64, chainhash.Hash) error { + panic("stub") +} + +func (stubDB) BlockInsert(context.Context, *btcutil.Block) (int64, error) { + panic("stub") +} + +func (stubDB) BlockByHash(context.Context, chainhash.Hash) (*btcutil.Block, error) { + panic("stub") +} + +func (stubDB) BlockExistsByHash(context.Context, chainhash.Hash) (bool, error) { + panic("stub") +} +func (stubDB) BlockCacheStats() tbcd.CacheStats { panic("stub") } +func (stubDB) BlockHeaderByUtxoIndex(context.Context) (*tbcd.BlockHeader, error) { + panic("stub") +} + +func (stubDB) BlockHeaderByTxIndex(context.Context) (*tbcd.BlockHeader, error) { + panic("stub") +} + +func (stubDB) BlockUtxoUpdate(context.Context, int, map[tbcd.Outpoint]tbcd.CacheOutput, chainhash.Hash) error { + panic("stub") +} + +func (stubDB) BlockTxUpdate(context.Context, int, map[tbcd.TxKey]*tbcd.TxValue, chainhash.Hash) error { + panic("stub") +} + +func (stubDB) SpentOutputsByTxId(context.Context, chainhash.Hash) ([]tbcd.SpentInfo, error) { + panic("stub") +} + +func (stubDB) BalanceByScriptHash(context.Context, tbcd.ScriptHash) (uint64, error) { + panic("stub") +} + +func (stubDB) BlockInTxIndex(context.Context, chainhash.Hash) (bool, error) { + panic("stub") +} + +func (stubDB) ScriptHashByOutpoint(context.Context, tbcd.Outpoint) (*tbcd.ScriptHash, error) { + panic("stub") +} + +func (stubDB) ScriptHashesByOutpoint(context.Context, []*tbcd.Outpoint, func(tbcd.Outpoint, tbcd.ScriptHash) error) error { + panic("stub") +} + +func (stubDB) UtxosByScriptHash(context.Context, tbcd.ScriptHash, uint64, uint64) ([]tbcd.Utxo, error) { + panic("stub") +} + +func (stubDB) UtxosByScriptHashCount(context.Context, tbcd.ScriptHash) (uint64, error) { + panic("stub") +} + +func (stubDB) BlockKeystoneUpdate(context.Context, int, map[chainhash.Hash]tbcd.Keystone, chainhash.Hash) error { + panic("stub") +} + +func (stubDB) BlockKeystoneByL2KeystoneAbrevHash(context.Context, chainhash.Hash) (*tbcd.Keystone, error) { + panic("stub") +} + +func (stubDB) BlockHeaderByKeystoneIndex(context.Context) (*tbcd.BlockHeader, error) { + panic("stub") +} + +func (stubDB) KeystonesByHeight(context.Context, uint32, int) ([]tbcd.Keystone, error) { + panic("stub") +} + +func (stubDB) BlockHeaderByZKIndex(context.Context) (*tbcd.BlockHeader, error) { + panic("stub") +} + +func (stubDB) BlockZKUpdate(context.Context, int, map[tbcd.ZKIndexKey][]byte, chainhash.Hash) error { + panic("stub") +} + +func (stubDB) ZKValueAndScriptByOutpoint(context.Context, tbcd.Outpoint) (uint64, []byte, error) { + panic("stub") +} + +func (stubDB) ZKBalanceByScriptHash(context.Context, tbcd.ScriptHash) (uint64, error) { + panic("stub") +} + +func (stubDB) ZKSpentOutputs(context.Context, tbcd.ScriptHash) ([]tbcd.ZKSpentOutput, error) { + panic("stub") +} + +func (stubDB) ZKSpendingOutpoints(context.Context, chainhash.Hash) ([]tbcd.ZKSpendingOutpoint, error) { + panic("stub") +} + +func (stubDB) ZKSpendableOutputs(context.Context, tbcd.ScriptHash) ([]tbcd.ZKSpendableOutput, error) { + panic("stub") +} + +// TestTxOutByOutpoint verifies that txOutByOutpoint resolves an +// output from a parent transaction that is in the mempool. +func TestTxOutByOutpoint(t *testing.T) { + mp, err := NewMempool() + if err != nil { + t.Fatal(err) + } + + // Build a parent transaction with a known output. + parentTx := wire.NewMsgTx(2) + parentTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.DoubleHashH([]byte("coinbase")), + Index: 0, + }, + }) + parentTx.AddTxOut(wire.NewTxOut(50_000, []byte{0x00, 0x14, 0xaa})) + parentTx.AddTxOut(wire.NewTxOut(30_000, []byte{0x00, 0x14, 0xbb})) + + parentHash := parentTx.TxHash() + + mptx := NewMempoolTx(parentTx) + mptx.expires = time.Now().Add(time.Minute) + mptx.size = 250 + ctx := context.Background() + if err := mp.TxInsert(ctx, &mptx); err != nil { + t.Fatal(err) + } + + // Index 0 should return the first output. + out := mp.txOutByOutpoint(parentHash, 0) + if out == nil { + t.Fatal("expected output at index 0") + } + if out.Value != 50_000 { + t.Fatalf("expected value 50000, got %d", out.Value) + } + + // Index 1 should return the second output. + out = mp.txOutByOutpoint(parentHash, 1) + if out == nil { + t.Fatal("expected output at index 1") + } + if out.Value != 30_000 { + t.Fatalf("expected value 30000, got %d", out.Value) + } +} + +// TestTxOutByOutpointNotFound verifies that txOutByOutpoint returns +// nil when the transaction is not in the mempool. +func TestTxOutByOutpointNotFound(t *testing.T) { + mp, err := NewMempool() + if err != nil { + t.Fatal(err) + } + + missing := chainhash.DoubleHashH([]byte("missing-tx")) + if out := mp.txOutByOutpoint(missing, 0); out != nil { + t.Fatalf("expected nil for missing tx, got %v", out) + } +} + +// TestTxOutByOutpointBadIndex verifies that txOutByOutpoint returns +// nil when the output index is out of range. +func TestTxOutByOutpointBadIndex(t *testing.T) { + mp, err := NewMempool() + if err != nil { + t.Fatal(err) + } + + parentTx := wire.NewMsgTx(2) + parentTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.DoubleHashH([]byte("coinbase")), + Index: 0, + }, + }) + parentTx.AddTxOut(wire.NewTxOut(50_000, []byte{0x00, 0x14, 0xaa})) + + mptx := NewMempoolTx(parentTx) + mptx.expires = time.Now().Add(time.Minute) + mptx.size = 250 + ctx := context.Background() + if err := mp.TxInsert(ctx, &mptx); err != nil { + t.Fatal(err) + } + + parentHash := parentTx.TxHash() + + // Index 1 is out of range (only one output). + if out := mp.txOutByOutpoint(parentHash, 1); out != nil { + t.Fatalf("expected nil for out-of-range index, got %v", out) + } + + // Large index. + if out := mp.txOutByOutpoint(parentHash, 999); out != nil { + t.Fatalf("expected nil for large index, got %v", out) + } +} + +// TestParseTxCPFP verifies the child-pays-for-parent path in +// parseTx: when a child transaction spends an output from an +// unconfirmed parent, parseTx resolves the input value from the +// mempool instead of the block database. +func TestParseTxCPFP(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + mp, err := NewMempool() + if err != nil { + t.Fatal(err) + } + + // Build and insert a parent transaction into the mempool. + parentTx := wire.NewMsgTx(2) + parentTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.DoubleHashH([]byte("coinbase")), + Index: 0, + }, + }) + parentTx.AddTxOut(wire.NewTxOut(100_000, []byte{0x00, 0x14, 0xcc})) + + mptx := NewMempoolTx(parentTx) + mptx.expires = time.Now().Add(time.Minute) + mptx.inValue = 100_000 + mptx.outValue = 100_000 + mptx.size = 250 + if err := mp.TxInsert(ctx, &mptx); err != nil { + t.Fatal(err) + } + + // Build a child that spends the parent's output 0. + childTx := wire.NewMsgTx(2) + childTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: parentTx.TxHash(), + Index: 0, + }, + }) + childTx.AddTxOut(wire.NewTxOut(90_000, []byte{0x00, 0x14, 0xdd})) + + // parseTx with a stub db that always fails lookups — the CPFP + // path should resolve the parent's output from the mempool. + db := stubDB{} + inVal, outVal, err := parseTx(ctx, db, mp, childTx) + if err != nil { + t.Fatalf("parseTx CPFP failed: %v", err) + } + if inVal != 100_000 { + t.Fatalf("expected input value 100000, got %d", inVal) + } + if outVal != 90_000 { + t.Fatalf("expected output value 90000, got %d", outVal) + } +} + +// TestParseTxCPFPNoMempool verifies that parseTx fails when the +// parent is not in the block database AND no mempool is provided. +func TestParseTxCPFPNoMempool(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + parentHash := chainhash.DoubleHashH([]byte("parent")) + childTx := wire.NewMsgTx(2) + childTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: parentHash, Index: 0}, + }) + childTx.AddTxOut(wire.NewTxOut(90_000, []byte{0x00, 0x14, 0xdd})) + + db := stubDB{} + _, _, err := parseTx(ctx, db, nil, childTx) + if err == nil { + t.Fatal("expected error when parent not in db and no mempool") + } +} + +// TestParseTxCPFPParentNotInMempool verifies that parseTx fails when +// the parent is in neither the block database nor the mempool. +func TestParseTxCPFPParentNotInMempool(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + mp, err := NewMempool() + if err != nil { + t.Fatal(err) + } + + parentHash := chainhash.DoubleHashH([]byte("missing-parent")) + childTx := wire.NewMsgTx(2) + childTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: parentHash, Index: 0}, + }) + childTx.AddTxOut(wire.NewTxOut(90_000, []byte{0x00, 0x14, 0xdd})) + + db := stubDB{} + _, _, err = parseTx(ctx, db, mp, childTx) + if err == nil { + t.Fatal("expected error when parent not in db or mempool") + } +} diff --git a/service/tbc/maxresponse_test.go b/service/tbc/maxresponse_test.go new file mode 100644 index 000000000..f94585617 --- /dev/null +++ b/service/tbc/maxresponse_test.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package tbc + +import ( + "testing" + + "github.com/hemilabs/heminetwork/v2/api/tbcapi" +) + +// TestMaxResponseSize verifies the websocket read limit constant +// is large enough for worst-case block hex payloads. A 4 MB block +// serialises to ~8 MB hex and the JSON envelope adds overhead; the +// old 6 MiB limit was insufficient. +func TestMaxResponseSize(t *testing.T) { + const expected = 16 * (1 << 20) // 16 MiB + if tbcapi.MaxResponseSize != expected { + t.Fatalf("MaxResponseSize = %d, want %d (16 MiB)", + tbcapi.MaxResponseSize, expected) + } +} From 80b75e7d469e00a237e88057155d8050b1cbedf6 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Fri, 17 Apr 2026 04:50:07 -0700 Subject: [PATCH 14/25] feat(zuul): add tss key type TSSNamedKey represents a key controlled by an external threshold signature scheme. No private material is held locally; the struct carries only the aggregated group public key and an opaque keyID that external signers use to identify which distributed key to sign with. The Zuul interface grows four symmetrical methods: PutTSSKey, GetTSSKey, PurgeTSSKey, LookupTSSKeyByAddr. These parallel the existing local-key methods but dispatch to a separate in-memory index so the two key types share address namespace without overlapping. TSS keys are indexed under P2PKH and P2WPKH only. Taproot P2TR key-path spends require schnorr signatures; ECDSA TSS (the variant this branch initially integrates) cannot satisfy a BIP-341 key-path spend, so exposing a TSS key under a P2TR address would be a footgun for callers trying to build a send from that address. Schnorr TSS, when added later, will get its own enrolment path that includes P2TR. Collisions are detected bidirectionally: enrolling a local key at an address already held by a TSS key (and vice versa) returns ErrKeyExists. PurgeTSSKey removes every indexed address form in a single call, mirroring the multi-address behaviour of the local PurgeKey. Tests cover: P2PKH+P2WPKH indexing with P2TR explicitly excluded; purge round-trip from any address form; field validation (nil struct, missing PublicKey, zero-length KeyID); collision detection in both directions between PutKey and PutTSSKey. Existing PoP and signing tests pass unchanged. --- bitcoin/wallet/zuul/memory/memory.go | 139 +++++++++- bitcoin/wallet/zuul/memory/memory_tss_test.go | 252 ++++++++++++++++++ bitcoin/wallet/zuul/zuul.go | 33 ++- 3 files changed, 417 insertions(+), 7 deletions(-) create mode 100644 bitcoin/wallet/zuul/memory/memory_tss_test.go diff --git a/bitcoin/wallet/zuul/memory/memory.go b/bitcoin/wallet/zuul/memory/memory.go index 8f9188b31..3016a3b2e 100644 --- a/bitcoin/wallet/zuul/memory/memory.go +++ b/bitcoin/wallet/zuul/memory/memory.go @@ -24,10 +24,16 @@ import ( // public key, so LookupKeyByAddr succeeds regardless of which address // form a caller presents. Currently indexes P2PKH, P2WPKH, and // BIP-86 P2TR addresses. +// +// TSS keys are tracked in a parallel map. They share the same +// address-keyspace as local keys (an address is either controlled by +// a local private key or by a TSS committee, never both) but queries +// dispatch to the right side via the typed lookup methods. type memoryZuul struct { - mtx sync.Mutex - params *chaincfg.Params - keys map[string]*zuul.NamedKey + mtx sync.Mutex + params *chaincfg.Params + keys map[string]*zuul.NamedKey + tssKeys map[string]*zuul.TSSNamedKey } var _ zuul.Zuul = (*memoryZuul)(nil) @@ -35,8 +41,9 @@ var _ zuul.Zuul = (*memoryZuul)(nil) // New returns a new [zuul.Zuul] implementation that stores data in-memory. func New(params *chaincfg.Params) (zuul.Zuul, error) { m := &memoryZuul{ - params: params, - keys: make(map[string]*zuul.NamedKey, 10), + params: params, + keys: make(map[string]*zuul.NamedKey, 10), + tssKeys: make(map[string]*zuul.TSSNamedKey, 10), } return m, nil } @@ -101,11 +108,16 @@ func (m *memoryZuul) PutKey(nk *zuul.NamedKey) error { defer m.mtx.Unlock() // All-or-nothing: if any address already points at a stored key, - // refuse the put without mutating. + // refuse the put without mutating. Collisions are detected + // against both the local and TSS key indexes so the two cannot + // claim overlapping address space. for _, a := range addrs { if _, ok := m.keys[a]; ok { return zuul.ErrKeyExists } + if _, ok := m.tssKeys[a]; ok { + return zuul.ErrKeyExists + } } for _, a := range addrs { m.keys[a] = nk @@ -180,3 +192,118 @@ func (m *memoryZuul) LookupKeyByAddr(addr btcutil.Address) (*btcec.PrivateKey, b } return nk.PrivateKey, true, nil } + +// ecdsaAddressesForPubKey returns the set of addresses an ECDSA key +// can sign for: P2PKH and P2WPKH. Taproot (P2TR) key-path spends +// require schnorr signatures, so ECDSA TSS keys cannot be used to +// spend a P2TR output regardless of the aggregated pubkey. This +// helper is used for TSS key enrolment to restrict the address +// surface accordingly. +func ecdsaAddressesForPubKey(params *chaincfg.Params, pubCompressed []byte) ([]string, error) { + addrs := make([]string, 0, 2) + + pkHash := btcutil.Hash160(pubCompressed) + p2pkh, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + return nil, fmt.Errorf("p2pkh address: %w", err) + } + addrs = append(addrs, p2pkh.EncodeAddress()) + + p2wpkh, err := btcutil.NewAddressWitnessPubKeyHash(pkHash, params) + if err != nil { + return nil, fmt.Errorf("p2wpkh address: %w", err) + } + addrs = append(addrs, p2wpkh.EncodeAddress()) + + return addrs, nil +} + +// PutTSSKey enrols a TSS-controlled public key. The key is indexed +// under the addresses an ECDSA signer can spend: P2PKH and P2WPKH. +// Taproot is not supported because ECDSA signatures cannot satisfy +// a BIP-341 key-path spend. +// +// Collisions are detected against both the local-key index and the +// TSS-key index: an address pointing to either form of key refuses +// the insert with ErrKeyExists. +func (m *memoryZuul) PutTSSKey(tnk *zuul.TSSNamedKey) error { + if tnk == nil || tnk.PublicKey == nil { + return fmt.Errorf("tss key: public key required") + } + if len(tnk.KeyID) == 0 { + return fmt.Errorf("tss key: key id required") + } + + pubBytes := tnk.PublicKey.SerializeCompressed() + addrs, err := ecdsaAddressesForPubKey(m.params, pubBytes) + if err != nil { + return err + } + + m.mtx.Lock() + defer m.mtx.Unlock() + + for _, a := range addrs { + if _, ok := m.keys[a]; ok { + return zuul.ErrKeyExists + } + if _, ok := m.tssKeys[a]; ok { + return zuul.ErrKeyExists + } + } + for _, a := range addrs { + m.tssKeys[a] = tnk + } + return nil +} + +// GetTSSKey returns the TSS key indexed under addr. If addr maps to +// a local private key (or nothing), ErrKeyDoesntExist is returned. +func (m *memoryZuul) GetTSSKey(addr btcutil.Address) (*zuul.TSSNamedKey, error) { + m.mtx.Lock() + defer m.mtx.Unlock() + + tnk, ok := m.tssKeys[addr.String()] + if !ok { + return nil, zuul.ErrKeyDoesntExist + } + return tnk, nil +} + +// PurgeTSSKey removes a TSS key. Because PutTSSKey indexes under +// multiple address forms, PurgeTSSKey recomputes all of them from +// the stored public key and removes every entry. +func (m *memoryZuul) PurgeTSSKey(addr btcutil.Address) error { + m.mtx.Lock() + defer m.mtx.Unlock() + + tnk, ok := m.tssKeys[addr.String()] + if !ok { + return zuul.ErrKeyDoesntExist + } + + pubBytes := tnk.PublicKey.SerializeCompressed() + addrs, err := ecdsaAddressesForPubKey(m.params, pubBytes) + if err != nil { + return fmt.Errorf("derive addresses: %w", err) + } + for _, a := range addrs { + delete(m.tssKeys, a) + } + return nil +} + +// LookupTSSKeyByAddr is the TSS counterpart to LookupKeyByAddr. It +// returns the TSS key and true when addr is TSS-controlled, or false +// with ErrKeyDoesntExist when addr is unknown or controlled by a +// local private key. +func (m *memoryZuul) LookupTSSKeyByAddr(addr btcutil.Address) (*zuul.TSSNamedKey, bool, error) { + m.mtx.Lock() + defer m.mtx.Unlock() + + tnk, ok := m.tssKeys[addr.String()] + if !ok { + return nil, false, zuul.ErrKeyDoesntExist + } + return tnk, true, nil +} diff --git a/bitcoin/wallet/zuul/memory/memory_tss_test.go b/bitcoin/wallet/zuul/memory/memory_tss_test.go new file mode 100644 index 000000000..dacd7e075 --- /dev/null +++ b/bitcoin/wallet/zuul/memory/memory_tss_test.go @@ -0,0 +1,252 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package memory + +import ( + "errors" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + + "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul" +) + +// TestPutTSSKeyIndexing verifies a TSS key enrolled under its +// aggregated pubkey is discoverable via the ECDSA-signable address +// forms (P2PKH, P2WPKH) and NOT via the taproot form. ECDSA +// signatures cannot satisfy a BIP-341 key-path spend, so the TSS +// address surface must exclude P2TR. +func TestPutTSSKeyIndexing(t *testing.T) { + params := &chaincfg.TestNet3Params + + m, err := New(params) + if err != nil { + t.Fatal(err) + } + + // Generate a pubkey to stand in for the aggregated TSS group key. + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pub := priv.PubKey() + + keyID := []byte("test-tss-key-id-0") + err = m.PutTSSKey(&zuul.TSSNamedKey{ + Name: "tss", + KeyID: keyID, + PublicKey: pub, + }) + if err != nil { + t.Fatalf("PutTSSKey: %v", err) + } + + pkHash := btcutil.Hash160(pub.SerializeCompressed()) + p2pkh, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + p2wpkh, err := btcutil.NewAddressWitnessPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + outputKey := txscript.ComputeTaprootKeyNoScript(pub) + p2tr, err := btcutil.NewAddressTaproot(schnorr.SerializePubKey(outputKey), params) + if err != nil { + t.Fatal(err) + } + + // Both P2PKH and P2WPKH must resolve to the TSS key. + for _, tc := range []struct { + name string + addr btcutil.Address + }{ + {"P2PKH", p2pkh}, + {"P2WPKH", p2wpkh}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := m.GetTSSKey(tc.addr) + if err != nil { + t.Fatalf("GetTSSKey: %v", err) + } + if string(got.KeyID) != string(keyID) { + t.Fatalf("keyID mismatch: got %x want %x", got.KeyID, keyID) + } + + tnk, ok, err := m.LookupTSSKeyByAddr(tc.addr) + if err != nil { + t.Fatal(err) + } + if !ok || tnk == nil { + t.Fatal("LookupTSSKeyByAddr: not found") + } + }) + } + + // P2TR must NOT resolve to a TSS key. ECDSA cannot sign a key-path + // taproot spend; exposing the TSS key under this address would be + // a soft footgun for anyone trying to send to that taproot output. + if _, err := m.GetTSSKey(p2tr); err == nil || !errors.Is(err, zuul.ErrKeyDoesntExist) { + t.Fatalf("GetTSSKey(p2tr): expected ErrKeyDoesntExist, got %v", err) + } + if _, ok, _ := m.LookupTSSKeyByAddr(p2tr); ok { + t.Fatal("LookupTSSKeyByAddr(p2tr): must not resolve") + } +} + +// TestPutTSSKeyPurgeRoundTrip verifies PurgeTSSKey removes every +// indexed address form in a single call. +func TestPutTSSKeyPurgeRoundTrip(t *testing.T) { + params := &chaincfg.TestNet3Params + m, err := New(params) + if err != nil { + t.Fatal(err) + } + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pub := priv.PubKey() + + err = m.PutTSSKey(&zuul.TSSNamedKey{ + Name: "purge-test", + KeyID: []byte("kid-purge"), + PublicKey: pub, + }) + if err != nil { + t.Fatal(err) + } + + pkHash := btcutil.Hash160(pub.SerializeCompressed()) + p2pkh, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + p2wpkh, err := btcutil.NewAddressWitnessPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + + // Purge via P2WPKH form should drop P2PKH too. + err = m.PurgeTSSKey(p2wpkh) + if err != nil { + t.Fatal(err) + } + + if _, err := m.GetTSSKey(p2pkh); err == nil || !errors.Is(err, zuul.ErrKeyDoesntExist) { + t.Fatalf("P2PKH still present after purge: %v", err) + } + if _, err := m.GetTSSKey(p2wpkh); err == nil || !errors.Is(err, zuul.ErrKeyDoesntExist) { + t.Fatalf("P2WPKH still present after purge: %v", err) + } +} + +// TestPutTSSKeyRequiresFields verifies that PutTSSKey rejects +// malformed inputs: nil key struct, nil public key, or zero-length +// keyID. +func TestPutTSSKeyRequiresFields(t *testing.T) { + m, err := New(&chaincfg.TestNet3Params) + if err != nil { + t.Fatal(err) + } + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + if err := m.PutTSSKey(nil); err == nil { + t.Fatal("nil TSSNamedKey: expected error") + } + if err := m.PutTSSKey(&zuul.TSSNamedKey{KeyID: []byte("k")}); err == nil { + t.Fatal("missing PublicKey: expected error") + } + if err := m.PutTSSKey(&zuul.TSSNamedKey{PublicKey: priv.PubKey()}); err == nil { + t.Fatal("missing KeyID: expected error") + } +} + +// TestPutKeyVsPutTSSKeyCollision verifies that a local private key +// and a TSS key cannot claim the same address: attempting to enrol +// one when the other already holds that address must fail with +// ErrKeyExists. +func TestPutKeyVsPutTSSKeyCollision(t *testing.T) { + m, err := New(&chaincfg.TestNet3Params) + if err != nil { + t.Fatal(err) + } + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + // Enrol as local first; TSS enrolment for the same pubkey must + // then fail because the P2PKH/P2WPKH slots are taken. + if err := m.PutKey(&zuul.NamedKey{Name: "local", PrivateKey: priv}); err != nil { + t.Fatal(err) + } + err = m.PutTSSKey(&zuul.TSSNamedKey{ + Name: "tss", + KeyID: []byte("kid"), + PublicKey: priv.PubKey(), + }) + if err == nil || !errors.Is(err, zuul.ErrKeyExists) { + t.Fatalf("expected ErrKeyExists for TSS after local, got %v", err) + } + + // Reverse direction: fresh zuul, TSS first, local second. + m2, err := New(&chaincfg.TestNet3Params) + if err != nil { + t.Fatal(err) + } + priv2, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + err = m2.PutTSSKey(&zuul.TSSNamedKey{ + Name: "tss", + KeyID: []byte("kid2"), + PublicKey: priv2.PubKey(), + }) + if err != nil { + t.Fatal(err) + } + err = m2.PutKey(&zuul.NamedKey{Name: "local", PrivateKey: priv2}) + if err == nil || !errors.Is(err, zuul.ErrKeyExists) { + t.Fatalf("expected ErrKeyExists for local after TSS, got %v", err) + } +} + +// TestPurgeTSSKeyUnknownAddress verifies PurgeTSSKey returns +// ErrKeyDoesntExist when asked to purge a TSS address that was +// never enrolled. +func TestPurgeTSSKeyUnknownAddress(t *testing.T) { + params := &chaincfg.TestNet3Params + m, err := New(params) + if err != nil { + t.Fatal(err) + } + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + + err = m.PurgeTSSKey(addr) + if err == nil || !errors.Is(err, zuul.ErrKeyDoesntExist) { + t.Fatalf("PurgeTSSKey on unknown addr: expected ErrKeyDoesntExist, got %v", err) + } +} diff --git a/bitcoin/wallet/zuul/zuul.go b/bitcoin/wallet/zuul/zuul.go index d16859b8f..e3277f691 100644 --- a/bitcoin/wallet/zuul/zuul.go +++ b/bitcoin/wallet/zuul/zuul.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Hemi Labs, Inc. +// Copyright (c) 2025-2026 Hemi Labs, Inc. // Use of this source code is governed by the MIT License, // which can be found in the LICENSE file. @@ -40,10 +40,41 @@ type NamedKey struct { PrivateKey *dcrsecpk256k1.PrivateKey } +// TSSNamedKey represents a key controlled by an external threshold +// signature scheme. The private key material is sharded across a +// committee and never materialises on this host; this struct carries +// only the aggregated group public key and a keyID that external +// signers use to identify which distributed key to sign with. +// +// The keyID is opaque to the wallet. Its format is determined by the +// TSS system that produced the key at keygen time and callers must +// not interpret its contents. The wallet only stores and forwards +// it verbatim when asking the coordinator to sign. +// +// Signing a TSS input is a two-step dance: the wallet assembles the +// unsigned transaction, computes the sighash for the TSS input, and +// hands the sighash and keyID to the TSS coordinator. The coordinator +// returns a signature which the wallet injects into the witness or +// signature script via TransactionApplyECDSA or TransactionApplySchnorr. +type TSSNamedKey struct { + Name string // User defined name + KeyID []byte // External TSS key identifier + PublicKey *btcec.PublicKey // Aggregated group public key +} + // Zuul is an interface for storing secret material. type Zuul interface { PutKey(nk *NamedKey) error GetKey(addr btcutil.Address) (*NamedKey, error) PurgeKey(addr btcutil.Address) error LookupKeyByAddr(addr btcutil.Address) (*btcec.PrivateKey, bool, error) // signing lookup + + // TSS key enrolment and lookup. A TSSNamedKey carries no private + // material; signing happens externally via the TSS committee and + // the produced signature is applied to the transaction through + // the wallet's external-signature entry points. + PutTSSKey(tnk *TSSNamedKey) error + GetTSSKey(addr btcutil.Address) (*TSSNamedKey, error) + PurgeTSSKey(addr btcutil.Address) error + LookupTSSKeyByAddr(addr btcutil.Address) (*TSSNamedKey, bool, error) } From f77cab43dacb88b679723428708c109387ace7f1 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Fri, 17 Apr 2026 04:54:27 -0700 Subject: [PATCH 15/25] feat(wallet): apply external ecdsa signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TransactionApplyECDSA wires a pre-computed DER-encoded ECDSA signature into a specific transaction input. The signature is produced out of band — by a hardware wallet, a PSBT flow, or a threshold signature committee — and the wallet only handles the witness or sigScript assembly. P2PKH inputs receive the standard two-push SignatureScript ( ). P2WPKH inputs receive a two-element witness stack ([sig||hashType, pubKey]). Other script classes are rejected with an explicit error: P2TR requires schnorr, and wrapped-segwit/P2WSH variants are not yet supported. Before applying, the function cross-checks the provided pubkey against the address embedded in the prev pkScript. A mismatch would produce a transaction the network rejects on broadcast; catching it at injection time surfaces the bug at the caller. ECDSASigFromRS assembles a DER signature from raw big-endian r and s scalar bytes, the shape commonly emitted by ECDSA TSS signing libraries. The helper rejects empty scalars, zero scalars, and scalars that overflow the secp256k1 group order. Low-S normalisation (BIP-146) is performed implicitly by Signature.Serialize, so high-S TSS output is accepted by the helper and emitted as low-S. Tests exercise: round-trip verify of assembled DER signatures; rejection of bad scalars; low-S normalisation of a high-S input; end-to-end P2PKH injection with script-engine verification; end-to-end P2WPKH injection with BIP-143 sighash and engine verification; wrong-pubkey rejection; and unsupported-script-class (P2TR) rejection. PoP and existing signing tests pass unchanged. --- bitcoin/wallet/external_sign.go | 226 +++++++++ bitcoin/wallet/external_sign_test.go | 695 +++++++++++++++++++++++++++ 2 files changed, 921 insertions(+) create mode 100644 bitcoin/wallet/external_sign.go create mode 100644 bitcoin/wallet/external_sign_test.go diff --git a/bitcoin/wallet/external_sign.go b/bitcoin/wallet/external_sign.go new file mode 100644 index 000000000..cb8eb9777 --- /dev/null +++ b/bitcoin/wallet/external_sign.go @@ -0,0 +1,226 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package wallet + +import ( + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/decred/dcrd/dcrec/secp256k1/v4" +) + +// maxECDSASigDERLen is a generous upper bound on a DER-encoded +// ECDSA signature over secp256k1. The real maximum is 72 bytes +// (0x30 0x02 0x02 with leading +// zero bytes for r and s when their high bit is set). The cap +// gives headroom for encodings we might not have seen while still +// rejecting attacker-controlled buffers that would otherwise be +// copied in full before the DER parser runs. +const maxECDSASigDERLen = 128 + +// ECDSASigFromRS assembles a DER-encoded ECDSA signature from raw +// big-endian r and s scalar bytes as produced by many threshold +// signature libraries (including hemilabs/x/tss-lib/v3). The +// signature is normalised to low-S form per BIP-146 before encoding; +// a high-S input is implicitly negated by Serialize so the encoded +// signature is accepted by Bitcoin consensus rules. +// +// Each of r and s is interpreted as a big-endian unsigned integer +// modulo the secp256k1 group order. A zero scalar or a scalar at +// or above the group order is rejected as invalid. +func ECDSASigFromRS(r, s []byte) ([]byte, error) { + if len(r) == 0 || len(s) == 0 { + return nil, fmt.Errorf("empty scalar") + } + + var rs, ss secp256k1.ModNScalar + if overflow := rs.SetByteSlice(r); overflow { + return nil, fmt.Errorf("r overflows group order") + } + if rs.IsZero() { + return nil, fmt.Errorf("r is zero") + } + if overflow := ss.SetByteSlice(s); overflow { + return nil, fmt.Errorf("s overflows group order") + } + if ss.IsZero() { + return nil, fmt.Errorf("s is zero") + } + + sig := ecdsa.NewSignature(&rs, &ss) + return sig.Serialize(), nil +} + +// TransactionApplyECDSA applies an externally-computed ECDSA signature +// to a single transaction input. Use this to inject a signature +// produced by an out-of-band signer — a hardware wallet, PSBT flow, or +// a TSS committee — that the wallet cannot sign locally. +// +// sigDER is the DER-encoded signature (without the trailing sighash +// byte); this function appends hashType. pubKey is the public key +// that produced the signature; for TSS this is the aggregated group +// pubkey. The function validates that pubKey matches the address +// encoded in prev.PkScript before applying the signature. +// +// For P2PKH inputs, the result is a SignatureScript of the form +// +// +// +// For P2WPKH inputs, the result is a two-element witness stack +// +// [ sigDER||hashType , pubKeyCompressed ] +// +// Other script classes are rejected. P2TR inputs require schnorr +// signatures — use TransactionApplySchnorr. P2SH-P2WPKH and P2WSH +// are not yet supported. +// +// This function does not verify the signature cryptographically. +// It parses sigDER as DER (catching gross encoding errors) and +// cross-checks pubKey against the address, but never confirms that +// sigDER actually validates over the transaction's sighash. +// +// SECURITY: callers accepting signatures from an untrusted source +// (an unauthenticated RPC, a network path the attacker might +// intercept, a TSS coordinator the wallet cannot authenticate) +// MUST call VerifyECDSA with the correct sighash before calling +// this function. A malformed signature injected here produces a +// transaction that fails script verification — the network +// rejects it, so no funds are at risk, but downstream components +// that trust a nil return from Apply as "transaction is valid" +// would be misled. Verification is not done here because the +// sighash depends on the full PrevOuts map which Apply does not +// receive; the caller already has it. +func TransactionApplyECDSA(params *chaincfg.Params, tx *wire.MsgTx, idx int, prev *wire.TxOut, pubKey *btcec.PublicKey, sigDER []byte, hashType txscript.SigHashType) error { + if tx == nil || prev == nil || pubKey == nil { + return fmt.Errorf("nil argument") + } + if idx < 0 || idx >= len(tx.TxIn) { + return fmt.Errorf("input index %d out of range (tx has %d inputs)", + idx, len(tx.TxIn)) + } + if len(sigDER) == 0 { + return fmt.Errorf("empty signature") + } + if len(sigDER) > maxECDSASigDERLen { + return fmt.Errorf("signature exceeds max DER length: got %d, max %d", + len(sigDER), maxECDSASigDERLen) + } + if err := validateSigHashType(hashType); err != nil { + return err + } + + // Sanity check: ensure sigDER is parseable DER. This catches + // gross encoding errors early; full cryptographic verification + // is a caller choice via VerifyECDSA. + if _, err := ecdsa.ParseDERSignature(sigDER); err != nil { + return fmt.Errorf("parse signature: %w", err) + } + + pubCompressed := pubKey.SerializeCompressed() + + // Attach sighash byte for script-engine consumption. + sigWithHash := append([]byte{}, sigDER...) + sigWithHash = append(sigWithHash, byte(hashType)) + + class := txscript.GetScriptClass(prev.PkScript) + switch class { + case txscript.PubKeyHashTy: + return applyECDSAP2PKH(params, tx, idx, prev, sigWithHash, pubCompressed) + case txscript.WitnessV0PubKeyHashTy: + return applyECDSAP2WPKH(params, tx, idx, prev, sigWithHash, pubCompressed) + default: + return fmt.Errorf("unsupported script class for ECDSA: %v", class) + } +} + +// applyECDSAP2PKH writes a P2PKH SignatureScript of the form +// and clears any witness. Cross-checks +// pubCompressed against the address encoded in prev.PkScript. +func applyECDSAP2PKH(params *chaincfg.Params, tx *wire.MsgTx, idx int, prev *wire.TxOut, sigWithHash, pubCompressed []byte) error { + if err := pubKeyMatchesAddress(params, prev.PkScript, pubCompressed, false); err != nil { + return fmt.Errorf("p2pkh: %w", err) + } + script, err := txscript.NewScriptBuilder(). + AddData(sigWithHash). + AddData(pubCompressed). + Script() + if err != nil { + return fmt.Errorf("build sigScript: %w", err) + } + tx.TxIn[idx].SignatureScript = script + tx.TxIn[idx].Witness = nil + return nil +} + +// applyECDSAP2WPKH writes a P2WPKH two-element witness stack +// [sigWithHash, pubCompressed] and clears any SignatureScript. +// Cross-checks pubCompressed against the address encoded in +// prev.PkScript. +func applyECDSAP2WPKH(params *chaincfg.Params, tx *wire.MsgTx, idx int, prev *wire.TxOut, sigWithHash, pubCompressed []byte) error { + if err := pubKeyMatchesAddress(params, prev.PkScript, pubCompressed, true); err != nil { + return fmt.Errorf("p2wpkh: %w", err) + } + tx.TxIn[idx].Witness = wire.TxWitness{sigWithHash, pubCompressed} + tx.TxIn[idx].SignatureScript = nil + return nil +} + +// pubKeyMatchesAddress verifies that pubCompressed derives to the +// address embedded in pkScript. For P2PKH and P2WPKH the derived +// pubkey hash (HASH160) must equal the 20-byte hash in the script; +// both forms share the same derivation so one helper covers both. +// segwit selects whether the comparison uses a P2WPKH or P2PKH +// derivation (they match structurally but differ in script wrapping, +// and we use ExtractPkScriptAddrs so params-dependent HRP matches). +func pubKeyMatchesAddress(params *chaincfg.Params, pkScript, pubCompressed []byte, segwit bool) error { + _, addrs, _, err := txscript.ExtractPkScriptAddrs(pkScript, params) + if err != nil { + return fmt.Errorf("extract address: %w", err) + } + if len(addrs) != 1 { + return fmt.Errorf("pkScript extracted %d addresses, expected 1", len(addrs)) + } + + pkHash := btcutil.Hash160(pubCompressed) + var want btcutil.Address + if segwit { + want, err = btcutil.NewAddressWitnessPubKeyHash(pkHash, params) + } else { + want, err = btcutil.NewAddressPubKeyHash(pkHash, params) + } + if err != nil { + return fmt.Errorf("derive address: %w", err) + } + + if addrs[0].EncodeAddress() != want.EncodeAddress() { + return fmt.Errorf("public key does not match address") + } + return nil +} + +// validateSigHashType verifies hashType is one of the standard +// Bitcoin sighash values and fits in a single byte. Silent +// narrowing of txscript.SigHashType (uint32) to byte would convert +// an attacker-controlled 0xFFFF_FF01 to 0x01 (SigHashAll) and embed +// the wrong sighash semantics in the witness. Reject anything +// unrecognised rather than trusting the low byte. +func validateSigHashType(hashType txscript.SigHashType) error { + switch hashType { + case txscript.SigHashDefault, + txscript.SigHashAll, + txscript.SigHashNone, + txscript.SigHashSingle, + txscript.SigHashAll | txscript.SigHashAnyOneCanPay, + txscript.SigHashNone | txscript.SigHashAnyOneCanPay, + txscript.SigHashSingle | txscript.SigHashAnyOneCanPay: + return nil + } + return fmt.Errorf("invalid sighash type %#x", uint32(hashType)) +} diff --git a/bitcoin/wallet/external_sign_test.go b/bitcoin/wallet/external_sign_test.go new file mode 100644 index 000000000..151bb19a3 --- /dev/null +++ b/bitcoin/wallet/external_sign_test.go @@ -0,0 +1,695 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package wallet + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/decred/dcrd/dcrec/secp256k1/v4" +) + +// TestECDSASigFromRSValid verifies the r/s assembly helper produces +// DER output that round-trips through ParseDERSignature. This is +// the shape tss-lib returns signatures in. +func TestECDSASigFromRSValid(t *testing.T) { + // Use a known signature: sign a hash with a throwaway key, then + // split into r and s to round-trip. + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + hash := chainhash.HashB([]byte("tss-regression-payload")) + sig := ecdsa.Sign(priv, hash) + + r := sig.R() + s := sig.S() + rBytes := r.Bytes() + sBytes := s.Bytes() + + // Assemble via the helper. + der, err := ECDSASigFromRS(rBytes[:], sBytes[:]) + if err != nil { + t.Fatalf("ECDSASigFromRS: %v", err) + } + + // Parsed sig must verify against the original pubkey and hash. + parsed, err := ecdsa.ParseDERSignature(der) + if err != nil { + t.Fatalf("ParseDERSignature: %v", err) + } + if !parsed.Verify(hash, priv.PubKey()) { + t.Fatal("assembled DER signature failed to verify") + } +} + +// TestECDSASigFromRSRejectsBad verifies the helper refuses empty or +// overflow scalars. +func TestECDSASigFromRSRejectsBad(t *testing.T) { + cases := []struct { + name string + r, s []byte + }{ + {"empty r", nil, []byte{1}}, + {"empty s", []byte{1}, nil}, + {"zero r", make([]byte, 32), []byte{1}}, + {"zero s", []byte{1}, make([]byte, 32)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := ECDSASigFromRS(tc.r, tc.s); err == nil { + t.Fatalf("expected error") + } + }) + } +} + +// TestECDSASigFromRSLowSNormalization verifies the helper emits a +// low-S signature even when the input s is above N/2. Bitcoin +// consensus rejects high-S signatures under ScriptVerifyLowS; any +// TSS producing high-S output would otherwise be unspendable. +func TestECDSASigFromRSLowSNormalization(t *testing.T) { + // Build a scalar deliberately above N/2. The curve order N's + // upper half starts at (N+1)/2. We construct s = N-1 which is + // always > N/2. + var nMinus1 secp256k1.ModNScalar + nMinus1.SetInt(1).Negate() // N - 1 + + if !nMinus1.IsOverHalfOrder() { + t.Fatal("test setup: N-1 should be over half order") + } + + rBytes := []byte{0x42} + sFull := nMinus1.Bytes() + der, err := ECDSASigFromRS(rBytes, sFull[:]) + if err != nil { + t.Fatalf("ECDSASigFromRS: %v", err) + } + + parsed, err := ecdsa.ParseDERSignature(der) + if err != nil { + t.Fatalf("ParseDERSignature: %v", err) + } + parsedS := parsed.S() + if parsedS.IsOverHalfOrder() { + t.Fatal("serialized signature is not low-S") + } +} + +// signWithKeyToDER signs hash with priv and returns a DER-encoded +// signature (no sighash byte). Used by injection tests to simulate +// what a TSS coordinator would hand back. +func signWithKeyToDER(priv *btcec.PrivateKey, hash []byte) []byte { + sig := ecdsa.Sign(priv, hash) + return sig.Serialize() +} + +// TestTransactionApplyECDSAP2PKH exercises the P2PKH injection path +// end-to-end: build a tx with a P2PKH input, compute its sighash, +// sign externally with a known key, inject via TransactionApplyECDSA, +// and confirm the script engine accepts the result. +func TestTransactionApplyECDSAP2PKH(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pubCompressed := priv.PubKey().SerializeCompressed() + pkHash := btcutil.Hash160(pubCompressed) + addr, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("external-p2pkh-funding-00000000")) + op := wire.NewOutPoint(&fundHash, 0) + const fundValue int64 = 100_000 + + destPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + destHash := btcutil.Hash160(destPriv.PubKey().SerializeCompressed()) + destAddr, err := btcutil.NewAddressPubKeyHash(destHash, params) + if err != nil { + t.Fatal(err) + } + destScript, err := txscript.PayToAddrScript(destAddr) + if err != nil { + t.Fatal(err) + } + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + tx.AddTxOut(wire.NewTxOut(fundValue/2, destScript)) + + prev := wire.NewTxOut(fundValue, pkScript) + prevOuts := PrevOuts{op.String(): prev} + + // Compute sighash the way the P2PKH path does. + sigHash, err := txscript.CalcSignatureHash(pkScript, + txscript.SigHashAll, tx, 0) + if err != nil { + t.Fatal(err) + } + + sigDER := signWithKeyToDER(priv, sigHash) + err = TransactionApplyECDSA(params, tx, 0, prev, priv.PubKey(), + sigDER, txscript.SigHashAll) + if err != nil { + t.Fatalf("TransactionApplyECDSA: %v", err) + } + + if len(tx.TxIn[0].SignatureScript) == 0 { + t.Fatal("SignatureScript not set") + } + if len(tx.TxIn[0].Witness) != 0 { + t.Fatalf("Witness unexpectedly set for P2PKH: %d elements", + len(tx.TxIn[0].Witness)) + } + + if err := verifyInput(tx, 0, prevOuts); err != nil { + t.Fatalf("engine rejected injected P2PKH signature: %v", err) + } +} + +// TestTransactionApplyECDSAP2WPKH exercises the P2WPKH injection path +// end-to-end. BIP-143 sighash is used, matching what a TSS +// coordinator would sign over for a segwit input. +func TestTransactionApplyECDSAP2WPKH(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pubCompressed := priv.PubKey().SerializeCompressed() + pkHash := btcutil.Hash160(pubCompressed) + addr, err := btcutil.NewAddressWitnessPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("external-p2wpkh-funding-0000000")) + op := wire.NewOutPoint(&fundHash, 0) + const fundValue int64 = 100_000 + + destPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + destHash := btcutil.Hash160(destPriv.PubKey().SerializeCompressed()) + destAddr, err := btcutil.NewAddressPubKeyHash(destHash, params) + if err != nil { + t.Fatal(err) + } + destScript, err := txscript.PayToAddrScript(destAddr) + if err != nil { + t.Fatal(err) + } + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + tx.AddTxOut(wire.NewTxOut(fundValue/2, destScript)) + + prev := wire.NewTxOut(fundValue, pkScript) + prevOuts := PrevOuts{op.String(): prev} + + // BIP-143 sighash for the P2WPKH input. + fetcher := prevOutsFetcher(prevOuts) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + sigHash, err := txscript.CalcWitnessSigHash(pkScript, sigHashes, + txscript.SigHashAll, tx, 0, fundValue) + if err != nil { + t.Fatal(err) + } + + sigDER := signWithKeyToDER(priv, sigHash) + err = TransactionApplyECDSA(params, tx, 0, prev, priv.PubKey(), + sigDER, txscript.SigHashAll) + if err != nil { + t.Fatalf("TransactionApplyECDSA: %v", err) + } + + if len(tx.TxIn[0].Witness) != 2 { + t.Fatalf("witness wrong length: got %d, want 2", + len(tx.TxIn[0].Witness)) + } + if len(tx.TxIn[0].SignatureScript) != 0 { + t.Fatal("SignatureScript unexpectedly set for P2WPKH") + } + if !bytes.Equal(tx.TxIn[0].Witness[1], pubCompressed) { + t.Fatal("witness[1] does not match provided pubkey") + } + + if err := verifyInput(tx, 0, prevOuts); err != nil { + t.Fatalf("engine rejected injected P2WPKH signature: %v", err) + } +} + +// TestTransactionApplyECDSAWrongKey verifies the function rejects a +// pubkey that doesn't match the prev pkScript's address. Without this +// check a caller could build an unspendable transaction at runtime. +func TestTransactionApplyECDSAWrongKey(t *testing.T) { + params := &chaincfg.TestNet3Params + + ownerPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + wrongPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + pkHash := btcutil.Hash160(ownerPriv.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("wrong-key-funding-0000000000000")) + op := wire.NewOutPoint(&fundHash, 0) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + tx.AddTxOut(wire.NewTxOut(50_000, pkScript)) + + prev := wire.NewTxOut(100_000, pkScript) + + sigDER := signWithKeyToDER(wrongPriv, chainhash.HashB([]byte("anything"))) + err = TransactionApplyECDSA(params, tx, 0, prev, wrongPriv.PubKey(), + sigDER, txscript.SigHashAll) + if err == nil { + t.Fatal("expected address-mismatch error") + } +} + +// TestTransactionApplyECDSARejectsP2TR verifies the ECDSA injection +// path refuses P2TR inputs. Taproot requires schnorr signatures; +// attempting to wire an ECDSA signature into a taproot witness would +// produce an unspendable transaction and warrants an explicit error. +func TestTransactionApplyECDSARejectsP2TR(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + addr, err := btcutil.NewAddressTaproot( + outputKey.SerializeCompressed()[1:], params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("reject-p2tr-funding-00000000000")) + op := wire.NewOutPoint(&fundHash, 0) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + tx.AddTxOut(wire.NewTxOut(50_000, pkScript)) + + prev := wire.NewTxOut(100_000, pkScript) + + sigDER := signWithKeyToDER(priv, chainhash.HashB([]byte("anything"))) + err = TransactionApplyECDSA(params, tx, 0, prev, priv.PubKey(), + sigDER, txscript.SigHashDefault) + if err == nil { + t.Fatal("expected unsupported-script-class error for P2TR") + } +} + +// TestECDSASigFromRSRejectsOverflow verifies the helper refuses +// scalars that equal or exceed the secp256k1 group order. An +// overflow scalar cannot be reduced mod N without losing +// information, so the helper must return an error rather than +// silently wrap around. +func TestECDSASigFromRSRejectsOverflow(t *testing.T) { + // N for secp256k1 is + // 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141. + // Any scalar >= N triggers the overflow branch in + // ModNScalar.SetByteSlice. + n := []byte{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, + 0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, + 0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x41, + } + // Above N: N itself, N+1, and all-0xff. + allFF := make([]byte, 32) + for i := range allFF { + allFF[i] = 0xff + } + nPlus1 := append([]byte{}, n...) + nPlus1[31]++ + + cases := []struct { + name string + r, s []byte + }{ + {"r equals N", n, []byte{1}}, + {"s equals N", []byte{1}, n}, + {"r equals N+1", nPlus1, []byte{1}}, + {"s equals N+1", []byte{1}, nPlus1}, + {"r all-0xff", allFF, []byte{1}}, + {"s all-0xff", []byte{1}, allFF}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := ECDSASigFromRS(tc.r, tc.s); err == nil { + t.Fatalf("expected overflow error") + } + }) + } +} + +// FuzzECDSASigFromRS exercises the r/s assembly helper with random +// byte slices. The helper must never panic regardless of input: +// every error path returns a Go error rather than crashing. This +// is Günther's fuzz-required rule — anything that parses input +// gets fuzzed. +func FuzzECDSASigFromRS(f *testing.F) { + f.Add([]byte{}, []byte{}) + f.Add([]byte{0x01}, []byte{0x01}) + f.Add(make([]byte, 32), make([]byte, 32)) // zero-zero + f.Add(make([]byte, 33), make([]byte, 33)) // oversize + f.Add(bytes.Repeat([]byte{0xff}, 32), bytes.Repeat([]byte{0xff}, 32)) + + f.Fuzz(func(t *testing.T, r, s []byte) { + // Must not panic for any input. Errors are fine, crashes + // are not. If the helper returns a signature, it must + // round-trip through ParseDERSignature — anything the + // helper emits must be valid DER. + sig, err := ECDSASigFromRS(r, s) + if err != nil { + return + } + if _, perr := ecdsa.ParseDERSignature(sig); perr != nil { + t.Fatalf("helper emitted unparseable DER: %v", perr) + } + }) +} + +// TestTransactionApplyECDSARejectsIdxOutOfRange verifies the function +// refuses negative or out-of-bounds input indices before mutating +// the transaction. Buggy callers passing an index computed from a +// stale copy would otherwise panic on tx.TxIn[idx]. +func TestTransactionApplyECDSARejectsIdxOutOfRange(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("idx-oor-funding-00000000000000")) + op := wire.NewOutPoint(&fundHash, 0) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + + prev := wire.NewTxOut(50_000, pkScript) + sigDER := signWithKeyToDER(priv, chainhash.HashB([]byte("x"))) + + cases := []struct { + name string + idx int + }{ + {"negative", -1}, + {"past end", 1}, + {"far overshoot", 99}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := TransactionApplyECDSA(params, tx, tc.idx, prev, priv.PubKey(), + sigDER, txscript.SigHashAll); err == nil { + t.Fatalf("idx=%d: expected out-of-range error", tc.idx) + } + }) + } +} + +// TestTransactionApplyECDSARejectsEmptyAndMalformedSig verifies the +// guards catch zero-length DER and non-DER bytes before reaching +// the script builder. A caller whose TSS coordinator returned a +// buffer of the wrong shape would otherwise embed unspendable +// garbage into the witness. +func TestTransactionApplyECDSARejectsEmptyAndMalformedSig(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("bad-sig-funding-00000000000000")) + op := wire.NewOutPoint(&fundHash, 0) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + + prev := wire.NewTxOut(50_000, pkScript) + + cases := []struct { + name string + sigDER []byte + }{ + {"nil", nil}, + {"empty", []byte{}}, + // Plausible-looking but invalid DER: 0x30 len marker with garbage. + {"malformed DER", []byte{0x30, 0x02, 0xff, 0xff}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := TransactionApplyECDSA(params, tx, 0, prev, priv.PubKey(), + tc.sigDER, txscript.SigHashAll); err == nil { + t.Fatalf("expected error for %s sigDER", tc.name) + } + }) + } +} + +// TestTransactionApplyECDSARejectsNilArgs verifies the nil-guard +// refuses tx, prev, or pubKey = nil without panicking on deref. +func TestTransactionApplyECDSARejectsNilArgs(t *testing.T) { + params := &chaincfg.TestNet3Params + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + tx := wire.NewMsgTx(2) + prev := wire.NewTxOut(1, []byte{txscript.OP_DUP}) + sigDER := signWithKeyToDER(priv, chainhash.HashB([]byte("x"))) + + cases := []struct { + name string + tx *wire.MsgTx + prev *wire.TxOut + pubKey *btcec.PublicKey + }{ + {"nil tx", nil, prev, priv.PubKey()}, + {"nil prev", tx, nil, priv.PubKey()}, + {"nil pubKey", tx, prev, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := TransactionApplyECDSA(params, tc.tx, 0, tc.prev, tc.pubKey, + sigDER, txscript.SigHashAll); err == nil { + t.Fatalf("%s: expected nil-argument error", tc.name) + } + }) + } +} + +// TestTransactionApplyECDSARejectsBadSigHashType verifies that the +// hashType argument is rejected if it is not a standard sighash +// value. Silent byte() truncation of uint32 would otherwise let +// an attacker-controlled 0xFFFF_FF01 masquerade as SigHashAll. +func TestTransactionApplyECDSARejectsBadSigHashType(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("bad-sighash-funding-0000000")) + op := wire.NewOutPoint(&fundHash, 0) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + + prev := wire.NewTxOut(50_000, pkScript) + sigDER := signWithKeyToDER(priv, chainhash.HashB([]byte("x"))) + + cases := []struct { + name string + hashType txscript.SigHashType + }{ + // Smuggles low-byte 0x01 (SigHashAll) inside a uint32 that + // doesn't match any standard sighash. + {"high-byte smuggle to SigHashAll", txscript.SigHashType(0xFFFFFF01)}, + // 0x04 is below the anyonecanpay bit but not a defined value. + {"undefined low byte 0x04", txscript.SigHashType(0x04)}, + // AnyoneCanPay with no base sighash is ambiguous. + {"bare AnyoneCanPay", txscript.SigHashAnyOneCanPay}, + // Truly exotic. + {"0xDEADBEEF", txscript.SigHashType(0xDEADBEEF)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := TransactionApplyECDSA(params, tx, 0, prev, priv.PubKey(), + sigDER, tc.hashType) + if err == nil { + t.Fatalf("hashType=%#x: expected error, got nil", + uint32(tc.hashType)) + } + }) + } +} + +// TestTransactionApplyECDSARejectsOversizedSig verifies the length +// cap catches attacker-controlled sigDER buffers before the full +// copy into sigWithHash. Without the cap, a 1MB sigDER would be +// copied in full before the DER parser ran; the cap short-circuits +// that allocation path. +func TestTransactionApplyECDSARejectsOversizedSig(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("oversized-sig-funding-000000")) + op := wire.NewOutPoint(&fundHash, 0) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + + prev := wire.NewTxOut(50_000, pkScript) + + // Start with a valid DER signature and pad past the cap. + validDER := signWithKeyToDER(priv, chainhash.HashB([]byte("x"))) + oversized := make([]byte, 4096) + copy(oversized, validDER) + + err = TransactionApplyECDSA(params, tx, 0, prev, priv.PubKey(), + oversized, txscript.SigHashAll) + if err == nil { + t.Fatal("oversized sigDER: expected length-cap error") + } +} + +// TestTransactionApplyECDSAAcceptsUnverifiedSig documents the +// deliberate design: Apply checks DER structure and address +// binding, but does NOT cryptographically verify the signature. +// A structurally-valid DER signed over the wrong sighash is +// injected successfully by Apply, then rejected by the script +// engine on broadcast. This separation lets callers drive TSS +// replay or debugging flows without re-signing. Callers that +// want the crypto check must call VerifyECDSA first (see +// TestVerifyECDSAGatesTransactionApplyECDSA). +func TestTransactionApplyECDSAAcceptsUnverifiedSig(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("unverified-sig-funding-00000")) + op := wire.NewOutPoint(&fundHash, 0) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + tx.AddTxOut(wire.NewTxOut(25_000, pkScript)) + + prev := wire.NewTxOut(50_000, pkScript) + prevOuts := PrevOuts{op.String(): prev} + + // Sign an UNRELATED message, not the tx's sighash. DER is + // structurally valid, matches the claimed pubkey, but won't + // verify against the real sighash the engine computes. + unrelatedDER := signWithKeyToDER(priv, chainhash.HashB([]byte("not the sighash"))) + + // Apply accepts it — it only checks structure + address binding. + err = TransactionApplyECDSA(params, tx, 0, prev, priv.PubKey(), + unrelatedDER, txscript.SigHashAll) + if err != nil { + t.Fatalf("Apply rejected a structurally-valid signature: %v", err) + } + + // Engine rejects it — the network guards the real correctness. + if err := verifyInput(tx, 0, prevOuts); err == nil { + t.Fatal("engine accepted a signature over the wrong message") + } +} From d0d7ffeab55279dcb4c545145aad1847b67d731f Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Fri, 17 Apr 2026 04:56:26 -0700 Subject: [PATCH 16/25] feat(wallet): apply external schnorr signature TransactionApplySchnorr wires a pre-computed 64-byte BIP-340 schnorr signature into a P2TR key-path input. The sibling of TransactionApplyECDSA, this is the injection path for schnorr threshold signature schemes (MuSig2, FROST, schnorr-TSS) that produce aggregated signatures the wallet cannot sign locally. pubKey is the untweaked internal key; the function applies the BIP-86 tweak via ComputeTaprootKeyNoScript and cross-checks the tweaked x-only output key against the witness program in the prev pkScript. A mismatch is reported before the transaction is mutated. For SigHashDefault (the common case) the witness stack is the bare 64-byte signature per BIP-341. Any other sighash type appends the single sighash byte. schnorr.ParseSignature is called on the input sig as a cheap structural check: it validates the 64-byte length and catches grossly malformed encodings. On-curve validity of R.x is only checked during actual Verify, so callers wanting a real cryptographic check before broadcast should use VerifySchnorr. Only BIP-86 key-path spends are supported. Script-path taproot spends require a committed leaf script and a control block; those inputs must be assembled by the caller. Tests cover: round-trip sign + inject + engine verification on a real P2TR input; structural rejection (wrong length, nil pubkey, empty signature); wrong-key rejection via tweak mismatch; and unsupported-script-class (P2PKH) rejection. PoP regression tests continue to pass. --- bitcoin/wallet/external_sign_schnorr.go | 120 +++++ bitcoin/wallet/external_sign_schnorr_test.go | 454 +++++++++++++++++++ 2 files changed, 574 insertions(+) create mode 100644 bitcoin/wallet/external_sign_schnorr.go create mode 100644 bitcoin/wallet/external_sign_schnorr_test.go diff --git a/bitcoin/wallet/external_sign_schnorr.go b/bitcoin/wallet/external_sign_schnorr.go new file mode 100644 index 000000000..69ece21ce --- /dev/null +++ b/bitcoin/wallet/external_sign_schnorr.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package wallet + +import ( + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" +) + +// TransactionApplySchnorr applies an externally-computed schnorr +// signature to a P2TR key-path input. Use this to inject a signature +// produced by a schnorr threshold signature scheme (MuSig2, FROST, or +// a schnorr-TSS coordinator) that the wallet cannot sign locally. +// +// params select the network HRP used when rendering addresses for +// the pkScript cross-check. sig64 is the 64-byte schnorr signature +// per BIP-340. pubKey is the internal key that produced the +// signature before the taproot tweak; the function computes the +// tweaked output key via ComputeTaprootKeyNoScript and cross-checks +// it against the taproot address decoded from prev.PkScript. +// +// For SigHashDefault (the common case), the witness stack is +// +// [ sig64 ] +// +// For any other sighash type, the single sighash byte is appended per +// BIP-341. +// +// Only BIP-86 key-path spends are supported through this entry point. +// Script-path spends require additional data (the committed leaf +// script and a control block) and must be assembled by the caller. +// +// This function does not verify the signature cryptographically. +// It enforces BIP-340 structural parsing via schnorr.ParseSignature +// and cross-checks the BIP-86-tweaked pubKey against the address +// but never confirms that sig64 actually validates over the +// transaction's BIP-341 sighash. +// +// SECURITY: callers accepting signatures from an untrusted source +// (an unauthenticated RPC, a network path the attacker might +// intercept, a schnorr-TSS coordinator the wallet cannot +// authenticate) MUST call VerifySchnorr with the correct sighash +// before calling this function. A malformed signature injected +// here produces a transaction that fails script verification — +// the network rejects it, so no funds are at risk, but downstream +// components that trust a nil return from Apply as "transaction +// is valid" would be misled. +func TransactionApplySchnorr(params *chaincfg.Params, tx *wire.MsgTx, idx int, prev *wire.TxOut, pubKey *btcec.PublicKey, sig64 []byte, hashType txscript.SigHashType) error { + if tx == nil || prev == nil || pubKey == nil { + return fmt.Errorf("nil argument") + } + if idx < 0 || idx >= len(tx.TxIn) { + return fmt.Errorf("input index %d out of range (tx has %d inputs)", + idx, len(tx.TxIn)) + } + if len(sig64) != 64 { + return fmt.Errorf("schnorr signature must be 64 bytes, got %d", + len(sig64)) + } + if err := validateSigHashType(hashType); err != nil { + return err + } + + // Parse to enforce BIP-340 encoding: upper bit of R.x and a few + // other canonicity rules. ParseSignature catches encodings that + // would be rejected by the script engine. + if _, err := schnorr.ParseSignature(sig64); err != nil { + return fmt.Errorf("parse signature: %w", err) + } + + class := txscript.GetScriptClass(prev.PkScript) + if class != txscript.WitnessV1TaprootTy { + return fmt.Errorf("unsupported script class for schnorr: %v", class) + } + + if err := pubKeyMatchesTaprootAddress(params, prev.PkScript, pubKey); err != nil { + return fmt.Errorf("p2tr: %w", err) + } + + witness := sig64 + if hashType != txscript.SigHashDefault { + witness = append(append([]byte{}, sig64...), byte(hashType)) + } + tx.TxIn[idx].Witness = wire.TxWitness{witness} + tx.TxIn[idx].SignatureScript = nil + return nil +} + +// pubKeyMatchesTaprootAddress verifies that applying the BIP-86 +// taproot tweak to pubKey produces the same address encoded in +// pkScript. Using ExtractPkScriptAddrs plus NewAddressTaproot with +// the tweaked x-only key gives two btcutil.Address values with +// params-matched HRPs; equality of their encoded forms proves the +// pkScript commits to this internal key under a nil script root. +func pubKeyMatchesTaprootAddress(params *chaincfg.Params, pkScript []byte, pubKey *btcec.PublicKey) error { + _, addrs, _, err := txscript.ExtractPkScriptAddrs(pkScript, params) + if err != nil { + return fmt.Errorf("extract address: %w", err) + } + if len(addrs) != 1 { + return fmt.Errorf("pkScript extracted %d addresses, expected 1", len(addrs)) + } + tweaked := txscript.ComputeTaprootKeyNoScript(pubKey) + want, err := btcutil.NewAddressTaproot(schnorr.SerializePubKey(tweaked), params) + if err != nil { + return fmt.Errorf("derive address: %w", err) + } + if addrs[0].EncodeAddress() != want.EncodeAddress() { + return fmt.Errorf("public key does not match address") + } + return nil +} diff --git a/bitcoin/wallet/external_sign_schnorr_test.go b/bitcoin/wallet/external_sign_schnorr_test.go new file mode 100644 index 000000000..4126396f1 --- /dev/null +++ b/bitcoin/wallet/external_sign_schnorr_test.go @@ -0,0 +1,454 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package wallet + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" +) + +// TestTransactionApplySchnorrP2TR exercises the P2TR schnorr +// injection path end-to-end. The sighash is computed with the +// BIP-341 algorithm, signed externally with the tweaked key, and +// injected via TransactionApplySchnorr. The script engine must +// accept the resulting witness. +func TestTransactionApplySchnorrP2TR(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + // BIP-86 output key for the address. + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(outputKey), params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("external-p2tr-funding-000000000")) + op := wire.NewOutPoint(&fundHash, 0) + const fundValue int64 = 100_000 + + destPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + destOutputKey := txscript.ComputeTaprootKeyNoScript(destPriv.PubKey()) + destAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(destOutputKey), params) + if err != nil { + t.Fatal(err) + } + destScript, err := txscript.PayToAddrScript(destAddr) + if err != nil { + t.Fatal(err) + } + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + tx.AddTxOut(wire.NewTxOut(fundValue/2, destScript)) + + prev := wire.NewTxOut(fundValue, pkScript) + prevOuts := PrevOuts{op.String(): prev} + + // Produce an external schnorr signature over the BIP-341 sighash. + // The TSS committee will do this; here we simulate with the local + // key tweaked per BIP-86. + fetcher := prevOutsFetcher(prevOuts) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + // RawTxInTaprootSignature applies the BIP-86 tweak internally and + // returns a 64-byte SigHashDefault sig. + sig64, err := txscript.RawTxInTaprootSignature(tx, sigHashes, 0, + fundValue, pkScript, nil, txscript.SigHashDefault, priv) + if err != nil { + t.Fatal(err) + } + if len(sig64) != 64 { + t.Fatalf("expected 64-byte sig, got %d", len(sig64)) + } + + // Inject: caller supplies the untweaked internal pubkey; the + // helper computes the tweaked output key and cross-checks. + err = TransactionApplySchnorr(params, tx, 0, prev, priv.PubKey(), + sig64, txscript.SigHashDefault) + if err != nil { + t.Fatalf("TransactionApplySchnorr: %v", err) + } + + if len(tx.TxIn[0].Witness) != 1 { + t.Fatalf("witness wrong length: got %d, want 1", + len(tx.TxIn[0].Witness)) + } + if !bytes.Equal(tx.TxIn[0].Witness[0], sig64) { + t.Fatal("witness[0] does not match provided signature") + } + if len(tx.TxIn[0].SignatureScript) != 0 { + t.Fatal("SignatureScript unexpectedly set for P2TR") + } + + if err := verifyInput(tx, 0, prevOuts); err != nil { + t.Fatalf("engine rejected injected schnorr signature: %v", err) + } +} + +// TestTransactionApplySchnorrRejectsBadSig verifies the function +// refuses malformed inputs: wrong length, nil args, bad BIP-340 +// encoding. +func TestTransactionApplySchnorrRejectsBadSig(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(outputKey), params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("schnorr-bad-sig-000000000000000")) + op := wire.NewOutPoint(&fundHash, 0) + prev := wire.NewTxOut(50_000, pkScript) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + + // Wrong length. + cases := []struct { + name string + pubKey *btcec.PublicKey + sig []byte + }{ + {"wrong length", priv.PubKey(), []byte{1, 2, 3}}, + {"nil pubkey", nil, make([]byte, 64)}, + {"empty sig", priv.PubKey(), nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := TransactionApplySchnorr(params, tx, 0, prev, tc.pubKey, + tc.sig, txscript.SigHashDefault); err == nil { + t.Fatalf("%s: expected error", tc.name) + } + }) + } +} + +// TestTransactionApplySchnorrWrongKey verifies that a key which +// doesn't tweak to the prev pkScript's x-only witness program is +// rejected. +func TestTransactionApplySchnorrWrongKey(t *testing.T) { + params := &chaincfg.TestNet3Params + + ownerPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + wrongPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + ownerOut := txscript.ComputeTaprootKeyNoScript(ownerPriv.PubKey()) + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(ownerOut), params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("schnorr-wrong-key-00000000000000")) + op := wire.NewOutPoint(&fundHash, 0) + prev := wire.NewTxOut(50_000, pkScript) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + + // Build a well-formed schnorr sig from the wrong key; the address + // check must reject before the signature matters. + sigHashes := txscript.NewTxSigHashes(tx, + txscript.NewCannedPrevOutputFetcher(pkScript, 50_000)) + sig64, err := txscript.RawTxInTaprootSignature(tx, sigHashes, 0, + 50_000, pkScript, nil, txscript.SigHashDefault, wrongPriv) + if err != nil { + t.Fatal(err) + } + + err = TransactionApplySchnorr(params, tx, 0, prev, wrongPriv.PubKey(), + sig64, txscript.SigHashDefault) + if err == nil { + t.Fatal("expected address-mismatch error") + } +} + +// TestTransactionApplySchnorrRejectsP2PKH verifies the schnorr +// injection path refuses a non-taproot script class. +func TestTransactionApplySchnorrRejectsP2PKH(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pkHash := btcutil.Hash160(priv.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("schnorr-rejects-p2pkh-0000000000")) + op := wire.NewOutPoint(&fundHash, 0) + prev := wire.NewTxOut(50_000, pkScript) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + + // Fabricate a plausible-looking 64-byte blob just to get past the + // length check; the class check must fire first. + sig64 := make([]byte, 64) + copy(sig64, priv.PubKey().SerializeCompressed()[1:]) + copy(sig64[32:], priv.PubKey().SerializeCompressed()[1:]) + + err = TransactionApplySchnorr(params, tx, 0, prev, priv.PubKey(), + sig64, txscript.SigHashDefault) + if err == nil { + t.Fatal("expected unsupported-script-class error") + } +} + +// TestTransactionApplySchnorrRejectsIdxOutOfRange verifies negative +// and overshoot indices are refused before mutating the transaction. +func TestTransactionApplySchnorrRejectsIdxOutOfRange(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(outputKey), params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("schnorr-idx-oor-0000000000000000")) + op := wire.NewOutPoint(&fundHash, 0) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + + prev := wire.NewTxOut(50_000, pkScript) + sig64 := make([]byte, 64) + // Plausible 64-byte blob just to pass the length guard — must not + // reach the idx check. Actual validity is irrelevant because idx + // guard fires first. + + cases := []struct { + name string + idx int + }{ + {"negative", -1}, + {"past end", 1}, + {"far overshoot", 99}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := TransactionApplySchnorr(params, tx, tc.idx, prev, priv.PubKey(), + sig64, txscript.SigHashDefault); err == nil { + t.Fatalf("idx=%d: expected out-of-range error", tc.idx) + } + }) + } +} + +// TestTransactionApplySchnorrRejectsMalformedSig verifies that a +// 64-byte blob that fails BIP-340 structural parsing is refused. +// schnorr.ParseSignature checks that s is below the group order; +// a scalar >= N gets rejected here. +func TestTransactionApplySchnorrRejectsMalformedSig(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(outputKey), params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("schnorr-malformed-0000000000000")) + op := wire.NewOutPoint(&fundHash, 0) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + + prev := wire.NewTxOut(50_000, pkScript) + + // All-0xff: s is way above the curve order, ParseSignature rejects. + allFF := make([]byte, 64) + for i := range allFF { + allFF[i] = 0xff + } + if err := TransactionApplySchnorr(params, tx, 0, prev, priv.PubKey(), + allFF, txscript.SigHashDefault); err == nil { + t.Fatal("malformed 64-byte sig: expected parse error") + } +} + +// TestTransactionApplySchnorrRejectsNonStandardScript verifies that +// a taproot-class pkScript which cannot be decoded into a single +// standard address is rejected by the cross-check step rather than +// producing a confusing downstream error. An OP_1-prefixed but +// malformed witness program is structurally taproot-class but +// ExtractPkScriptAddrs will refuse it. +func TestTransactionApplySchnorrRejectsNonStandardScript(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + // Build a valid signature first so the sig guards pass; the + // test target is the address cross-check. + sig64, err := schnorr.Sign(priv, chainhash.HashB([]byte("x"))) + if err != nil { + t.Fatal(err) + } + + // Produce a pkScript that classifies as taproot but whose witness + // program is a non-point. Using all-zero x-only coordinate: + // txscript.GetScriptClass recognises the OP_1+push32 shape as + // WitnessV1TaprootTy, but NewAddressTaproot accepts only valid + // curve points. However the extracted address will parse fine — + // what we want to trigger is the mismatch branch, which requires + // a genuinely valid but wrong x-only key. + // + // Simpler test target: a taproot pkScript for key A, injected + // with signature from key B. Already covered by WrongKey test + // (line above). For ExtractPkScriptAddrs failure specifically + // we'd need a malformed script — but GetScriptClass would not + // classify it as taproot, so the class check fires first. + // + // The practical ExtractPkScriptAddrs failure path is therefore + // unreachable from a well-classified pkScript. This test + // documents that invariant: if class == taproot then + // ExtractPkScriptAddrs succeeds and len(addrs) == 1. + fundHash := chainhash.DoubleHashH([]byte("schnorr-invariant-0000000000000")) + op := wire.NewOutPoint(&fundHash, 0) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(outputKey), params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + prev := wire.NewTxOut(50_000, pkScript) + + // Happy path through the invariant: class matches, extract + // yields 1 address, derive matches, signature applied. + if err := TransactionApplySchnorr(params, tx, 0, prev, priv.PubKey(), + sig64.Serialize(), txscript.SigHashDefault); err != nil { + t.Fatalf("invariant path failed: %v", err) + } +} + +// TestTransactionApplySchnorrRejectsBadSigHashType verifies the +// hashType argument is rejected if it is not a standard sighash +// value. Relevant to taproot because a non-default hashType +// causes the sighash byte to be appended to the 64-byte signature; +// silent byte() truncation would append the wrong byte. +func TestTransactionApplySchnorrRejectsBadSigHashType(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(outputKey), params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("schnorr-bad-sighash-0000000")) + op := wire.NewOutPoint(&fundHash, 0) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + + prev := wire.NewTxOut(50_000, pkScript) + sig, err := schnorr.Sign(priv, chainhash.HashB([]byte("x"))) + if err != nil { + t.Fatal(err) + } + sig64 := sig.Serialize() + + cases := []struct { + name string + hashType txscript.SigHashType + }{ + {"high-byte smuggle to SigHashAll", txscript.SigHashType(0xFFFFFF01)}, + {"undefined low byte 0x04", txscript.SigHashType(0x04)}, + {"0xDEADBEEF", txscript.SigHashType(0xDEADBEEF)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := TransactionApplySchnorr(params, tx, 0, prev, priv.PubKey(), + sig64, tc.hashType) + if err == nil { + t.Fatalf("hashType=%#x: expected error, got nil", + uint32(tc.hashType)) + } + }) + } +} From 7a8122604c4820bcff21afe55b773fa35a310fc0 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Fri, 17 Apr 2026 04:59:22 -0700 Subject: [PATCH 17/25] feat(wallet): verify external signatures VerifyECDSA and VerifySchnorr are pre-broadcast sanity helpers for externally-computed signatures. Callers producing a signature out of band (TSS committee, hardware wallet, PSBT flow) can run the result through these helpers before handing it to TransactionApplyECDSA or TransactionApplySchnorr to catch wrong-key or wrong-hash errors at injection time rather than on broadcast. VerifyECDSA parses a DER-encoded signature (no trailing sighash byte) and checks it against a 32-byte sighash under the provided public key. Structural guards reject nil pubkey, wrong-length sighash, empty signature, and malformed DER. VerifySchnorr is the BIP-340 counterpart. The caller supplies the tweaked x-only output key (not the internal key), the 32-byte BIP-341 sighash, and the 64-byte schnorr signature. Tests cover: happy-path verification for both algorithms; wrong-key rejection; wrong-hash rejection for ECDSA; structural rejection of nil/short/malformed inputs; and a taproot round-trip exercising the tweak flow a real TSS caller would follow. PoP regression tests continue to pass. --- bitcoin/wallet/verify.go | 84 ++++++++ bitcoin/wallet/verify_test.go | 383 ++++++++++++++++++++++++++++++++++ 2 files changed, 467 insertions(+) create mode 100644 bitcoin/wallet/verify.go create mode 100644 bitcoin/wallet/verify_test.go diff --git a/bitcoin/wallet/verify.go b/bitcoin/wallet/verify.go new file mode 100644 index 000000000..8c401f642 --- /dev/null +++ b/bitcoin/wallet/verify.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package wallet + +import ( + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcec/v2/schnorr" +) + +// VerifyECDSA checks that sigDER is a valid ECDSA signature over +// sigHash under pubKey. This is a pre-broadcast sanity check for +// externally-computed signatures (hardware wallets, PSBT flows, TSS +// committees) before handing them to TransactionApplyECDSA. +// +// sigDER is DER-encoded without any trailing sighash byte. sigHash +// is the 32-byte hash the signer produced the signature over; the +// caller is responsible for computing it with the correct sighash +// algorithm (legacy for P2PKH, BIP-143 for P2WPKH). +// +// Returns nil on success. On failure the error distinguishes +// between parse errors and verification mismatches. +func VerifyECDSA(sigHash, sigDER []byte, pubKey *btcec.PublicKey) error { + if pubKey == nil { + return fmt.Errorf("nil pubkey") + } + if len(sigHash) != 32 { + return fmt.Errorf("sighash must be 32 bytes, got %d", len(sigHash)) + } + if len(sigDER) == 0 { + return fmt.Errorf("empty signature") + } + sig, err := ecdsa.ParseDERSignature(sigDER) + if err != nil { + return fmt.Errorf("parse signature: %w", err) + } + if !sig.Verify(sigHash, pubKey) { + return fmt.Errorf("signature does not verify") + } + return nil +} + +// VerifySchnorr checks that sig64 is a valid BIP-340 schnorr +// signature over sigHash under xOnlyPubKey. Use this to sanity-check +// an externally-computed schnorr signature (schnorr-TSS, MuSig2, +// FROST) before handing it to TransactionApplySchnorr. +// +// xOnlyPubKey is the 32-byte x-only public key — typically the +// tweaked taproot output key, not the untweaked internal key. The +// caller is responsible for applying the BIP-341 tweak before +// verification; for BIP-86 key-path that is +// schnorr.SerializePubKey(txscript.ComputeTaprootKeyNoScript(internal)). +// +// sigHash is the 32-byte BIP-341 taproot sighash the signer produced +// the signature over. +func VerifySchnorr(sigHash, sig64, xOnlyPubKey []byte) error { + if len(xOnlyPubKey) != 32 { + return fmt.Errorf("x-only pubkey must be 32 bytes, got %d", + len(xOnlyPubKey)) + } + if len(sigHash) != 32 { + return fmt.Errorf("sighash must be 32 bytes, got %d", len(sigHash)) + } + if len(sig64) != 64 { + return fmt.Errorf("schnorr signature must be 64 bytes, got %d", + len(sig64)) + } + sig, err := schnorr.ParseSignature(sig64) + if err != nil { + return fmt.Errorf("parse signature: %w", err) + } + pub, err := schnorr.ParsePubKey(xOnlyPubKey) + if err != nil { + return fmt.Errorf("parse x-only pubkey: %w", err) + } + if !sig.Verify(sigHash, pub) { + return fmt.Errorf("signature does not verify") + } + return nil +} diff --git a/bitcoin/wallet/verify_test.go b/bitcoin/wallet/verify_test.go new file mode 100644 index 000000000..38bf7d0ad --- /dev/null +++ b/bitcoin/wallet/verify_test.go @@ -0,0 +1,383 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package wallet + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" +) + +// TestVerifyECDSAValid signs a hash with a known key and confirms +// VerifyECDSA accepts the resulting signature. +func TestVerifyECDSAValid(t *testing.T) { + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + hash := chainhash.HashB([]byte("verify-payload")) + sig := ecdsa.Sign(priv, hash) + + if err := VerifyECDSA(hash, sig.Serialize(), priv.PubKey()); err != nil { + t.Fatalf("VerifyECDSA rejected a valid signature: %v", err) + } +} + +// TestVerifyECDSAWrongKey confirms the helper rejects a signature +// produced by a different key than the one being verified against. +func TestVerifyECDSAWrongKey(t *testing.T) { + signer, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + other, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + hash := chainhash.HashB([]byte("verify-payload")) + sig := ecdsa.Sign(signer, hash) + + if err := VerifyECDSA(hash, sig.Serialize(), other.PubKey()); err == nil { + t.Fatal("expected verification failure for wrong key") + } +} + +// TestVerifyECDSAWrongHash confirms that signing h1 and verifying +// against h2 fails. This catches the common bug of recomputing the +// sighash with different parameters on the verify side. +func TestVerifyECDSAWrongHash(t *testing.T) { + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + signedHash := chainhash.HashB([]byte("signed")) + otherHash := chainhash.HashB([]byte("other")) + sig := ecdsa.Sign(priv, signedHash) + + if err := VerifyECDSA(otherHash, sig.Serialize(), priv.PubKey()); err == nil { + t.Fatal("expected verification failure for wrong hash") + } +} + +// TestVerifyECDSARejectsBad checks the structural guards: nil +// pubkey, wrong sighash length, empty signature, malformed DER. +func TestVerifyECDSARejectsBad(t *testing.T) { + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + hash := chainhash.HashB([]byte("x")) + + cases := []struct { + name string + sigHash []byte + sigDER []byte + pubKey *btcec.PublicKey + }{ + {"nil pubkey", hash, []byte{0x30, 0x00}, nil}, + {"short sighash", []byte{1, 2, 3}, []byte{0x30, 0x00}, priv.PubKey()}, + {"empty sig", hash, nil, priv.PubKey()}, + {"malformed DER", hash, []byte{0xff, 0xff}, priv.PubKey()}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := VerifyECDSA(tc.sigHash, tc.sigDER, tc.pubKey); err == nil { + t.Fatalf("%s: expected error", tc.name) + } + }) + } +} + +// TestVerifySchnorrValid signs a hash with a known key (tweaked +// per BIP-86) and confirms VerifySchnorr accepts the resulting +// 64-byte signature against the tweaked x-only output key. +func TestVerifySchnorrValid(t *testing.T) { + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + // BIP-86 tweak: nil script root. + tweaked := txscript.TweakTaprootPrivKey(*priv, nil) + + hash := chainhash.HashB([]byte("taproot-payload")) + sig, err := schnorr.Sign(tweaked, hash) + if err != nil { + t.Fatal(err) + } + + xOnly := schnorr.SerializePubKey(tweaked.PubKey()) + if err := VerifySchnorr(hash, sig.Serialize(), xOnly); err != nil { + t.Fatalf("VerifySchnorr rejected a valid signature: %v", err) + } +} + +// TestVerifySchnorrWrongKey confirms a schnorr signature produced +// by one key fails verification against a different x-only pubkey. +func TestVerifySchnorrWrongKey(t *testing.T) { + signer, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + other, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + hash := chainhash.HashB([]byte("taproot-payload")) + sig, err := schnorr.Sign(signer, hash) + if err != nil { + t.Fatal(err) + } + + otherXOnly := schnorr.SerializePubKey(other.PubKey()) + if err := VerifySchnorr(hash, sig.Serialize(), otherXOnly); err == nil { + t.Fatal("expected verification failure for wrong key") + } +} + +// TestVerifySchnorrRejectsBad checks the structural guards: wrong +// sighash length, wrong sig length, wrong x-only pubkey length. +func TestVerifySchnorrRejectsBad(t *testing.T) { + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + xOnly := schnorr.SerializePubKey(priv.PubKey()) + + hash32 := make([]byte, 32) + sig64 := make([]byte, 64) + + cases := []struct { + name string + sigHash []byte + sig []byte + pubKey []byte + }{ + {"short sighash", []byte{1, 2, 3}, sig64, xOnly}, + {"short sig", hash32, []byte{1, 2, 3}, xOnly}, + {"short pubkey", hash32, sig64, []byte{1, 2, 3}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := VerifySchnorr(tc.sigHash, tc.sig, tc.pubKey); err == nil { + t.Fatalf("%s: expected error", tc.name) + } + }) + } +} + +// TestVerifySchnorrTaprootAddressRoundTrip is an end-to-end sanity +// check: derive a taproot address from an internal key, sign a hash +// with the tweaked key, then verify using the x-only form of the +// tweaked output key. This is the verification surface a caller +// uses before handing a TSS signature to TransactionApplySchnorr. +func TestVerifySchnorrTaprootAddressRoundTrip(t *testing.T) { + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + tweaked := txscript.TweakTaprootPrivKey(*priv, nil) + + hash := chainhash.HashB([]byte("round-trip")) + sig, err := schnorr.Sign(tweaked, hash) + if err != nil { + t.Fatal(err) + } + + xOnly := schnorr.SerializePubKey(outputKey) + if err := VerifySchnorr(hash, sig.Serialize(), xOnly); err != nil { + t.Fatalf("round-trip verify failed: %v", err) + } +} + +// TestVerifyECDSAGatesTransactionApplyECDSA proves VerifyECDSA sits +// on the real integration path between an external signer and +// TransactionApplyECDSA. Flow: +// +// 1. Build a P2WPKH input. +// 2. Compute the BIP-143 sighash the external signer will sign. +// 3. Assemble the signature from raw (r, s) via ECDSASigFromRS — +// the shape hemilabs/x/tss-lib/v3 returns. +// 4. Run VerifyECDSA as the pre-broadcast gate. +// 5. Only on success, inject via TransactionApplyECDSA. +// 6. Confirm the script engine accepts the result. +// +// This is the canonical caller flow the verify helpers were built +// to serve. A regression in any step — sighash mismatch, DER +// malformation, wrong address binding — fails this test. +func TestVerifyECDSAGatesTransactionApplyECDSA(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + pubCompressed := priv.PubKey().SerializeCompressed() + pkHash := btcutil.Hash160(pubCompressed) + addr, err := btcutil.NewAddressWitnessPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("verify-ecdsa-gate-0000000000000")) + op := wire.NewOutPoint(&fundHash, 0) + const fundValue int64 = 100_000 + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + tx.AddTxOut(wire.NewTxOut(fundValue/2, pkScript)) + + prev := wire.NewTxOut(fundValue, pkScript) + prevOuts := PrevOuts{op.String(): prev} + + // Step 2: sighash the external signer signs over. + fetcher := prevOutsFetcher(prevOuts) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + sigHash, err := txscript.CalcWitnessSigHash(pkScript, sigHashes, + txscript.SigHashAll, tx, 0, fundValue) + if err != nil { + t.Fatal(err) + } + + // Step 3: produce the signature as raw (r, s), assemble DER via + // the helper that wraps the TSS output format. + sig := ecdsa.Sign(priv, sigHash) + r := sig.R() + s := sig.S() + rBytes := r.Bytes() + sBytes := s.Bytes() + sigDER, err := ECDSASigFromRS(rBytes[:], sBytes[:]) + if err != nil { + t.Fatalf("ECDSASigFromRS: %v", err) + } + + // Step 4: gate. + if err := VerifyECDSA(sigHash, sigDER, priv.PubKey()); err != nil { + t.Fatalf("VerifyECDSA rejected a signature it should have accepted: %v", err) + } + + // Step 5: inject. + err = TransactionApplyECDSA(params, tx, 0, prev, priv.PubKey(), + sigDER, txscript.SigHashAll) + if err != nil { + t.Fatalf("TransactionApplyECDSA: %v", err) + } + + // Step 6: script engine is the ultimate witness. + if err := verifyInput(tx, 0, prevOuts); err != nil { + t.Fatalf("engine rejected gated+injected signature: %v", err) + } +} + +// TestVerifyECDSACatchesBadSignatureBeforeApply proves the gate +// rejects a malformed signature before it reaches +// TransactionApplyECDSA. Without this gate a caller would only +// discover the bad signature at broadcast time. +func TestVerifyECDSACatchesBadSignatureBeforeApply(t *testing.T) { + signer, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + other, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + sigHash := chainhash.HashB([]byte("payload")) + + // Signature produced by the wrong key — common TSS failure mode + // (wrong keyID requested, committee agreed on a different key). + sig := ecdsa.Sign(other, sigHash) + sigDER := sig.Serialize() + + // Gate must reject against the claimed signer's pubkey. + if err := VerifyECDSA(sigHash, sigDER, signer.PubKey()); err == nil { + t.Fatal("VerifyECDSA accepted a signature from the wrong key") + } +} + +// TestVerifySchnorrGatesTransactionApplySchnorr is the schnorr +// counterpart: produces a BIP-340 signature over a BIP-341 taproot +// sighash, gates via VerifySchnorr with the tweaked x-only key, +// then injects via TransactionApplySchnorr. The script engine +// accepts the result when the gate passes. +func TestVerifySchnorrGatesTransactionApplySchnorr(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(outputKey), params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("verify-schnorr-gate-0000000000")) + op := wire.NewOutPoint(&fundHash, 0) + const fundValue int64 = 100_000 + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(op, nil, nil)) + tx.AddTxOut(wire.NewTxOut(fundValue/2, pkScript)) + + prev := wire.NewTxOut(fundValue, pkScript) + prevOuts := PrevOuts{op.String(): prev} + + // Produce signature through the same path a schnorr-TSS coordinator + // would take: tweak per BIP-86, sign over the BIP-341 sighash. + fetcher := prevOutsFetcher(prevOuts) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + sig64, err := txscript.RawTxInTaprootSignature(tx, sigHashes, 0, + fundValue, pkScript, nil, txscript.SigHashDefault, priv) + if err != nil { + t.Fatal(err) + } + sigHash, err := txscript.CalcTaprootSignatureHash(sigHashes, + txscript.SigHashDefault, tx, 0, fetcher) + if err != nil { + t.Fatal(err) + } + + tweakedXOnly := schnorr.SerializePubKey(outputKey) + + // Gate. + if err := VerifySchnorr(sigHash, sig64, tweakedXOnly); err != nil { + t.Fatalf("VerifySchnorr rejected a signature it should have accepted: %v", err) + } + + // Inject. + err = TransactionApplySchnorr(params, tx, 0, prev, priv.PubKey(), + sig64, txscript.SigHashDefault) + if err != nil { + t.Fatalf("TransactionApplySchnorr: %v", err) + } + + // Engine. + if err := verifyInput(tx, 0, prevOuts); err != nil { + t.Fatalf("engine rejected gated+injected schnorr signature: %v", err) + } +} From 7298985bd9c017aa7c8858337082c4a75deb7af4 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Fri, 17 Apr 2026 07:16:34 -0700 Subject: [PATCH 18/25] test(wallet): tss end-to-end bitcoin spend TestTSS_E2E_P2WPKH proves that an externally-produced ECDSA threshold signature can be injected into a bitcoin transaction and accepted by the same script engine a bitcoin node runs against witnessed inputs. The test runs a real 2-of-3 ECDSA TSS ceremony in-process using github.com/hemilabs/x/tss-lib/v3: full Paillier pre-parameter generation, 4-round distributed keygen, 9-round distributed signing + finalize. The private key exists only as shares across the committee at every moment of the test. No mocks, no single-party shortcuts. The group public key is turned into a P2WPKH testnet address, an unsigned spend is built against a funding UTXO locked to that address, the BIP-143 sighash is computed, and the committee signs the sighash. The resulting raw (r, s) scalars flow through ECDSASigFromRS, VerifyECDSA, and TransactionApplyECDSA, and the final transaction is handed to txscript.NewEngine for consensus validation. Gated behind the tss_e2e build tag. Paillier safe-prime generation takes roughly 50 seconds for a 3-party run, and the full test finishes in about one minute. Regular make test does not build this file, so the default test suite remains fast. Run locally with: go test -tags tss_e2e -timeout 15m \ -run TestTSS_E2E ./bitcoin/wallet/ Adds github.com/hemilabs/x/tss-lib/v3 as a direct test-only dependency pinned to the max/tss_changes branch tip. Will follow the upstream tss-lib v3 release once that repo tags a version. --- bitcoin/wallet/tss_e2e_test.go | 665 +++++++++++++++++++++++++++++++++ go.mod | 12 + go.sum | 78 ++++ 3 files changed, 755 insertions(+) create mode 100644 bitcoin/wallet/tss_e2e_test.go diff --git a/bitcoin/wallet/tss_e2e_test.go b/bitcoin/wallet/tss_e2e_test.go new file mode 100644 index 000000000..97684db90 --- /dev/null +++ b/bitcoin/wallet/tss_e2e_test.go @@ -0,0 +1,665 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +//go:build tss_e2e +// +build tss_e2e + +// This end-to-end test exercises the full TSS signing path against a +// real 2-of-3 ECDSA threshold committee assembled in-process from +// github.com/hemilabs/x/tss-lib/v3. No mocks, no shortcuts: +// +// 1. Real Paillier pre-parameters (safe-prime generation). +// 2. Real 4-round distributed keygen. +// 3. Real 9-round distributed signing. +// 4. Assemble DER via ECDSASigFromRS from the raw (r, s) scalars the +// committee emits. +// 5. Inject via TransactionApplyECDSA into a bitcoin tx whose +// funding input is locked to the group public key's P2WPKH +// address. +// 6. Run the btcd script engine against the signed transaction. +// +// The script engine is the same consensus validator that bitcoin +// nodes run against every witnessed input. A pass here means the +// committee-produced signature is accepted by the bitcoin network +// for a real spend — not a simulation. +// +// Build tag `tss_e2e` gates this test because Paillier safe-prime +// generation makes it take several minutes per run. Run with: +// +// go test -tags tss_e2e -timeout 15m -run TestTSS_E2E \ +// ./bitcoin/wallet/ +// +// Regular `make test` does not build this file. + +package wallet + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "math/big" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/hemilabs/x/tss-lib/v3/crypto" + "github.com/hemilabs/x/tss-lib/v3/ecdsa/keygen" + "github.com/hemilabs/x/tss-lib/v3/ecdsa/signing" + "github.com/hemilabs/x/tss-lib/v3/tss" +) + +// TestTSS_E2E_P2WPKH runs the complete flow: real distributed ECDSA +// keygen, derive a P2WPKH address from the group pubkey, build an +// unsigned tx spending the funding output, compute BIP-143 sighash, +// drive a real distributed signing ceremony, inject via +// TransactionApplyECDSA, and validate through the btcd script +// engine. A failure anywhere along this path is a real-world +// failure — there is no mock layer hiding anything. +func TestTSS_E2E_P2WPKH(t *testing.T) { + const ( + parties = 3 + threshold = 1 // tss-lib uses t = minSigners-1 for t-of-n + ) + ctx := t.Context() + + // Phase 1: Paillier pre-parameters — slow, do out of band. + t.Logf("generating Paillier pre-params for %d parties...", parties) + t0 := time.Now() + preParams := make([]keygen.LocalPreParams, parties) + for i := range preParams { + pp, err := keygen.GeneratePreParams(5 * time.Minute) + if err != nil { + t.Fatalf("GeneratePreParams[%d]: %v", i, err) + } + preParams[i] = *pp + } + preParamsDur := time.Since(t0) + t.Logf("pre-params ready in %.1fs", preParamsDur.Seconds()) + + // Phase 2: Party IDs and peer context. + pIDs := tss.GenerateTestPartyIDs(parties) + peerCtx := tss.NewPeerContext(pIDs) + + // Phase 3: Run the distributed keygen ceremony. + t.Log("running 4-round distributed keygen...") + tKeygen := time.Now() + saves := runKeygen(t, ctx, parties, threshold, pIDs, peerCtx, preParams) + keygenDur := time.Since(tKeygen) + tssPub := saves[0].ECDSAPub + t.Logf("keygen complete in %.1fs", keygenDur.Seconds()) + + // Convert the tss-lib public key to a btcec public key via the + // SEC1 compressed encoding — this forces btcec.ParsePubKey's + // on-curve validation, so a malformed point from a buggy or + // malicious TSS coordinator would be rejected here rather than + // silently accepted by direct field-element construction. + pubKey := tssPubKeyToBtcec(t, tssPub) + pubX, pubY := tssPub.X(), tssPub.Y() + + // Phase 4: Derive a P2WPKH address from the group public key. + // This is the address that external observers (regular bitcoin + // nodes, indexers, explorers) would see as the owner of the + // UTXO. It is controlled by the TSS committee, not any single + // party. + params := &chaincfg.TestNet3Params + pkHash := btcutil.Hash160(pubKey.SerializeCompressed()) + addr, err := btcutil.NewAddressWitnessPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + // Phase 5: Build an unsigned transaction that spends a funding + // UTXO locked to the TSS address. + fundHash := chainhash.DoubleHashH([]byte("tss-e2e-funding")) + fundOutpoint := wire.NewOutPoint(&fundHash, 0) + const inputAmount int64 = 100_000 + const outputAmount int64 = 99_000 // 1000 sat fee + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(fundOutpoint, nil, nil)) + // Send to an arbitrary P2WPKH recipient — the recipient does + // not matter for the signing exercise. + recipPriv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + recipHash := btcutil.Hash160(recipPriv.PubKey().SerializeCompressed()) + recipAddr, err := btcutil.NewAddressWitnessPubKeyHash(recipHash, params) + if err != nil { + t.Fatal(err) + } + recipScript, err := txscript.PayToAddrScript(recipAddr) + if err != nil { + t.Fatal(err) + } + tx.AddTxOut(wire.NewTxOut(outputAmount, recipScript)) + + // Serialise the unsigned tx for the evidence block. + var unsignedBuf bytes.Buffer + if err := tx.Serialize(&unsignedBuf); err != nil { + t.Fatalf("serialize unsigned tx: %v", err) + } + unsignedHex := hex.EncodeToString(unsignedBuf.Bytes()) + + // Phase 6: Compute the BIP-143 sighash for the TSS-controlled + // input. This is the message the committee will sign. + prev := wire.NewTxOut(inputAmount, pkScript) + prevOuts := PrevOuts{ + fundOutpoint.String(): prev, + } + fetcher := prevOutsFetcher(prevOuts) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + // For P2WPKH the sighash is computed against the P2PKH-equivalent + // script per BIP-143. + p2pkhScript, err := txscript.PayToAddrScript( + mustP2PKH(t, params, pkHash), + ) + if err != nil { + t.Fatal(err) + } + sighash, err := txscript.CalcWitnessSigHash(p2pkhScript, sigHashes, + txscript.SigHashAll, tx, 0, inputAmount) + if err != nil { + t.Fatalf("CalcWitnessSigHash: %v", err) + } + + // Phase 7: Run the distributed signing ceremony on the sighash. + // This is NINE rounds plus finalize of real MPC — the private + // key exists only as shares across the committee. + t.Log("running 9-round distributed signing...") + tSign := time.Now() + tssSig := runSigning(t, ctx, parties, threshold, pIDs, peerCtx, saves, + new(big.Int).SetBytes(sighash)) + signDur := time.Since(tSign) + t.Logf("committee produced signature in %.2fs", signDur.Seconds()) + + // Phase 8: Assemble DER via the helper this branch ships. + sigDER, err := ECDSASigFromRS(tssSig.R, tssSig.S) + if err != nil { + t.Fatalf("ECDSASigFromRS: %v", err) + } + + // Phase 9: Pre-broadcast verify gate. + if err := VerifyECDSA(sighash, sigDER, pubKey); err != nil { + t.Fatalf("VerifyECDSA rejected committee signature: %v", err) + } + + // Phase 10: Inject into the transaction. + if err := TransactionApplyECDSA(params, tx, 0, prev, pubKey, + sigDER, txscript.SigHashAll); err != nil { + t.Fatalf("TransactionApplyECDSA: %v", err) + } + + // Serialise the signed tx for the evidence block. Anyone can + // paste this hex into `bitcoin-cli decoderawtransaction` or an + // online parser to inspect the witness stack and confirm the + // signature is well-formed. + var signedBuf bytes.Buffer + if err := tx.Serialize(&signedBuf); err != nil { + t.Fatalf("serialize signed tx: %v", err) + } + signedHex := hex.EncodeToString(signedBuf.Bytes()) + txid := tx.TxHash().String() + + // Phase 11: Consensus validation. The btcd script engine is + // the same validator bitcoin nodes run. Passing here means + // the network would accept this spend. + vm, err := txscript.NewEngine(pkScript, tx, 0, + txscript.StandardVerifyFlags, nil, sigHashes, + inputAmount, fetcher) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + if err := vm.Execute(); err != nil { + t.Fatalf("script engine rejected TSS-signed tx: %v", err) + } + + // ------------------------------------------------------------- + // EVIDENCE BLOCK — self-contained, paste-able. Values are + // single-run; R, S, sighash, the group key, and derived + // address change on every invocation because TSS keygen and + // signing both use fresh randomness. + // ------------------------------------------------------------- + ev := evidence{ + parties: parties, + threshold: threshold, + pIDs: pIDs, + pubX: pubX, + pubY: pubY, + pubKey: pubKey, + addr: addr, + fundOutpt: fundOutpoint, + inputAmt: inputAmount, + outputAmt: outputAmount, + recipAddr: recipAddr, + unsignedHex: unsignedHex, + sighash: sighash, + tssR: tssSig.R, + tssS: tssSig.S, + sigDER: sigDER, + tx: tx, + txid: txid, + signedHex: signedHex, + pkScript: pkScript, + recipScript: recipScript, + p2pkhScript: p2pkhScript, + pkHash: pkHash, + preParams: preParamsDur, + keygen: keygenDur, + sign: signDur, + } + printEvidence(ev) +} + +// mustP2PKH returns a P2PKH address for pkHash on the given params. +// It's a small convenience so the caller above doesn't have to +// handle the error inline and clutter the flow. +func mustP2PKH(t *testing.T, params *chaincfg.Params, pkHash []byte) btcutil.Address { + t.Helper() + a, err := btcutil.NewAddressPubKeyHash(pkHash, params) + if err != nil { + t.Fatal(err) + } + return a +} + +// tssPubKeyToBtcec converts a tss-lib *crypto.ECPoint into a btcec +// *PublicKey via the SEC1 compressed encoding. The roundtrip +// through ParsePubKey enforces on-curve validation: a TSS +// implementation bug (or a malicious coordinator supplying a +// forged point) would fail here rather than silently construct a +// garbage pubkey via direct field-element assembly. Downstream +// wallets integrating TSS should follow this pattern — never +// btcec.NewPublicKey(fx, fy) without a curve check. +func tssPubKeyToBtcec(t *testing.T, pub *crypto.ECPoint) *btcec.PublicKey { + t.Helper() + + // SEC1 compressed: 0x02 if Y is even, 0x03 if Y is odd, then X + // padded to 32 bytes. + xBytes := pub.X().Bytes() + if len(xBytes) > 32 { + t.Fatalf("TSS pubkey X is %d bytes, expected <= 32", len(xBytes)) + } + compressed := make([]byte, 33) + if pub.Y().Bit(0) == 0 { + compressed[0] = 0x02 + } else { + compressed[0] = 0x03 + } + copy(compressed[1+32-len(xBytes):], xBytes) + + pk, err := btcec.ParsePubKey(compressed) + if err != nil { + t.Fatalf("ParsePubKey rejected TSS-produced pubkey: %v", err) + } + return pk +} + +// evidence collects every value the final paste-able block needs. +// Grouping them in a struct keeps the test body flat and lets +// printEvidence be pure output with no logic. +type evidence struct { + parties int + threshold int + pIDs tss.SortedPartyIDs + + pubX, pubY *big.Int + pubKey *btcec.PublicKey + addr btcutil.Address + + fundOutpt *wire.OutPoint + inputAmt int64 + outputAmt int64 + recipAddr btcutil.Address + unsignedHex string + + sighash []byte + tssR []byte + tssS []byte + sigDER []byte + + tx *wire.MsgTx + txid string + signedHex string + + pkScript []byte + recipScript []byte + p2pkhScript []byte + pkHash []byte + + preParams time.Duration + keygen time.Duration + sign time.Duration +} + +// printEvidence renders the paste-able evidence block. Output-only: +// it performs no assertions or computation beyond hex/decimal +// formatting and script disassembly. All assertions are in the +// calling test (Phases 9 and 11). +func printEvidence(e evidence) { + fmt.Println() + fmt.Println("============================================================") + fmt.Println(" TSS 2-of-3 BITCOIN SPEND — EVIDENCE BLOCK") + fmt.Println("============================================================") + fmt.Println() + fmt.Println("Committee:") + for i := 0; i < e.parties; i++ { + fmt.Printf(" party %d: id=%s moniker=%s index=%d\n", + i, e.pIDs[i].Id, e.pIDs[i].Moniker, e.pIDs[i].Index) + } + fmt.Printf(" threshold: %d (i.e. %d-of-%d)\n", + e.threshold, e.threshold+1, e.parties) + fmt.Println(" curve: secp256k1") + fmt.Println() + fmt.Println("Group public key (no single party ever held the priv):") + fmt.Printf(" X (hex): %064x\n", e.pubX) + fmt.Printf(" Y (hex): %064x\n", e.pubY) + fmt.Printf(" compressed: %x\n", e.pubKey.SerializeCompressed()) + fmt.Println() + fmt.Println("Address (testnet P2WPKH controlled by the committee):") + fmt.Printf(" %s\n", e.addr.EncodeAddress()) + fmt.Println() + fmt.Println("Unsigned transaction (testnet):") + fmt.Printf(" funding outpoint: %s:%d\n", + e.fundOutpt.Hash.String(), e.fundOutpt.Index) + fmt.Printf(" funding amount: %d sat\n", e.inputAmt) + fmt.Printf(" output amount: %d sat\n", e.outputAmt) + fmt.Printf(" fee: %d sat\n", e.inputAmt-e.outputAmt) + fmt.Printf(" recipient: %s\n", e.recipAddr.EncodeAddress()) + fmt.Printf(" raw hex: %s\n", e.unsignedHex) + fmt.Println() + fmt.Println("BIP-143 sighash (the 32 bytes the committee actually signed):") + fmt.Printf(" %x\n", e.sighash) + fmt.Println() + fmt.Println("TSS signature (raw scalars from the 9-round ceremony):") + fmt.Printf(" R (hex): %064x\n", new(big.Int).SetBytes(e.tssR)) + fmt.Printf(" S (hex): %064x\n", new(big.Int).SetBytes(e.tssS)) + fmt.Printf(" DER (%d bytes): %x\n", len(e.sigDER), e.sigDER) + fmt.Println() + fmt.Println("Signed transaction (wire format, ready for broadcast):") + fmt.Printf(" txid: %s\n", e.txid) + fmt.Printf(" witness: [sig||sighashByte=%dB, pubkey=%dB]\n", + len(e.tx.TxIn[0].Witness[0]), len(e.tx.TxIn[0].Witness[1])) + fmt.Printf(" raw hex:\n %s\n", e.signedHex) + fmt.Println() + printScripts(e) + fmt.Println() + fmt.Println("Independent verification:") + fmt.Println(" # decode on any machine with Bitcoin Core installed:") + fmt.Printf(" bitcoin-cli -testnet decoderawtransaction %s\n", e.signedHex) + fmt.Println(" # note: the funding outpoint is a test-fabricated hash,") + fmt.Println(" # so the tx will NOT confirm on real testnet -- but the") + fmt.Println(" # SIGNATURE in the witness is cryptographically valid") + fmt.Println(" # against the sighash above and the committee's pubkey.") + fmt.Println() + fmt.Println("Validation verdicts:") + fmt.Println(" VerifyECDSA (pre-broadcast gate): ACCEPTED") + fmt.Println(" btcd script engine (consensus): ACCEPTED") + fmt.Println() + fmt.Println("Timing:") + fmt.Printf(" Paillier pre-params: %.2fs\n", e.preParams.Seconds()) + fmt.Printf(" 4-round keygen: %.2fs\n", e.keygen.Seconds()) + fmt.Printf(" 9-round signing: %.2fs\n", e.sign.Seconds()) + fmt.Println() + fmt.Println("Library versions:") + fmt.Println(" tss-lib: github.com/hemilabs/x/tss-lib/v3") + fmt.Println(" bitcoin primitives: github.com/btcsuite/btcd") + fmt.Println(" wallet under test: github.com/hemilabs/heminetwork/v2/bitcoin/wallet") + fmt.Println("============================================================") +} + +// printScripts renders the script-disassembly and stack-execution +// trace sub-block of the evidence output. Disassembly errors are +// rendered inline as "" so the block remains +// paste-able even if one decode fails. +func printScripts(e evidence) { + disasm := func(s []byte) string { + out, err := txscript.DisasmString(s) + if err != nil { + return fmt.Sprintf("", err) + } + return out + } + + witSig := e.tx.TxIn[0].Witness[0] + witPub := e.tx.TxIn[0].Witness[1] + witSigDER := witSig[:len(witSig)-1] + witSigHashType := witSig[len(witSig)-1] + + fmt.Println("Script disassembly:") + fmt.Println(" Input 0 — prev pkScript (P2WPKH, v0 witness program):") + fmt.Printf(" hex: %x\n", e.pkScript) + fmt.Printf(" asm: %s\n", disasm(e.pkScript)) + fmt.Println() + fmt.Println(" Input 0 — scriptSig (empty; all data lives in the witness):") + fmt.Println(" hex: (empty)") + fmt.Println(" asm: (empty)") + fmt.Println() + fmt.Println(" Input 0 — witness stack (bottom -> top of stack):") + fmt.Printf(" [0] sig+hashType (%d bytes)\n", len(witSig)) + fmt.Printf(" DER sig: %x\n", witSigDER) + fmt.Printf(" hashType: 0x%02x (SigHashAll)\n", witSigHashType) + fmt.Printf(" [1] pubkey (%d bytes)\n", len(witPub)) + fmt.Printf(" compressed: %x\n", witPub) + fmt.Println() + fmt.Println(" Input 0 — BIP-143 script code (what the sighash commits to):") + fmt.Printf(" hex: %x\n", e.p2pkhScript) + fmt.Printf(" asm: %s\n", disasm(e.p2pkhScript)) + fmt.Println() + fmt.Println(" Execution trace (witness replayed against script code):") + fmt.Println(" initial stack: [ sig+hashType, pubkey ]") + fmt.Println(" OP_DUP -> [ sig+hashType, pubkey, pubkey ]") + fmt.Println(" OP_HASH160 -> [ sig+hashType, pubkey, HASH160(pubkey) ]") + fmt.Printf(" PUSH -> [ sig+hashType, pubkey, HASH160(pubkey), %x ]\n", e.pkHash) + fmt.Println(" OP_EQUALVERIFY -> [ sig+hashType, pubkey ] (pops top two, asserts equal)") + fmt.Println(" OP_CHECKSIG -> [ TRUE ] (ECDSA verify against sighash)") + fmt.Println() + fmt.Println(" Output 0 — recipient P2WPKH pkScript:") + fmt.Printf(" hex: %x\n", e.recipScript) + fmt.Printf(" asm: %s\n", disasm(e.recipScript)) +} + +// --------------------------------------------------------------------- +// tss-lib v3 ceremony drivers — copied verbatim from the canonical +// ecdsa/example_test.go in the module and trimmed to the keygen+sign +// slice this test needs. See that file for the reshare flow. +// --------------------------------------------------------------------- + +// runKeygen drives the 4-round tss-lib v3 ECDSA distributed keygen +// in-process, passing round messages between the n parties directly +// (no network transport). Round 1 produces VSS commitments and the +// Paillier pubkey. Round 2 produces per-peer P2P shares and the +// Schnorr/DLN proofs. Round 3 verifies decommitments and shares. +// Round 4 verifies the Paillier/mod/fac proofs and saves the +// LocalPartySaveData for each party. Returns the saves slice so the +// caller can drive signing with any threshold+1 subset of them. +func runKeygen( + t *testing.T, ctx context.Context, + n, threshold int, + pIDs tss.SortedPartyIDs, peerCtx *tss.PeerContext, + preParams []keygen.LocalPreParams, +) []keygen.LocalPartySaveData { + t.Helper() + + states := make([]*keygen.KeygenState, n) + r1 := make([]*tss.Message, n) + for i := 0; i < n; i++ { + params := tss.NewParameters(tss.S256(), peerCtx, pIDs[i], n, threshold) + st, out, err := keygen.Round1(ctx, params, preParams[i]) + if err != nil { + t.Fatalf("keygen.Round1[%d]: %v", i, err) + } + states[i] = st + r1[i] = out.Messages[0] + } + + r2p2p := make([][]*tss.Message, n) + r2bcast := make([]*tss.Message, n) + for i := range r2p2p { + r2p2p[i] = make([]*tss.Message, n) + } + for i := 0; i < n; i++ { + out, err := keygen.Round2(ctx, states[i], r1) + if err != nil { + t.Fatalf("keygen.Round2[%d]: %v", i, err) + } + for _, msg := range out.Messages { + if msg.To == nil { + r2bcast[i] = msg + } else { + for _, to := range msg.To { + r2p2p[to.Index][i] = msg + } + } + } + r2p2p[i][i] = states[i].ExportR2P2PSelf() + if r2bcast[i] == nil { + r2bcast[i] = states[i].ExportR2BcastSelf() + } + } + + r3 := make([]*tss.Message, n) + for i := 0; i < n; i++ { + out, err := keygen.Round3(ctx, states[i], r2p2p[i], r2bcast) + if err != nil { + t.Fatalf("keygen.Round3[%d]: %v", i, err) + } + r3[i] = out.Messages[0] + } + + saves := make([]keygen.LocalPartySaveData, n) + for i := 0; i < n; i++ { + out, err := keygen.Round4(ctx, states[i], r3) + if err != nil { + t.Fatalf("keygen.Round4[%d]: %v", i, err) + } + saves[i] = *out.Save + } + return saves +} + +// runSigning drives the 9-round tss-lib v3 ECDSA distributed signing +// plus the finalise step, producing a real threshold signature over +// the message m. Rounds 1-3 handle the MtA dance (P2P + broadcast); +// rounds 4-9 are all broadcast, assembling R and the partial +// signature shares; Finalise sums the partial sigs and returns the +// combined (R, S) pair in a *signing.SignatureData. Any party +// failing its round is fatal — this test expects an honest +// committee. +func runSigning( + t *testing.T, ctx context.Context, + n, threshold int, + pIDs tss.SortedPartyIDs, peerCtx *tss.PeerContext, + saves []keygen.LocalPartySaveData, m *big.Int, +) *signing.SignatureData { + t.Helper() + + states := make([]*signing.SigningState, n) + r1p2p := make([][]*tss.Message, n) + r1bcast := make([]*tss.Message, n) + for i := range r1p2p { + r1p2p[i] = make([]*tss.Message, n) + } + for i := 0; i < n; i++ { + params := tss.NewParameters(tss.S256(), peerCtx, pIDs[i], n, threshold) + st, out, err := signing.SignRound1(params, saves[i], m, nil, 0) + if err != nil { + t.Fatalf("SignRound1[%d]: %v", i, err) + } + states[i] = st + for _, msg := range out.Messages { + if msg.To == nil { + r1bcast[i] = msg + } else { + for _, to := range msg.To { + r1p2p[to.Index][i] = msg + } + } + } + } + + r2p2p := make([][]*tss.Message, n) + for i := range r2p2p { + r2p2p[i] = make([]*tss.Message, n) + } + for i := 0; i < n; i++ { + out, err := signing.SignRound2(ctx, states[i], r1p2p[i], r1bcast) + if err != nil { + t.Fatalf("SignRound2[%d]: %v", i, err) + } + for _, msg := range out.Messages { + for _, to := range msg.To { + r2p2p[to.Index][i] = msg + } + } + } + + r3 := make([]*tss.Message, n) + for i := 0; i < n; i++ { + out, err := signing.SignRound3(ctx, states[i], r2p2p[i]) + if err != nil { + t.Fatalf("SignRound3[%d]: %v", i, err) + } + r3[i] = out.Messages[0] + } + + r4 := bcastRound(t, n, states, func(i int) (*signing.SignRoundOutput, error) { + return signing.SignRound4(states[i], r3) + }, "Round4") + r5 := bcastRound(t, n, states, func(i int) (*signing.SignRoundOutput, error) { + return signing.SignRound5(states[i], r4) + }, "Round5") + r6 := bcastRound(t, n, states, func(i int) (*signing.SignRoundOutput, error) { + return signing.SignRound6(states[i]) + }, "Round6") + r7 := bcastRound(t, n, states, func(i int) (*signing.SignRoundOutput, error) { + return signing.SignRound7(states[i], r5, r6) + }, "Round7") + r8 := bcastRound(t, n, states, func(i int) (*signing.SignRoundOutput, error) { + return signing.SignRound8(states[i]) + }, "Round8") + r9 := bcastRound(t, n, states, func(i int) (*signing.SignRoundOutput, error) { + return signing.SignRound9(states[i], r7, r8) + }, "Round9") + + out, err := signing.SignFinalize(states[0], r9) + if err != nil { + t.Fatalf("SignFinalize: %v", err) + } + return out.Signature +} + +// bcastRound runs a single all-broadcast signing round across all n +// parties and collects each party's output into a slice addressable +// by sender index. Rounds 4-9 of tss-lib v3 ECDSA signing have no +// P2P component; this helper factors their identical invocation +// shape into one place so runSigning reads as a sequence of round +// names rather than six copies of the same loop. +func bcastRound( + t *testing.T, n int, + states []*signing.SigningState, + fn func(int) (*signing.SignRoundOutput, error), + name string, +) []*tss.Message { + t.Helper() + msgs := make([]*tss.Message, n) + for i := 0; i < n; i++ { + out, err := fn(i) + if err != nil { + t.Fatalf("%s[%d]: %v", name, i, err) + } + msgs[i] = out.Messages[0] + } + return msgs +} diff --git a/go.mod b/go.mod index cf49983e5..c77b31a98 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/ethereum/go-ethereum v1.17.2 github.com/go-test/deep v1.1.1 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/hemilabs/x/tss-lib/v3 v3.0.0-20260507172513-c23bec7119b9 github.com/juju/loggo/v2 v2.2.0 github.com/mitchellh/go-homedir v1.1.0 github.com/otiai10/copy v1.14.1 @@ -37,6 +38,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect github.com/aead/siphash v1.0.1 // indirect + github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.22.0 // indirect github.com/btcsuite/btclog v1.0.0 // indirect @@ -51,6 +53,7 @@ require ( github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect + github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/go-units v0.5.0 // indirect @@ -60,10 +63,15 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/holiman/uint256 v1.3.2 // indirect + github.com/ipfs/go-log v1.0.5 // indirect + github.com/ipfs/go-log/v2 v2.1.3 // indirect github.com/kkdai/bstream v1.0.0 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect @@ -79,7 +87,9 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/otiai10/mint v1.6.3 // indirect + github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect @@ -100,6 +110,8 @@ require ( go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect golang.org/x/net v0.53.0 // indirect diff --git a/go.sum b/go.sum index ca61d512e..c578231c9 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,7 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -16,6 +17,8 @@ github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMG github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI= +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= @@ -84,6 +87,7 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= @@ -103,6 +107,8 @@ github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/crypto/ripemd160 v1.0.2 h1:TvGTmUBHDU75OHro9ojPLK+Yv7gDl2hnUvRocRCjsys= github.com/decred/dcrd/crypto/ripemd160 v1.0.2/go.mod h1:uGfjDyePSpa75cSQLzNdVmWlbQMBuiJkvXw/MNKRY4M= +github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 h1:l/lhv2aJCUignzls81+wvga0TFlyoZx8QxRMQgXpZik= +github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3/go.mod h1:AKpV6+wZ2MfPRJnTbQ6NPgWrKzbe9RCIlCF/FKzMtM8= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= @@ -170,6 +176,7 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -181,8 +188,14 @@ github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasn github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hemilabs/x/tss-lib/v3 v3.0.0-20260507172513-c23bec7119b9 h1:RzxgFQVntLPmcdvqZyhNdCFb4PMumKHCd3FpLpJjxok= +github.com/hemilabs/x/tss-lib/v3 v3.0.0-20260507172513-c23bec7119b9/go.mod h1:nqQUFMnjplhN5uU51ijDK8odk1aqkRUAoio87uw9BcU= github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= @@ -192,6 +205,10 @@ github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXei github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= +github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= +github.com/ipfs/go-log/v2 v2.1.3 h1:1iS3IU7aXRlbgUpN8yTTpJ53NXYjAe37vcI5+5nYrzk= +github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -199,6 +216,8 @@ github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJS github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/juju/loggo/v2 v2.2.0 h1:sXcqSo1Kt14HAJSgohBhd975yd89F0a+Ei7PTKLRsdo= github.com/juju/loggo/v2 v2.2.0/go.mod h1:647d6WvXBLj5lvka2qBvccr7vMIvF2KFkEH+0ZuFOUM= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/kkdai/bstream v1.0.0 h1:Se5gHwgp2VT2uHfDrkbbgbgEvV9cimLELwrPJctSjg8= github.com/kkdai/bstream v1.0.0/go.mod h1:FDnDOHt5Yx4p3FaHcioFT0QjDOtgUpvjeZqAs+NVZZA= @@ -206,8 +225,11 @@ github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uq github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -266,10 +288,19 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= +github.com/otiai10/jsonindent v0.0.0-20171116142732-447bf004320b/go.mod h1:SXIpH2WO0dyF5YBc6Iq8jc8TEJYe1Fk2Rc1EVYUdIgY= +github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= +github.com/otiai10/mint v1.3.2/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= +github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11 h1:7x5D/2dkkr27Tgh4WFuX+iCS6OzuE5YJoqJzeqM+5mc= +github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11/go.mod h1:1DmRMnU78i/OVkMnHzvhXSi4p8IhYUmtLJWhyOavJc0= github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= @@ -280,6 +311,7 @@ github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -295,17 +327,20 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY= github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -313,6 +348,8 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -331,10 +368,14 @@ github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9R github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= +github.com/urfave/cli v1.22.5 h1:lNq9sAHXK2qfdI8W+GRItjCEkI+2oR4d+MEHy1CKXoU= +github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -357,25 +398,48 @@ go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -388,6 +452,7 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -404,6 +469,15 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -422,14 +496,17 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= @@ -439,5 +516,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= From 4ec69439eb226a7ce8dd2de91bafa4ff201807f6 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Fri, 17 Apr 2026 07:44:28 -0700 Subject: [PATCH 19/25] docs(changelog): add wallet TSS external signing entries Record the wallet changes introduced by this branch under the Unreleased section: external ECDSA/schnorr signature injection (TransactionApplyECDSA, TransactionApplySchnorr, ECDSASigFromRS, VerifyECDSA, VerifySchnorr), native P2WPKH and BIP-86 P2TR key-path signing in TransactionSign, and the TSSNamedKey storage type with its PutTSSKey/GetTSSKey/PurgeTSSKey/LookupTSSKeyByAddr interface on zuul. The prevOuts return-type change on TransactionCreate and PoPTransactionCreate is recorded under Changed as a minor breaking change for external consumers; internal callers (service/popm) treat prevOuts opaquely and are unaffected. --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 179fd0c18..81d1e9e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `TxByID` to the `gozer.Gozer` interface with `tbcGozer` implementation backed by TBC RPC ([#971](https://github.com/hemilabs/heminetwork/pull/971)). +- Add external ECDSA and schnorr signature injection to `bitcoin/wallet`, + enabling threshold signature committees, hardware wallets, and PSBT flows + to produce signatures out of band and hand them to the wallet for + witness/sigScript assembly. Includes `TransactionApplyECDSA`, + `TransactionApplySchnorr`, `ECDSASigFromRS` DER helper, and + `VerifyECDSA`/`VerifySchnorr` pre-broadcast gates. +- Add `bitcoin/zuul.TSSNamedKey` storage for keys controlled by an external + threshold signature scheme, alongside symmetrical `PutTSSKey` / + `GetTSSKey` / `PurgeTSSKey` / `LookupTSSKeyByAddr` interface methods. ### Changed From d9111a59fc0da9b172f999b69943fa53ec2fcd5e Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Fri, 17 Apr 2026 07:53:28 -0700 Subject: [PATCH 20/25] test(wallet): cover error paths flagged by codecov Codecov reported 77.88% patch coverage with 69 uncovered lines on the branch. The vast majority were error-return branches reachable from the public API but not exercised by the existing test suite. Add targeted tests to close the callable gaps: - UtxoPickerMultiple / UtxoPickerSingle no-match and skip-too-small paths. - TransactionSign error-wrap paths for unknown P2WPKH and P2TR keys, confirming the per-class dispatch propagates resolveInput- SigningKey failures with input index and class in the wrapping. - prevOutsFetcher defensive panic on a malformed outpoint key, asserted via recover(). Without this guard, a caller-crafted bad key would silently produce a corrupt sighash midstate. - TransactionApplyECDSA address-mismatch rejection on the P2WPKH branch of pubKeyMatchesAddress, the sibling to the existing P2PKH-branch coverage. - TransactionApplySchnorr witness assembly for non-SigHashDefault sighash types, verifying the trailing sighash byte is appended per BIP-341. Lifts statement coverage from 85.8% to 90.8% in bitcoin/wallet; patch coverage on the branch rises accordingly. The remaining uncovered branches are defensive wraps around btcd library calls that cannot fail on well-formed inputs (address derivation from valid pubkey bytes, SignatureScript building, etc.) and are not reachable without mocking dependencies Tobias forbids mocking. --- bitcoin/wallet/coverage_gaps_test.go | 97 ++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/bitcoin/wallet/coverage_gaps_test.go b/bitcoin/wallet/coverage_gaps_test.go index 7ab93012d..01773da7b 100644 --- a/bitcoin/wallet/coverage_gaps_test.go +++ b/bitcoin/wallet/coverage_gaps_test.go @@ -233,3 +233,100 @@ func TestPrevOutsFetcherPanicsOnMalformedKey(t *testing.T) { _ = prevOutsFetcher(bad) } + +// TestTransactionApplyECDSAP2WPKHWrongKey covers the P2WPKH branch +// of pubKeyMatchesAddress — the sibling to +// TestTransactionApplyECDSAWrongKey which exercises the P2PKH +// branch. Provides a pubkey whose HASH160 does not match the +// witness program in the prev pkScript. +func TestTransactionApplyECDSAP2WPKHWrongKey(t *testing.T) { + params := &chaincfg.TestNet3Params + + owner, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + wrongKey, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + // pkScript is a P2WPKH locked to owner's key. + ownerHash := btcutil.Hash160(owner.PubKey().SerializeCompressed()) + addr, err := btcutil.NewAddressWitnessPubKeyHash(ownerHash, params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("p2wpkh-wrong-key")) + outpoint := wire.NewOutPoint(&fundHash, 0) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(outpoint, nil, nil)) + prev := wire.NewTxOut(50_000, pkScript) + + // Produce a well-formed sigDER so we get past the parse gate. + sigDER := signWithKeyToDER(wrongKey, chainhash.HashB([]byte("x"))) + + // Call with wrongKey's pubkey — must be rejected at the + // pubKeyMatchesAddress check. + err = TransactionApplyECDSA(params, tx, 0, prev, wrongKey.PubKey(), + sigDER, txscript.SigHashAll) + if err == nil { + t.Fatal("expected error for wrong pubkey on P2WPKH input") + } + if !strings.Contains(err.Error(), "p2wpkh") { + t.Fatalf("expected error to reference 'p2wpkh', got: %v", err) + } +} + +// TestTransactionApplySchnorrNonDefaultSigHash verifies the witness +// assembly for a taproot input signed with a non-SigHashDefault +// sighash type. BIP-341 specifies that the sighash byte is +// appended to the 64-byte signature only when the type is not the +// default; a 65-byte witness element is produced. +func TestTransactionApplySchnorrNonDefaultSigHash(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + addr, err := btcutil.NewAddressTaproot( + outputKey.SerializeCompressed()[1:], params) + if err != nil { + t.Fatal(err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + + fundHash := chainhash.DoubleHashH([]byte("schnorr-nondefault-sighash")) + outpoint := wire.NewOutPoint(&fundHash, 0) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(outpoint, nil, nil)) + + prev := wire.NewTxOut(50_000, pkScript) + sig64 := make([]byte, 64) + + err = TransactionApplySchnorr(params, tx, 0, prev, priv.PubKey(), + sig64, txscript.SigHashAll) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Witness element 0 must be 65 bytes: 64-byte sig + 1 hashtype. + if got := len(tx.TxIn[0].Witness[0]); got != 65 { + t.Fatalf("non-default sighash witness: got %d bytes, want 65", + got) + } + if tx.TxIn[0].Witness[0][64] != byte(txscript.SigHashAll) { + t.Fatalf("trailing sighash byte: got 0x%02x, want 0x%02x", + tx.TxIn[0].Witness[0][64], byte(txscript.SigHashAll)) + } +} From 9f4857976684e0f1afd0b7c5029c2e7a77ba9be8 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Sun, 19 Apr 2026 01:45:22 -0700 Subject: [PATCH 21/25] test(wallet): cover taproot witness crypto negative paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing tests cover happy-path signing and verification for the BIP-86 key-path P2TR signer, the external schnorr injection helper, and VerifySchnorr. This adds the adversarial and structural cases that prove the primitives actually enforce the BIP-341 commitments they claim to: - tampered-tx: signed tx survives mutation of output value, output script, input sequence, or prev amount. Each mutation must invalidate the witness. The prev-amount case is distinctly taproot — BIP-341 commits to every input's prev-amount, segwit v0 does not. - wrong-key: spend a UTXO whose pkScript commits to key B using only key A in zuul. Must fail with the lookup-key-does-not-exist error rather than silently signing. - malformed pkScript at the resolve-input-signing-key layer: truncated push opcodes and OP_RETURN. Both must error out before any signing attempt. - pubKeyMatchesTaprootAddress: rejects the wrong key, rejects an untweaked internal key when the pkScript commits to a raw pubkey (demonstrates the helper applies BIP-86 tweak correctly), rejects malformed pkScripts. - VerifySchnorr parse failures: a 64-byte signature whose r is the field prime (out of range) forces schnorr.ParseSignature to error; a 32-byte pubkey with x = field prime forces schnorr.ParsePubKey to error. These exercise the two parse branches missed by structural (length-based) negative tests. - schnorr external-sign roundtrip: compute BIP-341 sighash, sign externally with the tweaked key, VerifySchnorr against the tweaked x-only output key, TransactionApplySchnorr, verify via script engine. Plus bit-flip negative control. - cross-input replay: two txs spending different outpoints under the same pkScript. Swapping the witness between them must fail — sighash commits to the outpoint. Coverage lift in bitcoin/wallet: before: 91.2% after: 92.9% VerifySchnorr 86.7% -> 100.0% pubKeyMatchesTaprootAddress 75.0% -> 83.3% resolveInputSigningKey 72.7% -> 81.8% The remaining gap is unreachable without mocking btcd: ExtractPkScriptAddrs never returns a non-nil error in btcd v0.24.3 (every branch returns nil), and btcutil.NewAddressTaproot only rejects non-32-byte input which schnorr.SerializePubKey never produces. The defensive error-returning code stays for forward compatibility if btcd tightens its parsers. --- bitcoin/wallet/taproot_crypto_test.go | 494 ++++++++++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 bitcoin/wallet/taproot_crypto_test.go diff --git a/bitcoin/wallet/taproot_crypto_test.go b/bitcoin/wallet/taproot_crypto_test.go new file mode 100644 index 000000000..086cde3d1 --- /dev/null +++ b/bitcoin/wallet/taproot_crypto_test.go @@ -0,0 +1,494 @@ +// Copyright (c) 2026 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package wallet + +import ( + "bytes" + "encoding/hex" + "strings" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + + "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul" + "github.com/hemilabs/heminetwork/v2/bitcoin/wallet/zuul/memory" +) + +// The tests in this file focus on the taproot witness crypto +// introduced by the P2TR key-path signer, the schnorr external- +// signing helpers, and the verify helpers. Happy paths already +// have coverage in wallet_p2tr_test.go, external_sign_schnorr_test.go, +// and verify_test.go; this file fills in the negative paths that +// exercise the crypto primitives themselves rather than plumbing. + +// --- helpers --------------------------------------------------------- + +// newP2TRKey returns a fresh private key plus the BIP-86 tweaked +// address and pkScript paying to its output key. No script +// commitment (nil script root). +func newP2TRKey(t *testing.T, params *chaincfg.Params) (*btcec.PrivateKey, btcutil.Address, []byte) { + t.Helper() + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + outputKey := txscript.ComputeTaprootKeyNoScript(priv.PubKey()) + addr, err := btcutil.NewAddressTaproot(schnorr.SerializePubKey(outputKey), params) + if err != nil { + t.Fatal(err) + } + script, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + return priv, addr, script +} + +// singleInputTxAt builds a one-input, one-output tx spending +// (fundHash, 0) of value `val` under pkScript, sending half to a +// throwaway P2TR output on the same params. Returned PrevOuts +// contains the single prevout. +func singleInputTxAt(t *testing.T, params *chaincfg.Params, pkScript []byte, val int64, fundTag string) (*wire.MsgTx, PrevOuts, *wire.OutPoint) { + t.Helper() + + fundHash := chainhash.DoubleHashH([]byte(fundTag)) + outpoint := wire.NewOutPoint(&fundHash, 0) + + _, _, destScript := newP2TRKey(t, params) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(outpoint, nil, nil)) + tx.AddTxOut(wire.NewTxOut(val/2, destScript)) + + prevOuts := PrevOuts{ + outpoint.String(): wire.NewTxOut(val, pkScript), + } + return tx, prevOuts, outpoint +} + +// --- signP2TRKeyPath negative paths ---------------------------------- + +// TestSignP2TRKeyPathRejectsTamperedTx confirms the BIP-341 sighash +// algorithm commits to the transaction: changing any committed field +// after signing invalidates the witness. This is the core guarantee +// callers rely on for taproot — any mutation between sign and broadcast +// is detectable by the verifier. +// +// Covers: signP2TRKeyPath happy path plus script-engine rejection +// across amount, outpoint, and output-value tampering. +func TestSignP2TRKeyPathRejectsTamperedTx(t *testing.T) { + params := &chaincfg.TestNet3Params + + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + priv, _, pkScript := newP2TRKey(t, params) + if err := m.PutKey(&zuul.NamedKey{Name: "tap", PrivateKey: priv}); err != nil { + t.Fatal(err) + } + + const fundValue int64 = 250_000 + + cases := []struct { + name string + tamper func(tx *wire.MsgTx, prevOuts PrevOuts) + }{ + { + name: "mutate output value", + tamper: func(tx *wire.MsgTx, _ PrevOuts) { + tx.TxOut[0].Value++ + }, + }, + { + name: "mutate output script", + tamper: func(tx *wire.MsgTx, _ PrevOuts) { + tx.TxOut[0].PkScript = append([]byte{}, tx.TxOut[0].PkScript...) + tx.TxOut[0].PkScript[len(tx.TxOut[0].PkScript)-1] ^= 0x01 + }, + }, + { + name: "mutate input sequence", + tamper: func(tx *wire.MsgTx, _ PrevOuts) { + tx.TxIn[0].Sequence ^= 1 + }, + }, + { + name: "mutate prev amount (BIP-341 commits to amount)", + tamper: func(_ *wire.MsgTx, prevOuts PrevOuts) { + // Taproot sighash (BIP-341) commits to every + // input's prev-amount; legacy sighash does not. + // Bumping the prev value by one sat after + // signing invalidates the witness. + for k, v := range prevOuts { + prevOuts[k] = wire.NewTxOut(v.Value+1, v.PkScript) + break + } + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tx, prevOuts, _ := singleInputTxAt(t, params, pkScript, + fundValue, "tamper-"+tc.name) + if err := TransactionSign(params, m, tx, prevOuts); err != nil { + t.Fatalf("TransactionSign: %v", err) + } + // Pre-tamper: verify happy path. + if err := verifyInput(tx, 0, prevOuts); err != nil { + t.Fatalf("pre-tamper engine failure: %v", err) + } + // Re-fetch prev from the possibly-mutated key. + tc.tamper(tx, prevOuts) + if err := verifyInput(tx, 0, prevOuts); err == nil { + t.Fatal("expected engine rejection of tampered tx") + } + }) + } +} + +// TestSignP2TRKeyPathWrongKey stores key A in zuul under a pkScript +// that actually commits to key B's output key, then invokes +// TransactionSign. resolveInputSigningKey has no way to notice — it +// looks up by the address extracted from pkScript, which is key B's +// address, which has no entry. The signer must fail with a key-not- +// found error rather than silently signing with whatever key happens +// to share storage. +// +// Covers: resolveInputSigningKey not-found path (wallet.go:240-242). +func TestSignP2TRKeyPathWrongKey(t *testing.T) { + params := &chaincfg.TestNet3Params + + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + keyA, _, _ := newP2TRKey(t, params) + _, _, scriptB := newP2TRKey(t, params) // key B is not stored + + if err := m.PutKey(&zuul.NamedKey{Name: "A", PrivateKey: keyA}); err != nil { + t.Fatal(err) + } + + tx, prevOuts, _ := singleInputTxAt(t, params, scriptB, 50_000, "wrong-key") + err = TransactionSign(params, m, tx, prevOuts) + if err == nil { + t.Fatal("expected key-not-found error") + } + if !strings.Contains(err.Error(), "lookup key") { + t.Fatalf("expected lookup-key error, got: %v", err) + } +} + +// TestResolveInputSigningKeyMalformedPkScript exercises the +// ExtractPkScriptAddrs error path in resolveInputSigningKey. A +// pkScript containing only OP_RETURN + garbage extracts zero +// addresses on many btcd versions; an intentionally-truncated push +// opcode produces a parse error on all versions. +// +// Covers: resolveInputSigningKey extract-error path (wallet.go:230-232) +// and the zero-addresses guard (wallet.go:233-235). +func TestResolveInputSigningKeyMalformedPkScript(t *testing.T) { + params := &chaincfg.TestNet3Params + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + pkScript []byte + }{ + { + // OP_DATA_32 followed by fewer than 32 bytes — + // parser reports a short-push error. + name: "truncated push", + pkScript: append([]byte{0x20}, make([]byte, 10)...), + }, + { + // OP_RETURN payload — extracts zero addresses, + // hitting the len(addrs) != 1 guard. + name: "op_return", + pkScript: []byte{txscript.OP_RETURN, 0x04, 0xde, 0xad, 0xbe, 0xef}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := resolveInputSigningKey(params, m, tc.pkScript) + if err == nil { + t.Fatal("expected resolve error") + } + }) + } +} + +// --- pubKeyMatchesTaprootAddress negative paths ---------------------- + +// TestPubKeyMatchesTaprootAddressRejectsWrongKey produces a P2TR +// pkScript from one key and calls the helper with a different pubkey. +// The tweaked-pubkey equality check must reject. +// +// Covers: pubKeyMatchesTaprootAddress mismatch path +// (external_sign_schnorr.go mismatch return). +func TestPubKeyMatchesTaprootAddressRejectsWrongKey(t *testing.T) { + params := &chaincfg.TestNet3Params + _, _, script := newP2TRKey(t, params) + + other, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + err = pubKeyMatchesTaprootAddress(params, script, other.PubKey()) + if err == nil { + t.Fatal("expected mismatch error") + } + if !strings.Contains(err.Error(), "does not match") { + t.Fatalf("expected match-failure error, got: %v", err) + } +} + +// TestPubKeyMatchesTaprootAddressRejectsUntweakedKey feeds the helper +// an *untweaked* internal key when the pkScript commits to the +// tweaked output key. Demonstrates the helper applies BIP-86 tweak +// correctly: a caller who forgets to tweak (or double-tweaks) gets +// a clean error rather than silent divergence. +func TestPubKeyMatchesTaprootAddressRejectsUntweakedKey(t *testing.T) { + params := &chaincfg.TestNet3Params + + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + // Build a pkScript that commits to the *raw* internal pubkey + // (not the BIP-86 tweak), so that passing the same raw pubkey + // to pubKeyMatchesTaprootAddress causes the helper's internal + // tweak step to over-tweak and mismatch. + rawXOnly := schnorr.SerializePubKey(priv.PubKey()) + addr, err := btcutil.NewAddressTaproot(rawXOnly, params) + if err != nil { + t.Fatal(err) + } + script, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatal(err) + } + err = pubKeyMatchesTaprootAddress(params, script, priv.PubKey()) + if err == nil { + t.Fatal("expected mismatch error: raw pkScript vs tweaked-match") + } +} + +// TestPubKeyMatchesTaprootAddressRejectsMalformedPkScript covers the +// ExtractPkScriptAddrs error path (truncated push) and the +// zero-addresses guard (OP_RETURN). +// +// Covers: pubKeyMatchesTaprootAddress extract-error and +// len(addrs) != 1 paths (external_sign_schnorr.go:105-110). +func TestPubKeyMatchesTaprootAddressRejectsMalformedPkScript(t *testing.T) { + params := &chaincfg.TestNet3Params + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + pkScript []byte + }{ + {"truncated push", append([]byte{0x20}, make([]byte, 10)...)}, + {"op_return", []byte{txscript.OP_RETURN, 0x04, 0xde, 0xad, 0xbe, 0xef}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := pubKeyMatchesTaprootAddress(params, tc.pkScript, priv.PubKey()) + if err == nil { + t.Fatal("expected error on malformed pkScript") + } + }) + } +} + +// --- VerifySchnorr crypto parse paths -------------------------------- + +// TestVerifySchnorrRejectsMalformedSig feeds 64 bytes of +// well-structured garbage: the `r` component is a value not on +// the secp256k1 curve (x >= field prime). schnorr.ParseSignature +// rejects, so the helper's parse-error branch fires. +// +// Covers: VerifySchnorr ParseSignature error (verify.go:73-75). +func TestVerifySchnorrRejectsMalformedSig(t *testing.T) { + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + xOnly := schnorr.SerializePubKey(priv.PubKey()) + hash := chainhash.HashB([]byte("hash-for-parse-fail")) + + // schnorr sig layout: r (32) || s (32). Set r to the secp256k1 + // field prime p — explicitly invalid as a curve x-coordinate. + // Keep s any value < group order (but non-zero) so s-parsing + // does not short-circuit the r-parse failure. + sig := make([]byte, 64) + // p (field prime) big-endian. + p, err := hex.DecodeString("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f") + if err != nil { + t.Fatal(err) + } + copy(sig[0:32], p) + sig[63] = 1 // s = 1 + + err = VerifySchnorr(hash, sig, xOnly) + if err == nil { + t.Fatal("expected parse error for invalid r") + } + if !strings.Contains(err.Error(), "parse signature") { + t.Fatalf("expected parse-signature error, got: %v", err) + } +} + +// TestVerifySchnorrRejectsMalformedPubKey feeds 32 bytes that decode +// as an x-coordinate not on the curve. schnorr.ParsePubKey rejects, +// so the helper's pubkey-parse branch fires. +// +// Covers: VerifySchnorr ParsePubKey error (verify.go:77-79). +func TestVerifySchnorrRejectsMalformedPubKey(t *testing.T) { + priv, err := btcec.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + hash := chainhash.HashB([]byte("hash-for-pubkey-parse-fail")) + sig, err := schnorr.Sign(priv, hash) + if err != nil { + t.Fatal(err) + } + + // Set x = field prime p: fits in 32 bytes but exceeds the + // secp256k1 field modulus, so schnorr.ParsePubKey rejects it + // as an out-of-range x-coordinate before any curve check. + bad, err := hex.DecodeString("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f") + if err != nil { + t.Fatal(err) + } + + err = VerifySchnorr(hash, sig.Serialize(), bad) + if err == nil { + t.Fatal("expected parse error for invalid pubkey x-coordinate") + } + if !strings.Contains(err.Error(), "parse x-only pubkey") { + t.Fatalf("expected parse-pubkey error, got: %v", err) + } +} + +// --- Roundtrip end-to-end with external schnorr --------------------- + +// TestSchnorrExternalSignRoundTrip simulates the TSS flow end-to-end: +// compute the BIP-341 sighash outside the wallet, sign externally +// with the tweaked key, verify via VerifySchnorr against the tweaked +// x-only output key, then splice the 64-byte sig into the tx via +// TransactionApplySchnorr, and confirm the full witness satisfies +// the script engine. Also cross-checks that changing any bit of the +// sig causes verifyInput to reject. +func TestSchnorrExternalSignRoundTrip(t *testing.T) { + params := &chaincfg.TestNet3Params + priv, _, pkScript := newP2TRKey(t, params) + + tx, prevOuts, _ := singleInputTxAt(t, params, pkScript, 200_000, "roundtrip") + + // External signing: compute sighash, sign with tweaked key. + tweaked := txscript.TweakTaprootPrivKey(*priv, nil) + sighashes := txscript.NewTxSigHashes(tx, prevOutsFetcher(prevOuts)) + sigHash, err := txscript.CalcTaprootSignatureHash(sighashes, + txscript.SigHashDefault, tx, 0, prevOutsFetcher(prevOuts)) + if err != nil { + t.Fatal(err) + } + sig, err := schnorr.Sign(tweaked, sigHash) + if err != nil { + t.Fatal(err) + } + sig64 := sig.Serialize() + + // VerifySchnorr against the tweaked output key — this is the + // pre-broadcast check a TSS coordinator performs. + xOnlyOutput := schnorr.SerializePubKey(tweaked.PubKey()) + if err := VerifySchnorr(sigHash, sig64, xOnlyOutput); err != nil { + t.Fatalf("VerifySchnorr on freshly-produced sig failed: %v", err) + } + + // Splice into tx and verify via script engine. + prev := prevOuts[tx.TxIn[0].PreviousOutPoint.String()] + if err := TransactionApplySchnorr(params, tx, 0, prev, + priv.PubKey(), sig64, txscript.SigHashDefault); err != nil { + t.Fatalf("TransactionApplySchnorr: %v", err) + } + if err := verifyInput(tx, 0, prevOuts); err != nil { + t.Fatalf("engine rejected signed taproot input: %v", err) + } + + // Negative control: flip one bit of the sig, the engine rejects. + // We have to restore the witness, flip, splice again, verify. + sigFlipped := append([]byte{}, sig64...) + sigFlipped[0] ^= 0x01 + if err := VerifySchnorr(sigHash, sigFlipped, xOnlyOutput); err == nil { + t.Fatal("VerifySchnorr accepted a bit-flipped signature") + } + tx.TxIn[0].Witness = wire.TxWitness{sigFlipped} + if err := verifyInput(tx, 0, prevOuts); err == nil { + t.Fatal("engine accepted a bit-flipped taproot signature") + } +} + +// TestSchnorrCrossInputRejectsReplay builds two identical-shape +// transactions with different inputs and confirms that swapping a +// signature from one into the other fails the script engine. This +// is the cross-input replay defense baked into BIP-341: the taproot +// sighash commits to the input index and all prev-scripts. +func TestSchnorrCrossInputRejectsReplay(t *testing.T) { + params := &chaincfg.TestNet3Params + + m, err := memory.New(params) + if err != nil { + t.Fatal(err) + } + priv, _, pkScript := newP2TRKey(t, params) + if err := m.PutKey(&zuul.NamedKey{Name: "tap", PrivateKey: priv}); err != nil { + t.Fatal(err) + } + + // Two txs spending different outpoints under the same pkScript. + txA, prevA, _ := singleInputTxAt(t, params, pkScript, 100_000, "replay-A") + txB, prevB, _ := singleInputTxAt(t, params, pkScript, 100_000, "replay-B") + + if err := TransactionSign(params, m, txA, prevA); err != nil { + t.Fatal(err) + } + if err := TransactionSign(params, m, txB, prevB); err != nil { + t.Fatal(err) + } + + // Sanity: each verifies against its own prev. + if err := verifyInput(txA, 0, prevA); err != nil { + t.Fatalf("txA self-verify: %v", err) + } + if err := verifyInput(txB, 0, prevB); err != nil { + t.Fatalf("txB self-verify: %v", err) + } + + // Replay: swap witness from A into B. Sighash commits to + // the prev outpoint (via txid), so this must fail. + if bytes.Equal(txA.TxIn[0].Witness[0], txB.TxIn[0].Witness[0]) { + t.Fatal("two sighashes produced identical signatures; test premise broken") + } + txB.TxIn[0].Witness = txA.TxIn[0].Witness + if err := verifyInput(txB, 0, prevB); err == nil { + t.Fatal("engine accepted a cross-tx schnorr replay") + } +} From 1f3cb582944353b6859459e764daac1b7c0396e3 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Wed, 22 Apr 2026 09:39:17 -0700 Subject: [PATCH 22/25] refactor(zuul): distinguish cross-index key collisions PutKey now returns ErrTSSKeyOccupied when a TSS key blocks the address, and PutTSSKey returns ErrLocalKeyOccupied when a local key blocks it. Both wrap ErrKeyExists for backward compatibility. Update TestPutKeyVsPutTSSKeyCollision to assert the specific sentinel while also verifying the ErrKeyExists wrapping. --- bitcoin/wallet/zuul/memory/memory.go | 17 +++++++++-------- bitcoin/wallet/zuul/memory/memory_tss_test.go | 18 ++++++++++++------ bitcoin/wallet/zuul/zuul.go | 7 +++++++ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/bitcoin/wallet/zuul/memory/memory.go b/bitcoin/wallet/zuul/memory/memory.go index 3016a3b2e..c2917b648 100644 --- a/bitcoin/wallet/zuul/memory/memory.go +++ b/bitcoin/wallet/zuul/memory/memory.go @@ -93,10 +93,10 @@ func addressesForPubKey(params *chaincfg.Params, pubCompressed []byte) ([]string // of those addresses. // // All-or-nothing: if any of the derived addresses already maps to -// a stored key (local or TSS-controlled), the call returns -// ErrKeyExists without mutating state. This prevents a single -// PutKey from partially populating the index when a collision -// exists on one address form but not others. +// a stored local key the call returns ErrKeyExists; if mapped to a +// TSS key it returns ErrTSSKeyOccupied. Both wrap ErrKeyExists. +// This prevents a single PutKey from partially populating the index +// when a collision exists on one address form but not others. func (m *memoryZuul) PutKey(nk *zuul.NamedKey) error { pubBytes := nk.PrivateKey.PubKey().SerializeCompressed() addrs, err := addressesForPubKey(m.params, pubBytes) @@ -116,7 +116,7 @@ func (m *memoryZuul) PutKey(nk *zuul.NamedKey) error { return zuul.ErrKeyExists } if _, ok := m.tssKeys[a]; ok { - return zuul.ErrKeyExists + return zuul.ErrTSSKeyOccupied } } for _, a := range addrs { @@ -224,8 +224,9 @@ func ecdsaAddressesForPubKey(params *chaincfg.Params, pubCompressed []byte) ([]s // a BIP-341 key-path spend. // // Collisions are detected against both the local-key index and the -// TSS-key index: an address pointing to either form of key refuses -// the insert with ErrKeyExists. +// TSS-key index: an address pointing to a local key refuses the +// insert with ErrLocalKeyOccupied; a duplicate TSS key refuses with +// ErrKeyExists. func (m *memoryZuul) PutTSSKey(tnk *zuul.TSSNamedKey) error { if tnk == nil || tnk.PublicKey == nil { return fmt.Errorf("tss key: public key required") @@ -245,7 +246,7 @@ func (m *memoryZuul) PutTSSKey(tnk *zuul.TSSNamedKey) error { for _, a := range addrs { if _, ok := m.keys[a]; ok { - return zuul.ErrKeyExists + return zuul.ErrLocalKeyOccupied } if _, ok := m.tssKeys[a]; ok { return zuul.ErrKeyExists diff --git a/bitcoin/wallet/zuul/memory/memory_tss_test.go b/bitcoin/wallet/zuul/memory/memory_tss_test.go index dacd7e075..ab03738d0 100644 --- a/bitcoin/wallet/zuul/memory/memory_tss_test.go +++ b/bitcoin/wallet/zuul/memory/memory_tss_test.go @@ -175,8 +175,8 @@ func TestPutTSSKeyRequiresFields(t *testing.T) { // TestPutKeyVsPutTSSKeyCollision verifies that a local private key // and a TSS key cannot claim the same address: attempting to enrol -// one when the other already holds that address must fail with -// ErrKeyExists. +// one when the other already holds that address must fail with a +// specific cross-index error that wraps ErrKeyExists. func TestPutKeyVsPutTSSKeyCollision(t *testing.T) { m, err := New(&chaincfg.TestNet3Params) if err != nil { @@ -198,8 +198,11 @@ func TestPutKeyVsPutTSSKeyCollision(t *testing.T) { KeyID: []byte("kid"), PublicKey: priv.PubKey(), }) - if err == nil || !errors.Is(err, zuul.ErrKeyExists) { - t.Fatalf("expected ErrKeyExists for TSS after local, got %v", err) + if err == nil || !errors.Is(err, zuul.ErrLocalKeyOccupied) { + t.Fatalf("expected ErrLocalKeyOccupied for TSS after local, got %v", err) + } + if !errors.Is(err, zuul.ErrKeyExists) { + t.Fatalf("ErrLocalKeyOccupied must wrap ErrKeyExists, got %v", err) } // Reverse direction: fresh zuul, TSS first, local second. @@ -220,8 +223,11 @@ func TestPutKeyVsPutTSSKeyCollision(t *testing.T) { t.Fatal(err) } err = m2.PutKey(&zuul.NamedKey{Name: "local", PrivateKey: priv2}) - if err == nil || !errors.Is(err, zuul.ErrKeyExists) { - t.Fatalf("expected ErrKeyExists for local after TSS, got %v", err) + if err == nil || !errors.Is(err, zuul.ErrTSSKeyOccupied) { + t.Fatalf("expected ErrTSSKeyOccupied for local after TSS, got %v", err) + } + if !errors.Is(err, zuul.ErrKeyExists) { + t.Fatalf("ErrTSSKeyOccupied must wrap ErrKeyExists, got %v", err) } } diff --git a/bitcoin/wallet/zuul/zuul.go b/bitcoin/wallet/zuul/zuul.go index e3277f691..c7556965d 100644 --- a/bitcoin/wallet/zuul/zuul.go +++ b/bitcoin/wallet/zuul/zuul.go @@ -11,6 +11,7 @@ package zuul import ( "errors" + "fmt" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" @@ -20,6 +21,12 @@ import ( var ( ErrKeyExists = errors.New("key exists") ErrKeyDoesntExist = errors.New("key does not exist") + + // Cross-index collision sentinels. Both wrap ErrKeyExists so + // callers testing errors.Is(err, ErrKeyExists) continue to + // work, but the specific type of occupant is now visible. + ErrTSSKeyOccupied = fmt.Errorf("address occupied by tss key: %w", ErrKeyExists) + ErrLocalKeyOccupied = fmt.Errorf("address occupied by local key: %w", ErrKeyExists) ) // NamedKeyHD contains a private key with metadata. From 69c4ab0e43cbdb07fbcd6d8768800454d6a77c2a Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Thu, 7 May 2026 10:04:27 +0100 Subject: [PATCH 23/25] fix(wallet): address mechanical review feedback Improve error messages: use errors.New where no format verbs, clarify nil-argument and verify-failure wording. Remove underscore separators from number literals in tests. Replace append-copy idiom with preallocate+copy for sigWithHash and schnorr witness construction. --- bitcoin/wallet/coverage_gaps_test.go | 4 ++-- bitcoin/wallet/external_sign.go | 7 ++++--- bitcoin/wallet/external_sign_schnorr.go | 6 ++++-- bitcoin/wallet/gozer/tbcgozer/tbcgozer.go | 2 +- bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go | 4 ++-- bitcoin/wallet/verify.go | 9 +++++---- 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/bitcoin/wallet/coverage_gaps_test.go b/bitcoin/wallet/coverage_gaps_test.go index 01773da7b..7d147df81 100644 --- a/bitcoin/wallet/coverage_gaps_test.go +++ b/bitcoin/wallet/coverage_gaps_test.go @@ -266,7 +266,7 @@ func TestTransactionApplyECDSAP2WPKHWrongKey(t *testing.T) { outpoint := wire.NewOutPoint(&fundHash, 0) tx := wire.NewMsgTx(2) tx.AddTxIn(wire.NewTxIn(outpoint, nil, nil)) - prev := wire.NewTxOut(50_000, pkScript) + prev := wire.NewTxOut(50000, pkScript) // Produce a well-formed sigDER so we get past the parse gate. sigDER := signWithKeyToDER(wrongKey, chainhash.HashB([]byte("x"))) @@ -311,7 +311,7 @@ func TestTransactionApplySchnorrNonDefaultSigHash(t *testing.T) { tx := wire.NewMsgTx(2) tx.AddTxIn(wire.NewTxIn(outpoint, nil, nil)) - prev := wire.NewTxOut(50_000, pkScript) + prev := wire.NewTxOut(50000, pkScript) sig64 := make([]byte, 64) err = TransactionApplySchnorr(params, tx, 0, prev, priv.PubKey(), diff --git a/bitcoin/wallet/external_sign.go b/bitcoin/wallet/external_sign.go index cb8eb9777..2a35d50fd 100644 --- a/bitcoin/wallet/external_sign.go +++ b/bitcoin/wallet/external_sign.go @@ -99,7 +99,7 @@ func ECDSASigFromRS(r, s []byte) ([]byte, error) { // receive; the caller already has it. func TransactionApplyECDSA(params *chaincfg.Params, tx *wire.MsgTx, idx int, prev *wire.TxOut, pubKey *btcec.PublicKey, sigDER []byte, hashType txscript.SigHashType) error { if tx == nil || prev == nil || pubKey == nil { - return fmt.Errorf("nil argument") + return fmt.Errorf("tx, prev and pubKey cannot be nil") } if idx < 0 || idx >= len(tx.TxIn) { return fmt.Errorf("input index %d out of range (tx has %d inputs)", @@ -126,8 +126,9 @@ func TransactionApplyECDSA(params *chaincfg.Params, tx *wire.MsgTx, idx int, pre pubCompressed := pubKey.SerializeCompressed() // Attach sighash byte for script-engine consumption. - sigWithHash := append([]byte{}, sigDER...) - sigWithHash = append(sigWithHash, byte(hashType)) + sigWithHash := make([]byte, len(sigDER)+1) + copy(sigWithHash, sigDER) + sigWithHash[len(sigDER)] = byte(hashType) class := txscript.GetScriptClass(prev.PkScript) switch class { diff --git a/bitcoin/wallet/external_sign_schnorr.go b/bitcoin/wallet/external_sign_schnorr.go index 69ece21ce..d4f00b7d4 100644 --- a/bitcoin/wallet/external_sign_schnorr.go +++ b/bitcoin/wallet/external_sign_schnorr.go @@ -55,7 +55,7 @@ import ( // is valid" would be misled. func TransactionApplySchnorr(params *chaincfg.Params, tx *wire.MsgTx, idx int, prev *wire.TxOut, pubKey *btcec.PublicKey, sig64 []byte, hashType txscript.SigHashType) error { if tx == nil || prev == nil || pubKey == nil { - return fmt.Errorf("nil argument") + return fmt.Errorf("tx, prev and pubKey cannot be nil") } if idx < 0 || idx >= len(tx.TxIn) { return fmt.Errorf("input index %d out of range (tx has %d inputs)", @@ -87,7 +87,9 @@ func TransactionApplySchnorr(params *chaincfg.Params, tx *wire.MsgTx, idx int, p witness := sig64 if hashType != txscript.SigHashDefault { - witness = append(append([]byte{}, sig64...), byte(hashType)) + witness = make([]byte, len(sig64)+1) + copy(witness, sig64) + witness[len(sig64)] = byte(hashType) } tx.TxIn[idx].Witness = wire.TxWitness{witness} tx.TxIn[idx].SignatureScript = nil diff --git a/bitcoin/wallet/gozer/tbcgozer/tbcgozer.go b/bitcoin/wallet/gozer/tbcgozer/tbcgozer.go index 8435dd6a4..89274e06e 100644 --- a/bitcoin/wallet/gozer/tbcgozer/tbcgozer.go +++ b/bitcoin/wallet/gozer/tbcgozer/tbcgozer.go @@ -160,7 +160,7 @@ func (t *tbcGozer) BroadcastTx(ctx context.Context, tx *wire.MsgTx) (*chainhash. // unreachable. func (t *tbcGozer) TxByID(ctx context.Context, txid *chainhash.Hash) (*tbcapi.Tx, error) { if txid == nil { - return nil, errors.New("txid is nil") + return nil, errors.New("txid cannot be nil") } req := &tbcapi.TxByIdRequest{TxID: *txid} diff --git a/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go b/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go index f770269ee..84a0c514c 100644 --- a/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go +++ b/bitcoin/wallet/gozer/tbcgozer/tbcgozer_test.go @@ -432,8 +432,8 @@ func TestTBCGozerTxByIDNilTxid(t *testing.T) { if err == nil { t.Fatal("expected error for nil txid") } - if err.Error() != "txid is nil" { - t.Fatalf("expected 'txid is nil', got: %v", err) + if err.Error() != "txid cannot be nil" { + t.Fatalf("expected 'txid cannot be nil', got: %v", err) } } diff --git a/bitcoin/wallet/verify.go b/bitcoin/wallet/verify.go index 8c401f642..b24840105 100644 --- a/bitcoin/wallet/verify.go +++ b/bitcoin/wallet/verify.go @@ -5,6 +5,7 @@ package wallet import ( + "errors" "fmt" "github.com/btcsuite/btcd/btcec/v2" @@ -26,20 +27,20 @@ import ( // between parse errors and verification mismatches. func VerifyECDSA(sigHash, sigDER []byte, pubKey *btcec.PublicKey) error { if pubKey == nil { - return fmt.Errorf("nil pubkey") + return fmt.Errorf("pubkey cannot be nil") } if len(sigHash) != 32 { return fmt.Errorf("sighash must be 32 bytes, got %d", len(sigHash)) } if len(sigDER) == 0 { - return fmt.Errorf("empty signature") + return errors.New("empty signature") } sig, err := ecdsa.ParseDERSignature(sigDER) if err != nil { return fmt.Errorf("parse signature: %w", err) } if !sig.Verify(sigHash, pubKey) { - return fmt.Errorf("signature does not verify") + return errors.New("invalid signature") } return nil } @@ -78,7 +79,7 @@ func VerifySchnorr(sigHash, sig64, xOnlyPubKey []byte) error { return fmt.Errorf("parse x-only pubkey: %w", err) } if !sig.Verify(sigHash, pub) { - return fmt.Errorf("signature does not verify") + return errors.New("invalid signature") } return nil } From 92cfec17b1458c6be973ff2a71fa237e7cb3e262 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Thu, 7 May 2026 10:15:42 +0100 Subject: [PATCH 24/25] fix(wallet): use ParseDERSignature/ParseSignature for validation and re-encoding Round-trip ECDSA and schnorr signatures through parse+serialize to guarantee canonical encoding. Remove redundant length pre-checks and maxECDSASigDERLen constant since the parsers validate length internally. --- bitcoin/wallet/external_sign.go | 32 +++++++------------------ bitcoin/wallet/external_sign_schnorr.go | 23 +++++++++--------- 2 files changed, 20 insertions(+), 35 deletions(-) diff --git a/bitcoin/wallet/external_sign.go b/bitcoin/wallet/external_sign.go index 2a35d50fd..8a9ba9544 100644 --- a/bitcoin/wallet/external_sign.go +++ b/bitcoin/wallet/external_sign.go @@ -16,15 +16,6 @@ import ( "github.com/decred/dcrd/dcrec/secp256k1/v4" ) -// maxECDSASigDERLen is a generous upper bound on a DER-encoded -// ECDSA signature over secp256k1. The real maximum is 72 bytes -// (0x30 0x02 0x02 with leading -// zero bytes for r and s when their high bit is set). The cap -// gives headroom for encodings we might not have seen while still -// rejecting attacker-controlled buffers that would otherwise be -// copied in full before the DER parser runs. -const maxECDSASigDERLen = 128 - // ECDSASigFromRS assembles a DER-encoded ECDSA signature from raw // big-endian r and s scalar bytes as produced by many threshold // signature libraries (including hemilabs/x/tss-lib/v3). The @@ -105,30 +96,25 @@ func TransactionApplyECDSA(params *chaincfg.Params, tx *wire.MsgTx, idx int, pre return fmt.Errorf("input index %d out of range (tx has %d inputs)", idx, len(tx.TxIn)) } - if len(sigDER) == 0 { - return fmt.Errorf("empty signature") - } - if len(sigDER) > maxECDSASigDERLen { - return fmt.Errorf("signature exceeds max DER length: got %d, max %d", - len(sigDER), maxECDSASigDERLen) - } if err := validateSigHashType(hashType); err != nil { return err } - // Sanity check: ensure sigDER is parseable DER. This catches - // gross encoding errors early; full cryptographic verification - // is a caller choice via VerifyECDSA. - if _, err := ecdsa.ParseDERSignature(sigDER); err != nil { + // Parse and re-encode to guarantee canonical DER. + // ParseDERSignature validates length, structure, and padding; + // Serialize round-trips through the parsed R,S scalars. + sig, err := ecdsa.ParseDERSignature(sigDER) + if err != nil { return fmt.Errorf("parse signature: %w", err) } + canonicalDER := sig.Serialize() pubCompressed := pubKey.SerializeCompressed() // Attach sighash byte for script-engine consumption. - sigWithHash := make([]byte, len(sigDER)+1) - copy(sigWithHash, sigDER) - sigWithHash[len(sigDER)] = byte(hashType) + sigWithHash := make([]byte, len(canonicalDER)+1) + copy(sigWithHash, canonicalDER) + sigWithHash[len(canonicalDER)] = byte(hashType) class := txscript.GetScriptClass(prev.PkScript) switch class { diff --git a/bitcoin/wallet/external_sign_schnorr.go b/bitcoin/wallet/external_sign_schnorr.go index d4f00b7d4..7acc390c0 100644 --- a/bitcoin/wallet/external_sign_schnorr.go +++ b/bitcoin/wallet/external_sign_schnorr.go @@ -61,20 +61,19 @@ func TransactionApplySchnorr(params *chaincfg.Params, tx *wire.MsgTx, idx int, p return fmt.Errorf("input index %d out of range (tx has %d inputs)", idx, len(tx.TxIn)) } - if len(sig64) != 64 { - return fmt.Errorf("schnorr signature must be 64 bytes, got %d", - len(sig64)) - } if err := validateSigHashType(hashType); err != nil { return err } - // Parse to enforce BIP-340 encoding: upper bit of R.x and a few - // other canonicity rules. ParseSignature catches encodings that - // would be rejected by the script engine. - if _, err := schnorr.ParseSignature(sig64); err != nil { + // Parse and re-encode to guarantee BIP-340 canonical form. + // ParseSignature validates length, upper bit of R.x, and + // other canonicity rules; Serialize round-trips through the + // parsed R,S values. + sig, err := schnorr.ParseSignature(sig64) + if err != nil { return fmt.Errorf("parse signature: %w", err) } + canonical := sig.Serialize() class := txscript.GetScriptClass(prev.PkScript) if class != txscript.WitnessV1TaprootTy { @@ -85,11 +84,11 @@ func TransactionApplySchnorr(params *chaincfg.Params, tx *wire.MsgTx, idx int, p return fmt.Errorf("p2tr: %w", err) } - witness := sig64 + witness := canonical if hashType != txscript.SigHashDefault { - witness = make([]byte, len(sig64)+1) - copy(witness, sig64) - witness[len(sig64)] = byte(hashType) + witness = make([]byte, len(canonical)+1) + copy(witness, canonical) + witness[len(canonical)] = byte(hashType) } tx.TxIn[idx].Witness = wire.TxWitness{witness} tx.TxIn[idx].SignatureScript = nil From 975df461b18f8247af7d38025265de3de3a53078 Mon Sep 17 00:00:00 2001 From: Marco Peereboom Date: Sat, 9 May 2026 09:16:28 +0100 Subject: [PATCH 25/25] fix(wallet): use errors.New where no format verbs Caught by golangci-lint --fix; missed in prior review cleanup. --- bitcoin/wallet/external_sign.go | 15 ++++++++------- bitcoin/wallet/external_sign_schnorr.go | 5 +++-- bitcoin/wallet/verify.go | 2 +- bitcoin/wallet/zuul/memory/memory.go | 5 +++-- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/bitcoin/wallet/external_sign.go b/bitcoin/wallet/external_sign.go index 8a9ba9544..05d989af6 100644 --- a/bitcoin/wallet/external_sign.go +++ b/bitcoin/wallet/external_sign.go @@ -5,6 +5,7 @@ package wallet import ( + "errors" "fmt" "github.com/btcsuite/btcd/btcec/v2" @@ -28,21 +29,21 @@ import ( // or above the group order is rejected as invalid. func ECDSASigFromRS(r, s []byte) ([]byte, error) { if len(r) == 0 || len(s) == 0 { - return nil, fmt.Errorf("empty scalar") + return nil, errors.New("empty scalar") } var rs, ss secp256k1.ModNScalar if overflow := rs.SetByteSlice(r); overflow { - return nil, fmt.Errorf("r overflows group order") + return nil, errors.New("r overflows group order") } if rs.IsZero() { - return nil, fmt.Errorf("r is zero") + return nil, errors.New("r is zero") } if overflow := ss.SetByteSlice(s); overflow { - return nil, fmt.Errorf("s overflows group order") + return nil, errors.New("s overflows group order") } if ss.IsZero() { - return nil, fmt.Errorf("s is zero") + return nil, errors.New("s is zero") } sig := ecdsa.NewSignature(&rs, &ss) @@ -90,7 +91,7 @@ func ECDSASigFromRS(r, s []byte) ([]byte, error) { // receive; the caller already has it. func TransactionApplyECDSA(params *chaincfg.Params, tx *wire.MsgTx, idx int, prev *wire.TxOut, pubKey *btcec.PublicKey, sigDER []byte, hashType txscript.SigHashType) error { if tx == nil || prev == nil || pubKey == nil { - return fmt.Errorf("tx, prev and pubKey cannot be nil") + return errors.New("tx, prev and pubKey cannot be nil") } if idx < 0 || idx >= len(tx.TxIn) { return fmt.Errorf("input index %d out of range (tx has %d inputs)", @@ -187,7 +188,7 @@ func pubKeyMatchesAddress(params *chaincfg.Params, pkScript, pubCompressed []byt } if addrs[0].EncodeAddress() != want.EncodeAddress() { - return fmt.Errorf("public key does not match address") + return errors.New("public key does not match address") } return nil } diff --git a/bitcoin/wallet/external_sign_schnorr.go b/bitcoin/wallet/external_sign_schnorr.go index 7acc390c0..a6138f2c3 100644 --- a/bitcoin/wallet/external_sign_schnorr.go +++ b/bitcoin/wallet/external_sign_schnorr.go @@ -5,6 +5,7 @@ package wallet import ( + "errors" "fmt" "github.com/btcsuite/btcd/btcec/v2" @@ -55,7 +56,7 @@ import ( // is valid" would be misled. func TransactionApplySchnorr(params *chaincfg.Params, tx *wire.MsgTx, idx int, prev *wire.TxOut, pubKey *btcec.PublicKey, sig64 []byte, hashType txscript.SigHashType) error { if tx == nil || prev == nil || pubKey == nil { - return fmt.Errorf("tx, prev and pubKey cannot be nil") + return errors.New("tx, prev and pubKey cannot be nil") } if idx < 0 || idx >= len(tx.TxIn) { return fmt.Errorf("input index %d out of range (tx has %d inputs)", @@ -115,7 +116,7 @@ func pubKeyMatchesTaprootAddress(params *chaincfg.Params, pkScript []byte, pubKe return fmt.Errorf("derive address: %w", err) } if addrs[0].EncodeAddress() != want.EncodeAddress() { - return fmt.Errorf("public key does not match address") + return errors.New("public key does not match address") } return nil } diff --git a/bitcoin/wallet/verify.go b/bitcoin/wallet/verify.go index b24840105..021bce07b 100644 --- a/bitcoin/wallet/verify.go +++ b/bitcoin/wallet/verify.go @@ -27,7 +27,7 @@ import ( // between parse errors and verification mismatches. func VerifyECDSA(sigHash, sigDER []byte, pubKey *btcec.PublicKey) error { if pubKey == nil { - return fmt.Errorf("pubkey cannot be nil") + return errors.New("pubkey cannot be nil") } if len(sigHash) != 32 { return fmt.Errorf("sighash must be 32 bytes, got %d", len(sigHash)) diff --git a/bitcoin/wallet/zuul/memory/memory.go b/bitcoin/wallet/zuul/memory/memory.go index c2917b648..9689ea16f 100644 --- a/bitcoin/wallet/zuul/memory/memory.go +++ b/bitcoin/wallet/zuul/memory/memory.go @@ -6,6 +6,7 @@ package memory import ( + "errors" "fmt" "sync" @@ -229,10 +230,10 @@ func ecdsaAddressesForPubKey(params *chaincfg.Params, pubCompressed []byte) ([]s // ErrKeyExists. func (m *memoryZuul) PutTSSKey(tnk *zuul.TSSNamedKey) error { if tnk == nil || tnk.PublicKey == nil { - return fmt.Errorf("tss key: public key required") + return errors.New("tss key: public key required") } if len(tnk.KeyID) == 0 { - return fmt.Errorf("tss key: key id required") + return errors.New("tss key: key id required") } pubBytes := tnk.PublicKey.SerializeCompressed()