Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
17a6b48
feat(zuul/memory): multi-form key indexing
marcopeereboom Apr 17, 2026
c636f9e
refactor(wallet): carry amounts in prevOuts
marcopeereboom Apr 17, 2026
25785ef
feat(wallet): sign p2wpkh inputs
marcopeereboom Apr 17, 2026
c15c90e
test(wallet): add pop regression tests
marcopeereboom Apr 17, 2026
f2542df
feat(wallet): sign p2tr key-path inputs
marcopeereboom Apr 17, 2026
78dd7b2
feat(gozer): add TxByID to fetch a tx by id
marcopeereboom Apr 18, 2026
e92331d
test(wallet): add TxByID coverage across mock, unit, and integration …
marcopeereboom Apr 22, 2026
5359397
test(wallet): add TxByID negative tests and fuzz coverage
marcopeereboom Apr 22, 2026
8bd467b
test(wallet): cover error paths in signing and utxo selection
marcopeereboom May 11, 2026
22f7821
fix(wallet): clear scriptSig for native segwit inputs
marcopeereboom May 11, 2026
1cc3840
docs(changelog): add wallet segwit signing entries
marcopeereboom May 11, 2026
2d07ca9
style(wallet): clean up test literal style
marcopeereboom May 12, 2026
399b0d2
tbc: add CPFP mempool resolution and MaxResponseSize tests
marcopeereboom May 12, 2026
80b75e7
feat(zuul): add tss key type
marcopeereboom Apr 17, 2026
f77cab4
feat(wallet): apply external ecdsa signature
marcopeereboom Apr 17, 2026
d0d7ffe
feat(wallet): apply external schnorr signature
marcopeereboom Apr 17, 2026
7a81226
feat(wallet): verify external signatures
marcopeereboom Apr 17, 2026
7298985
test(wallet): tss end-to-end bitcoin spend
marcopeereboom Apr 17, 2026
4ec6943
docs(changelog): add wallet TSS external signing entries
marcopeereboom Apr 17, 2026
d9111a5
test(wallet): cover error paths flagged by codecov
marcopeereboom Apr 17, 2026
9f48579
test(wallet): cover taproot witness crypto negative paths
marcopeereboom Apr 19, 2026
1f3cb58
refactor(zuul): distinguish cross-index key collisions
marcopeereboom Apr 22, 2026
69c4ab0
fix(wallet): address mechanical review feedback
marcopeereboom May 7, 2026
92cfec1
fix(wallet): use ParseDERSignature/ParseSignature for validation and …
marcopeereboom May 7, 2026
975df46
fix(wallet): use errors.New where no format verbs
marcopeereboom May 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,34 @@ 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)).
- 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

- **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)).
Expand Down Expand Up @@ -53,6 +78,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)).
Expand Down
332 changes: 332 additions & 0 deletions bitcoin/wallet/coverage_gaps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
// 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: 1000},
{Value: 2000},
}
_, err := UtxoPickerMultiple(10000, 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: 1000},
{Value: 2000},
{Value: 3000},
}
_, err := UtxoPickerSingle(100000, 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: 50000}, // first one large enough
{Value: 100000}, // should not be picked
}
u, err := UtxoPickerSingle(10000, 100, utxos)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if u.Value != 50000 {
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(50000, 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(50000, 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)
}

// 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(50000, 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(50000, 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))
}
}
Loading