Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
124 changes: 101 additions & 23 deletions crypto/bitcoin/README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,102 @@
# Bitcoin Signing Utility

This package provides utilities for signing Bitcoin Partially Signed Bitcoin Transactions (PSBT) specifically for P2TR (Taproot) key-spend paths.
This package provides utilities for signing Bitcoin PSBTs (Partially Signed Bitcoin Transactions) supporting both P2TR (Taproot) and P2WPKH (native SegWit) address types.

## Signer
## Key types

The `Signer` struct is an abstraction over a Bitcoin private key used for P2TR key-spend path signatures.
### `PrivateKey`

### Functions
A universal private key holder. Used for parsing only — signer implementations derive their address-type-specific key from it.

#### `NewSigner(privateKeyHex string) (*Signer, error)`
Creates a new `Signer` instance from a private key hex string.
| Constructor | Input |
|---|---|
| `NewPrivateKeyFromHex(hex string)` | Raw 32-byte private key, hex-encoded |
| `NewPrivateKeyFromWIF(wif string)` | WIF-encoded private key |
| `NewPrivateKeyFromMnemonic(mnemonic, passphrase string)` | BIP39 mnemonic phrase |

#### `SignDepositTx(signer *Signer, psbtB64 string, inputsToSig []int) (string, error)`
Signs the specified inputs of a base64-encoded PSBT using the P2TR key-spend path and returns the updated, base64-encoded PSBT.
### `Signer` interface

#### `SignPsbtInputKeySpend(packet *psbt.Packet, signInputIndex int) (*psbt.Packet, error)`
A method on `Signer` that signs a specific PSBT input with a key-spend path and returns the updated `psbt.Packet`.
Both signer implementations satisfy this interface:

## Usage Example
```go
type Signer interface {
Address() btcutil.Address
PublicKey() *btcec.PublicKey
SignInput(packet *psbt.Packet, input int) error
}
```

### `P2TRSigner`

Signs P2TR (Taproot) inputs via the key-spend path. For mnemonic-based keys, derives using **BIP86** path `m/86'/0'/0'/0/0`.

```go
signer, err := bitcoin.NewP2TRSigner(key, network)
```

### `P2WPKHSigner`

The following example demonstrates how to use the `Signer` to sign a PSBT.
Signs P2WPKH (native SegWit) inputs. For mnemonic-based keys, derives using **BIP84** path `m/84'/0'/0'/0/0`.

```go
signer, err := bitcoin.NewP2WPKHSigner(key, network)
```

## Functions

#### `SignDepositTx(signer Signer, psbtB64 string, inputsToSig []int) (string, error)`

Signs the specified inputs of a base64-encoded PSBT and returns the updated PSBT in base64.

#### `ParsePSBT(psbtB64 string) (*psbt.Packet, error)`

Parses a base64-encoded PSBT into a `psbt.Packet`.

## Usage examples

### From hex private key

```go
key, err := bitcoin.NewPrivateKeyFromHex("your-private-key-hex")
if err != nil {
log.Fatal(err)
}

signer, err := bitcoin.NewP2TRSigner(key, &chaincfg.MainNetParams)
if err != nil {
log.Fatal(err)
}

fmt.Println(signer.Address()) // bc1p...

signedPsbt, err := bitcoin.SignDepositTx(signer, psbtBase64, []int{0, 1})
```

### From WIF

```go
key, err := bitcoin.NewPrivateKeyFromWIF("your-wif-encoded-key")
if err != nil {
log.Fatal(err)
}

signer, err := bitcoin.NewP2WPKHSigner(key, &chaincfg.MainNetParams)
```

### From mnemonic

```go
key, err := bitcoin.NewPrivateKeyFromMnemonic("word1 word2 ... word12", "")
if err != nil {
log.Fatal(err)
}

// Each signer derives its own key via the appropriate BIP32 path.
p2trSigner, err := bitcoin.NewP2TRSigner(key, &chaincfg.MainNetParams) // BIP86
p2wpkhSigner, err := bitcoin.NewP2WPKHSigner(key, &chaincfg.MainNetParams) // BIP84
```

### Signing a deposit transaction

```go
package main
Expand All @@ -30,11 +107,12 @@ import (

"github.com/BoostyLabs/hotpot-sdk-go/crypto/bitcoin"
"github.com/BoostyLabs/hotpot-sdk-go/types"
"github.com/btcsuite/btcd/chaincfg"
)

func main() {
var (
// Replace with your hex-encoded private key
// Replace with your hex-encoded private key (or use NewPrivateKeyFromWIF / NewPrivateKeyFromMnemonic).
signerPrivateKeyHex string = "your-private-key-hex"

// This is a part of the response of the `create-intent` API call for the Bitcoin-source network.
Expand All @@ -47,26 +125,26 @@ func main() {
}
)

// 1. Initialize the signer
signer, err := bitcoin.NewSigner(signerPrivateKeyHex)
// 1. Parse the private key.
key, err := bitcoin.NewPrivateKeyFromHex(signerPrivateKeyHex)
if err != nil {
log.Fatalf("failed to parse private key: %v", err)
}

// 2. Initialize the signer for the desired address type.
signer, err := bitcoin.NewP2TRSigner(key, &chaincfg.MainNetParams)
if err != nil {
log.Fatalf("failed to create signer: %v", err)
}

// 2. Sign the deposit transaction
// 3. Sign the deposit transaction.
signedPsbtBase64, err := bitcoin.SignDepositTx(signer, approvalToSign.Htlc.Psbt, approvalToSign.Htlc.Inputs)
if err != nil {
log.Fatalf("failed to sign deposit tx: %v", err)
}

// 3. Use the signed PSBT as an approval.
// 4. Use the signed PSBT as an approval.
fmt.Println("Signed PSBT Base64:", signedPsbtBase64)
_ = types.NewHtlcIntentApproval(signedPsbtBase64)
}
```

## Implementation Details

- **P2TR Key-Path**: The implementation specifically handles Taproot key-spend paths.
- **Witness Serialization**: The utility includes internal helpers for serializing witness stacks correctly for PSBTs.
- **Dependency**: It relies on `github.com/btcsuite/btcd` for Bitcoin primitives and PSBT handling.
83 changes: 83 additions & 0 deletions crypto/bitcoin/p2tr_signer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package bitcoin

import (
"errors"
"fmt"

"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/btcutil/psbt"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
)

// p2trPath is BIP86: m/86'/0'/0'/0/0 (Bitcoin mainnet, account 0, first external address).
var p2trPath = []uint32{
86 + hdkeychain.HardenedKeyStart,
0 + hdkeychain.HardenedKeyStart,
0 + hdkeychain.HardenedKeyStart,
0,
0,
}

// P2TRSigner signs P2TR (Taproot) inputs using the key-spend path.
type P2TRSigner struct {
pk *btcec.PrivateKey
net *chaincfg.Params
}

// NewP2TRSigner creates a P2TRSigner from a PrivateKey.
// For mnemonic-based keys it derives using BIP86 path m/86'/0'/0'/0/0.
func NewP2TRSigner(key PrivateKey, network *chaincfg.Params) (*P2TRSigner, error) {
pk, err := resolveKey(key, network, p2trPath)
if err != nil {
return nil, err
}

return &P2TRSigner{pk: pk, net: network}, nil
}

// PublicKey returns the public key for the underlying private key.
func (s *P2TRSigner) PublicKey() *btcec.PublicKey {
return s.pk.PubKey()
}

// Address returns the P2TR (Taproot) address for this key.
func (s *P2TRSigner) Address() btcutil.Address {
tapKey := txscript.ComputeTaprootKeyNoScript(s.pk.PubKey())
addr, _ := btcutil.NewAddressTaproot(schnorr.SerializePubKey(tapKey), s.net)
return addr
}

// SignInput signs a P2TR key-spend input in the PSBT, updating it in place.
func (s *P2TRSigner) SignInput(packet *psbt.Packet, input int) error {
if len(packet.UnsignedTx.TxIn) <= input || len(packet.Inputs) <= input {
return errors.New("invalid input index")
}

pInput := packet.Inputs[input]
outsMap := make(map[wire.OutPoint]*wire.TxOut, len(packet.UnsignedTx.TxIn))
for idx, in := range packet.UnsignedTx.TxIn {
outsMap[in.PreviousOutPoint] = packet.Inputs[idx].WitnessUtxo
}

prevOuts := txscript.NewMultiPrevOutFetcher(outsMap)
witness, err := txscript.TaprootWitnessSignature(
packet.UnsignedTx,
txscript.NewTxSigHashes(packet.UnsignedTx, prevOuts),
input,
pInput.WitnessUtxo.Value,
pInput.WitnessUtxo.PkScript,
pInput.SighashType,
s.pk,
)
if err != nil {
return fmt.Errorf("failed to sign p2tr input: %w", err)
}

packet.Inputs[input].FinalScriptWitness, err = writeWitness(witness[0])
return err
}
96 changes: 96 additions & 0 deletions crypto/bitcoin/p2wpkh_signer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package bitcoin

import (
"errors"
"fmt"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
)

// p2wpkhPath is BIP84: m/84'/0'/0'/0/0 (Bitcoin mainnet, account 0, first external address).
var p2wpkhPath = []uint32{
84 + hdkeychain.HardenedKeyStart,
0 + hdkeychain.HardenedKeyStart,
0 + hdkeychain.HardenedKeyStart,
0,
0,
}

// P2WPKHSigner signs P2WPKH (native SegWit) inputs.
type P2WPKHSigner struct {
pk *btcec.PrivateKey
net *chaincfg.Params
}

// NewP2WPKHSigner creates a P2WPKHSigner from a PrivateKey.
// For mnemonic-based keys it derives using BIP84 path m/84'/0'/0'/0/0.
func NewP2WPKHSigner(key PrivateKey, network *chaincfg.Params) (*P2WPKHSigner, error) {
pk, err := resolveKey(key, network, p2wpkhPath)
if err != nil {
return nil, err
}

return &P2WPKHSigner{pk: pk, net: network}, nil
}

// PublicKey returns the public key for the underlying private key.
func (s *P2WPKHSigner) PublicKey() *btcec.PublicKey {
return s.pk.PubKey()
}

// Address returns the P2WPKH (native SegWit) address for this key.
func (s *P2WPKHSigner) Address() btcutil.Address {
pubKeyHash := btcutil.Hash160(s.pk.PubKey().SerializeCompressed())
addr, _ := btcutil.NewAddressWitnessPubKeyHash(pubKeyHash, s.net)
return addr
}

// SignInput signs a P2WPKH input in the PSBT, updating it in place.
func (s *P2WPKHSigner) SignInput(packet *psbt.Packet, input int) error {
if len(packet.UnsignedTx.TxIn) <= input || len(packet.Inputs) <= input {
return errors.New("invalid input index")
}

pInput := packet.Inputs[input]
outsMap := make(map[wire.OutPoint]*wire.TxOut, len(packet.UnsignedTx.TxIn))
for idx, in := range packet.UnsignedTx.TxIn {
outsMap[in.PreviousOutPoint] = packet.Inputs[idx].WitnessUtxo
}

prevOuts := txscript.NewMultiPrevOutFetcher(outsMap)
sigHashes := txscript.NewTxSigHashes(packet.UnsignedTx, prevOuts)

// BIP143: scriptCode for P2WPKH is the P2PKH script over the same pubkey hash.
pubKeyHash := btcutil.Hash160(s.pk.PubKey().SerializeCompressed())
p2pkhAddr, err := btcutil.NewAddressPubKeyHash(pubKeyHash, s.net)
if err != nil {
return fmt.Errorf("failed to build p2pkh address: %w", err)
}

subscript, err := txscript.PayToAddrScript(p2pkhAddr)
if err != nil {
return fmt.Errorf("failed to build subscript: %w", err)
}

sig, err := txscript.RawTxInWitnessSignature(
packet.UnsignedTx,
sigHashes,
input,
pInput.WitnessUtxo.Value,
subscript,
pInput.SighashType,
s.pk,
)
if err != nil {
return fmt.Errorf("failed to sign p2wpkh input: %w", err)
}

packet.Inputs[input].FinalScriptWitness, err = writeWitness(sig, s.pk.PubKey().SerializeCompressed())
return err
}
Loading
Loading