mirror of
https://github.com/aljazceru/ark.git
synced 2025-12-17 04:04:21 +01:00
* explicit Timelock struct * support & test CLTV forfeit path * fix wasm pkg * fix wasm * fix liquid GetCurrentBlockTime * cleaning * move esplora URL check
1272 lines
30 KiB
Go
1272 lines
30 KiB
Go
package txbuilder
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
|
|
"github.com/ark-network/ark/common"
|
|
"github.com/ark-network/ark/common/bitcointree"
|
|
"github.com/ark-network/ark/common/tree"
|
|
"github.com/ark-network/ark/server/internal/core/domain"
|
|
"github.com/ark-network/ark/server/internal/core/ports"
|
|
"github.com/btcsuite/btcd/btcec/v2/schnorr"
|
|
"github.com/btcsuite/btcd/btcutil"
|
|
"github.com/btcsuite/btcd/btcutil/psbt"
|
|
"github.com/btcsuite/btcd/chaincfg"
|
|
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
|
"github.com/btcsuite/btcd/txscript"
|
|
"github.com/btcsuite/btcd/wire"
|
|
"github.com/btcsuite/btcwallet/waddrmgr"
|
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
|
)
|
|
|
|
type txBuilder struct {
|
|
wallet ports.WalletService
|
|
net common.Network
|
|
roundLifetime common.Locktime
|
|
boardingExitDelay common.Locktime
|
|
}
|
|
|
|
func NewTxBuilder(
|
|
wallet ports.WalletService, net common.Network, roundLifetime, boardingExitDelay common.Locktime,
|
|
) ports.TxBuilder {
|
|
return &txBuilder{wallet, net, roundLifetime, boardingExitDelay}
|
|
}
|
|
|
|
func (b *txBuilder) GetTxID(tx string) (string, error) {
|
|
ptx, err := psbt.NewFromRawBytes(strings.NewReader(tx), true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return ptx.UnsignedTx.TxHash().String(), nil
|
|
}
|
|
|
|
func (b *txBuilder) VerifyTapscriptPartialSigs(tx string) (bool, error) {
|
|
ptx, err := psbt.NewFromRawBytes(strings.NewReader(tx), true)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return b.verifyTapscriptPartialSigs(ptx)
|
|
}
|
|
|
|
func (b *txBuilder) verifyTapscriptPartialSigs(ptx *psbt.Packet) (bool, error) {
|
|
txid := ptx.UnsignedTx.TxID()
|
|
|
|
serverPubkey, err := b.wallet.GetPubkey(context.Background())
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
for index, input := range ptx.Inputs {
|
|
if len(input.TaprootLeafScript) == 0 {
|
|
continue
|
|
}
|
|
|
|
if input.WitnessUtxo == nil {
|
|
return false, fmt.Errorf("missing witness utxo for input %d, cannot verify signature", index)
|
|
}
|
|
|
|
// verify taproot leaf script
|
|
tapLeaf := input.TaprootLeafScript[0]
|
|
|
|
closure, err := tree.DecodeClosure(tapLeaf.Script)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
keys := make(map[string]bool)
|
|
|
|
switch c := closure.(type) {
|
|
case *tree.MultisigClosure:
|
|
for _, key := range c.PubKeys {
|
|
keys[hex.EncodeToString(schnorr.SerializePubKey(key))] = false
|
|
}
|
|
case *tree.CSVSigClosure:
|
|
for _, key := range c.PubKeys {
|
|
keys[hex.EncodeToString(schnorr.SerializePubKey(key))] = false
|
|
}
|
|
case *tree.CLTVMultisigClosure:
|
|
for _, key := range c.PubKeys {
|
|
keys[hex.EncodeToString(schnorr.SerializePubKey(key))] = false
|
|
}
|
|
}
|
|
|
|
// we don't need to check if server signed
|
|
keys[hex.EncodeToString(schnorr.SerializePubKey(serverPubkey))] = true
|
|
|
|
if len(tapLeaf.ControlBlock) == 0 {
|
|
return false, fmt.Errorf("missing control block for input %d", index)
|
|
}
|
|
|
|
controlBlock, err := txscript.ParseControlBlock(tapLeaf.ControlBlock)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
rootHash := controlBlock.RootHash(tapLeaf.Script)
|
|
tapKeyFromControlBlock := txscript.ComputeTaprootOutputKey(bitcointree.UnspendableKey(), rootHash[:])
|
|
pkscript, err := common.P2TRScript(tapKeyFromControlBlock)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if !bytes.Equal(pkscript, input.WitnessUtxo.PkScript) {
|
|
return false, fmt.Errorf("invalid control block for input %d", index)
|
|
}
|
|
|
|
preimage, err := b.getTaprootPreimage(
|
|
ptx,
|
|
index,
|
|
tapLeaf.Script,
|
|
)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
for _, tapScriptSig := range input.TaprootScriptSpendSig {
|
|
sig, err := schnorr.ParseSignature(tapScriptSig.Signature)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
pubkey, err := schnorr.ParsePubKey(tapScriptSig.XOnlyPubKey)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if !sig.Verify(preimage, pubkey) {
|
|
return false, fmt.Errorf("invalid signature for tx %s", txid)
|
|
}
|
|
|
|
keys[hex.EncodeToString(schnorr.SerializePubKey(pubkey))] = true
|
|
}
|
|
|
|
missingSigs := 0
|
|
for key := range keys {
|
|
if !keys[key] {
|
|
missingSigs++
|
|
}
|
|
}
|
|
|
|
if missingSigs > 0 {
|
|
return false, fmt.Errorf("missing %d signatures", missingSigs)
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (b *txBuilder) FinalizeAndExtract(tx string) (string, error) {
|
|
ptx, err := psbt.NewFromRawBytes(strings.NewReader(tx), true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for i, in := range ptx.Inputs {
|
|
isTaproot := txscript.IsPayToTaproot(in.WitnessUtxo.PkScript)
|
|
if isTaproot && len(in.TaprootLeafScript) > 0 {
|
|
closure, err := tree.DecodeClosure(in.TaprootLeafScript[0].Script)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
signatures := make(map[string][]byte)
|
|
|
|
for _, sig := range in.TaprootScriptSpendSig {
|
|
signatures[hex.EncodeToString(sig.XOnlyPubKey)] = sig.Signature
|
|
}
|
|
|
|
witness, err := closure.Witness(in.TaprootLeafScript[0].ControlBlock, signatures)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var witnessBuf bytes.Buffer
|
|
if err := psbt.WriteTxWitness(&witnessBuf, witness); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
ptx.Inputs[i].FinalScriptWitness = witnessBuf.Bytes()
|
|
continue
|
|
|
|
}
|
|
|
|
if err := psbt.Finalize(ptx, i); err != nil {
|
|
return "", fmt.Errorf("failed to finalize input %d: %w", i, err)
|
|
}
|
|
}
|
|
|
|
signed, err := psbt.Extract(ptx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var serialized bytes.Buffer
|
|
|
|
if err := signed.Serialize(&serialized); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return hex.EncodeToString(serialized.Bytes()), nil
|
|
}
|
|
|
|
func (b *txBuilder) BuildSweepTx(inputs []ports.SweepInput) (signedSweepTx string, err error) {
|
|
sweepPsbt, err := sweepTransaction(
|
|
b.wallet,
|
|
inputs,
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
sweepPsbtBase64, err := sweepPsbt.B64Encode()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
ctx := context.Background()
|
|
signedSweepPsbtB64, err := b.wallet.SignTransactionTapscript(ctx, sweepPsbtBase64, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
signedPsbt, err := psbt.NewFromRawBytes(strings.NewReader(signedSweepPsbtB64), true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for i := range inputs {
|
|
if err := psbt.Finalize(signedPsbt, i); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
tx, err := psbt.Extract(signedPsbt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
buf := new(bytes.Buffer)
|
|
|
|
if err := tx.Serialize(buf); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return hex.EncodeToString(buf.Bytes()), nil
|
|
}
|
|
|
|
func (b *txBuilder) VerifyForfeitTxs(vtxos []domain.Vtxo, connectors []string, forfeitTxs []string) (map[domain.VtxoKey][]string, error) {
|
|
connectorsPtxs := make([]*psbt.Packet, 0, len(connectors))
|
|
var connectorAmount uint64
|
|
|
|
for i, connector := range connectors {
|
|
ptx, err := psbt.NewFromRawBytes(strings.NewReader(connector), true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if i == len(connectors)-1 {
|
|
lastOutput := ptx.UnsignedTx.TxOut[len(ptx.UnsignedTx.TxOut)-1]
|
|
connectorAmount = uint64(lastOutput.Value)
|
|
}
|
|
|
|
connectorsPtxs = append(connectorsPtxs, ptx)
|
|
}
|
|
|
|
// decode forfeit txs, map by vtxo key
|
|
forfeitTxsPtxs := make(map[domain.VtxoKey][]*psbt.Packet)
|
|
for _, forfeitTx := range forfeitTxs {
|
|
ptx, err := psbt.NewFromRawBytes(strings.NewReader(forfeitTx), true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(ptx.Inputs) != 2 {
|
|
return nil, fmt.Errorf("invalid forfeit tx, expect 2 inputs, got %d", len(ptx.Inputs))
|
|
}
|
|
|
|
valid, err := b.verifyTapscriptPartialSigs(ptx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !valid {
|
|
return nil, fmt.Errorf("invalid forfeit tx signature")
|
|
}
|
|
|
|
vtxoInput := ptx.UnsignedTx.TxIn[1]
|
|
|
|
vtxoKey := domain.VtxoKey{
|
|
Txid: vtxoInput.PreviousOutPoint.Hash.String(),
|
|
VOut: vtxoInput.PreviousOutPoint.Index,
|
|
}
|
|
if _, ok := forfeitTxsPtxs[vtxoKey]; !ok {
|
|
forfeitTxsPtxs[vtxoKey] = make([]*psbt.Packet, 0)
|
|
}
|
|
forfeitTxsPtxs[vtxoKey] = append(forfeitTxsPtxs[vtxoKey], ptx)
|
|
}
|
|
|
|
forfeitAddress, err := b.wallet.GetForfeitAddress(context.Background())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
addr, err := btcutil.DecodeAddress(forfeitAddress, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
forfeitScript, err := txscript.PayToAddrScript(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
minRate := b.wallet.MinRelayFeeRate(context.Background())
|
|
|
|
validForfeitTxs := make(map[domain.VtxoKey][]string)
|
|
|
|
blocktimestamp, err := b.wallet.GetCurrentBlockTime(context.Background())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for vtxoKey, ptxs := range forfeitTxsPtxs {
|
|
if len(ptxs) == 0 {
|
|
continue
|
|
}
|
|
|
|
var vtxo *domain.Vtxo
|
|
for _, v := range vtxos {
|
|
if v.VtxoKey == vtxoKey {
|
|
vtxo = &v
|
|
break
|
|
}
|
|
}
|
|
|
|
if vtxo == nil {
|
|
return nil, fmt.Errorf("missing vtxo %s", vtxoKey)
|
|
}
|
|
|
|
outputAmount := uint64(0)
|
|
|
|
// only take the first forfeit tx, as all forfeit must have the same output
|
|
firstForfeit := ptxs[0]
|
|
for _, output := range firstForfeit.UnsignedTx.TxOut {
|
|
outputAmount += uint64(output.Value)
|
|
}
|
|
|
|
inputAmount := vtxo.Amount + connectorAmount
|
|
feeAmount := inputAmount - outputAmount
|
|
|
|
if len(firstForfeit.Inputs[1].TaprootLeafScript) <= 0 {
|
|
return nil, fmt.Errorf("missing taproot leaf script for vtxo input, invalid forfeit tx")
|
|
}
|
|
|
|
vtxoTapscript := firstForfeit.Inputs[1].TaprootLeafScript[0]
|
|
|
|
// verify the forfeit closure script
|
|
closure, err := tree.DecodeClosure(vtxoTapscript.Script)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch c := closure.(type) {
|
|
case *tree.CLTVMultisigClosure:
|
|
switch c.Locktime.Type {
|
|
case common.LocktimeTypeBlock:
|
|
if c.Locktime.Value > blocktimestamp.Height {
|
|
return nil, fmt.Errorf("forfeit closure is CLTV locked, %d > %d (block height)", c.Locktime.Value, blocktimestamp.Height)
|
|
}
|
|
case common.LocktimeTypeSecond:
|
|
if c.Locktime.Value > uint32(blocktimestamp.Time) {
|
|
return nil, fmt.Errorf("forfeit closure is CLTV locked, %d > %d (block time)", c.Locktime.Value, blocktimestamp.Time)
|
|
}
|
|
}
|
|
case *tree.MultisigClosure:
|
|
default:
|
|
return nil, fmt.Errorf("invalid forfeit closure script")
|
|
}
|
|
|
|
ctrlBlock, err := txscript.ParseControlBlock(vtxoTapscript.ControlBlock)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
minFee, err := common.ComputeForfeitTxFee(
|
|
minRate,
|
|
&waddrmgr.Tapscript{
|
|
RevealedScript: vtxoTapscript.Script,
|
|
ControlBlock: ctrlBlock,
|
|
},
|
|
closure.WitnessSize(),
|
|
txscript.GetScriptClass(forfeitScript),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dustAmount, err := b.wallet.GetDustAmount(context.Background())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if inputAmount-feeAmount < dustAmount {
|
|
return nil, fmt.Errorf("forfeit tx output amount is dust, %d < %d", inputAmount-feeAmount, dustAmount)
|
|
}
|
|
|
|
if feeAmount < uint64(minFee) {
|
|
return nil, fmt.Errorf("forfeit tx fee is lower than the min relay fee, %d < %d", feeAmount, minFee)
|
|
}
|
|
|
|
feeThreshold := uint64(math.Ceil(float64(minFee) * 1.05))
|
|
|
|
if feeAmount > feeThreshold {
|
|
return nil, fmt.Errorf("forfeit tx fee is higher than 5%% of the min relay fee, %d > %d", feeAmount, feeThreshold)
|
|
}
|
|
|
|
vtxoChainhash, err := chainhash.NewHashFromStr(vtxoKey.Txid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vtxoInput := &wire.OutPoint{
|
|
Hash: *vtxoChainhash,
|
|
Index: vtxoKey.VOut,
|
|
}
|
|
|
|
vtxoTapKey, err := vtxo.TapKey()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vtxoScript, err := common.P2TRScript(vtxoTapKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rebuiltForfeits := make([]*psbt.Packet, 0)
|
|
|
|
for _, connector := range connectorsPtxs {
|
|
forfeits, err := bitcointree.BuildForfeitTxs(
|
|
connector,
|
|
vtxoInput,
|
|
vtxo.Amount,
|
|
connectorAmount,
|
|
feeAmount,
|
|
vtxoScript,
|
|
forfeitScript,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rebuiltForfeits = append(rebuiltForfeits, forfeits...)
|
|
}
|
|
|
|
if len(rebuiltForfeits) != len(ptxs) {
|
|
return nil, fmt.Errorf("missing forfeits, expect %d, got %d", len(ptxs), len(rebuiltForfeits))
|
|
}
|
|
|
|
for _, forfeit := range rebuiltForfeits {
|
|
found := false
|
|
txid := forfeit.UnsignedTx.TxHash().String()
|
|
for _, ptx := range ptxs {
|
|
if txid == ptx.UnsignedTx.TxHash().String() {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return nil, fmt.Errorf("missing forfeit tx %s", txid)
|
|
}
|
|
}
|
|
|
|
b64Txs := make([]string, 0, len(ptxs))
|
|
for _, forfeit := range ptxs {
|
|
b64, err := forfeit.B64Encode()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
b64Txs = append(b64Txs, b64)
|
|
}
|
|
|
|
validForfeitTxs[vtxoKey] = b64Txs
|
|
}
|
|
|
|
return validForfeitTxs, nil
|
|
}
|
|
|
|
func (b *txBuilder) BuildRoundTx(
|
|
serverPubkey *secp256k1.PublicKey,
|
|
requests []domain.TxRequest,
|
|
boardingInputs []ports.BoardingInput,
|
|
sweptRounds []domain.Round,
|
|
cosigners ...*secp256k1.PublicKey,
|
|
) (roundTx string, vtxoTree tree.VtxoTree, connectorAddress string, connectors []string, err error) {
|
|
var sharedOutputScript []byte
|
|
var sharedOutputAmount int64
|
|
|
|
if len(cosigners) == 0 {
|
|
return "", nil, "", nil, fmt.Errorf("missing cosigners")
|
|
}
|
|
|
|
receivers, err := getOutputVtxosLeaves(requests)
|
|
if err != nil {
|
|
return "", nil, "", nil, err
|
|
}
|
|
|
|
feeAmount, err := b.minRelayFeeTreeTx()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if !isOnchainOnly(requests) {
|
|
sharedOutputScript, sharedOutputAmount, err = bitcointree.CraftSharedOutput(
|
|
cosigners, serverPubkey, receivers, feeAmount, b.roundLifetime,
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
connectorAddress, err = b.wallet.DeriveConnectorAddress(context.Background())
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
ptx, err := b.createRoundTx(
|
|
sharedOutputAmount, sharedOutputScript, requests, boardingInputs, connectorAddress, sweptRounds,
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
roundTx, err = ptx.B64Encode()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if !isOnchainOnly(requests) {
|
|
initialOutpoint := &wire.OutPoint{
|
|
Hash: ptx.UnsignedTx.TxHash(),
|
|
Index: 0,
|
|
}
|
|
|
|
vtxoTree, err = bitcointree.BuildVtxoTree(
|
|
initialOutpoint, cosigners, serverPubkey, receivers, feeAmount, b.roundLifetime,
|
|
)
|
|
if err != nil {
|
|
return "", nil, "", nil, err
|
|
}
|
|
}
|
|
|
|
if countSpentVtxos(requests) <= 0 {
|
|
return
|
|
}
|
|
|
|
connectorAddr, err := btcutil.DecodeAddress(connectorAddress, b.onchainNetwork())
|
|
if err != nil {
|
|
return "", nil, "", nil, err
|
|
}
|
|
|
|
connectorPkScript, err := txscript.PayToAddrScript(connectorAddr)
|
|
if err != nil {
|
|
return "", nil, "", nil, err
|
|
}
|
|
|
|
minRelayFeeConnectorTx, err := b.minRelayFeeConnectorTx()
|
|
if err != nil {
|
|
return "", nil, "", nil, err
|
|
}
|
|
|
|
connectorsPsbts, err := b.createConnectors(roundTx, requests, connectorPkScript, minRelayFeeConnectorTx)
|
|
if err != nil {
|
|
return "", nil, "", nil, err
|
|
}
|
|
|
|
for _, ptx := range connectorsPsbts {
|
|
b64, err := ptx.B64Encode()
|
|
if err != nil {
|
|
return "", nil, "", nil, err
|
|
}
|
|
connectors = append(connectors, b64)
|
|
}
|
|
|
|
return roundTx, vtxoTree, connectorAddress, connectors, nil
|
|
}
|
|
|
|
func (b *txBuilder) GetSweepInput(node tree.Node) (lifetime *common.Locktime, sweepInput ports.SweepInput, err error) {
|
|
partialTx, err := psbt.NewFromRawBytes(strings.NewReader(node.Tx), true)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if len(partialTx.Inputs) != 1 {
|
|
return nil, nil, fmt.Errorf("invalid node pset, expect 1 input, got %d", len(partialTx.Inputs))
|
|
}
|
|
|
|
input := partialTx.UnsignedTx.TxIn[0]
|
|
txid := input.PreviousOutPoint.Hash
|
|
index := input.PreviousOutPoint.Index
|
|
|
|
sweepLeaf, internalKey, lifetime, err := extractSweepLeaf(partialTx.Inputs[0])
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
txhex, err := b.wallet.GetTransaction(context.Background(), txid.String())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var tx wire.MsgTx
|
|
if err := tx.Deserialize(hex.NewDecoder(strings.NewReader(txhex))); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
sweepInput = &sweepBitcoinInput{
|
|
inputArgs: wire.OutPoint{
|
|
Hash: txid,
|
|
Index: index,
|
|
},
|
|
internalPubkey: internalKey,
|
|
sweepLeaf: sweepLeaf,
|
|
amount: tx.TxOut[index].Value,
|
|
}
|
|
|
|
return lifetime, sweepInput, nil
|
|
}
|
|
|
|
func (b *txBuilder) FindLeaves(vtxoTree tree.VtxoTree, fromtxid string, vout uint32) ([]tree.Node, error) {
|
|
allLeaves := vtxoTree.Leaves()
|
|
foundLeaves := make([]tree.Node, 0)
|
|
|
|
for _, leaf := range allLeaves {
|
|
branch, err := vtxoTree.Branch(leaf.Txid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, node := range branch {
|
|
ptx, err := psbt.NewFromRawBytes(strings.NewReader(node.Tx), true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(ptx.Inputs) <= 0 {
|
|
return nil, fmt.Errorf("no input in the pset")
|
|
}
|
|
|
|
parentInput := ptx.UnsignedTx.TxIn[0].PreviousOutPoint
|
|
|
|
if parentInput.Hash.String() == fromtxid && parentInput.Index == vout {
|
|
foundLeaves = append(foundLeaves, leaf)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return foundLeaves, nil
|
|
}
|
|
|
|
// TODO use lnd CoinSelect to craft the pool tx
|
|
func (b *txBuilder) createRoundTx(
|
|
sharedOutputAmount int64,
|
|
sharedOutputScript []byte,
|
|
requests []domain.TxRequest,
|
|
boardingInputs []ports.BoardingInput,
|
|
connectorAddress string,
|
|
sweptRounds []domain.Round,
|
|
) (*psbt.Packet, error) {
|
|
connectorAddr, err := btcutil.DecodeAddress(connectorAddress, b.onchainNetwork())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
connectorScript, err := txscript.PayToAddrScript(connectorAddr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
connectorMinRelayFee, err := b.minRelayFeeConnectorTx()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dustLimit, err := b.wallet.GetDustAmount(context.Background())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
connectorAmount := dustLimit
|
|
|
|
nbOfInputs := countSpentVtxos(requests)
|
|
connectorsAmount := (connectorAmount + connectorMinRelayFee) * nbOfInputs
|
|
if nbOfInputs > 1 {
|
|
connectorsAmount -= connectorMinRelayFee
|
|
}
|
|
targetAmount := connectorsAmount
|
|
|
|
outputs := make([]*wire.TxOut, 0)
|
|
|
|
if sharedOutputScript != nil && sharedOutputAmount > 0 {
|
|
targetAmount += uint64(sharedOutputAmount)
|
|
|
|
outputs = append(outputs, &wire.TxOut{
|
|
Value: sharedOutputAmount,
|
|
PkScript: sharedOutputScript,
|
|
})
|
|
}
|
|
|
|
if connectorsAmount > 0 {
|
|
outputs = append(outputs, &wire.TxOut{
|
|
Value: int64(connectorsAmount),
|
|
PkScript: connectorScript,
|
|
})
|
|
}
|
|
|
|
onchainOutputs, err := getOnchainOutputs(requests, b.onchainNetwork())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, output := range onchainOutputs {
|
|
targetAmount += uint64(output.Value)
|
|
}
|
|
|
|
outputs = append(outputs, onchainOutputs...)
|
|
|
|
for _, input := range boardingInputs {
|
|
targetAmount -= input.Amount
|
|
}
|
|
|
|
ctx := context.Background()
|
|
utxos, change, err := b.selectUtxos(ctx, sweptRounds, targetAmount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var cacheChangeScript []byte
|
|
// avoid derivation of several change addresses
|
|
getChange := func() ([]byte, error) {
|
|
if len(cacheChangeScript) > 0 {
|
|
return cacheChangeScript, nil
|
|
}
|
|
|
|
changeAddresses, err := b.wallet.DeriveAddresses(ctx, 1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
changeAddress, err := btcutil.DecodeAddress(changeAddresses[0], b.onchainNetwork())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return txscript.PayToAddrScript(changeAddress)
|
|
}
|
|
|
|
exceedingValue := uint64(0)
|
|
if change > 0 {
|
|
if change <= dustLimit {
|
|
exceedingValue = change
|
|
change = 0
|
|
} else {
|
|
changeScript, err := getChange()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
outputs = append(outputs, &wire.TxOut{
|
|
Value: int64(change),
|
|
PkScript: changeScript,
|
|
})
|
|
}
|
|
}
|
|
|
|
ins := make([]*wire.OutPoint, 0)
|
|
nSequences := make([]uint32, 0)
|
|
witnessUtxos := make(map[int]*wire.TxOut)
|
|
tapLeaves := make(map[int]*psbt.TaprootTapLeafScript)
|
|
nextIndex := 0
|
|
|
|
for _, utxo := range utxos {
|
|
txhash, err := chainhash.NewHashFromStr(utxo.GetTxid())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ins = append(ins, &wire.OutPoint{
|
|
Hash: *txhash,
|
|
Index: utxo.GetIndex(),
|
|
})
|
|
nSequences = append(nSequences, wire.MaxTxInSequenceNum)
|
|
|
|
script, err := hex.DecodeString(utxo.GetScript())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
witnessUtxos[nextIndex] = &wire.TxOut{
|
|
Value: int64(utxo.GetValue()),
|
|
PkScript: script,
|
|
}
|
|
nextIndex++
|
|
}
|
|
|
|
for _, boardingInput := range boardingInputs {
|
|
txHash, err := chainhash.NewHashFromStr(boardingInput.Txid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ins = append(ins, &wire.OutPoint{
|
|
Hash: *txHash,
|
|
Index: boardingInput.VtxoKey.VOut,
|
|
})
|
|
nSequences = append(nSequences, wire.MaxTxInSequenceNum)
|
|
|
|
boardingVtxoScript, err := bitcointree.ParseVtxoScript(boardingInput.Tapscripts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
boardingTapKey, boardingTapTree, err := boardingVtxoScript.TapTree()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
boardingOutputScript, err := common.P2TRScript(boardingTapKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
witnessUtxos[nextIndex] = &wire.TxOut{
|
|
Value: int64(boardingInput.Amount),
|
|
PkScript: boardingOutputScript,
|
|
}
|
|
|
|
biggestProof, err := common.BiggestLeafMerkleProof(boardingTapTree)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tapLeaves[nextIndex] = &psbt.TaprootTapLeafScript{
|
|
Script: biggestProof.Script,
|
|
ControlBlock: biggestProof.ControlBlock,
|
|
}
|
|
|
|
nextIndex++
|
|
}
|
|
|
|
ptx, err := psbt.New(ins, outputs, 2, 0, nSequences)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
updater, err := psbt.NewUpdater(ptx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for inIndex, utxo := range witnessUtxos {
|
|
if err := updater.AddInWitnessUtxo(utxo, inIndex); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
for inIndex, tapLeaf := range tapLeaves {
|
|
updater.Upsbt.Inputs[inIndex].TaprootLeafScript = []*psbt.TaprootTapLeafScript{tapLeaf}
|
|
}
|
|
|
|
b64, err := ptx.B64Encode()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
feeAmount, err := b.wallet.EstimateFees(ctx, b64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for feeAmount > exceedingValue {
|
|
feesToPay := feeAmount - exceedingValue
|
|
|
|
// change is able to cover the remaining fees
|
|
if change > feesToPay {
|
|
newChange := change - (feeAmount - exceedingValue)
|
|
// new change amount is less than dust limit, let's remove it
|
|
if newChange <= dustLimit {
|
|
ptx.UnsignedTx.TxOut = ptx.UnsignedTx.TxOut[:len(ptx.UnsignedTx.TxOut)-1]
|
|
ptx.Outputs = ptx.Outputs[:len(ptx.Outputs)-1]
|
|
} else {
|
|
ptx.UnsignedTx.TxOut[len(ptx.Outputs)-1].Value = int64(newChange)
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
// change is not enough to cover the remaining fees, let's re-select utxos
|
|
newUtxos, newChange, err := b.wallet.SelectUtxos(ctx, "", feeAmount-exceedingValue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// add new inputs
|
|
for _, utxo := range newUtxos {
|
|
txhash, err := chainhash.NewHashFromStr(utxo.GetTxid())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
outpoint := &wire.OutPoint{
|
|
Hash: *txhash,
|
|
Index: utxo.GetIndex(),
|
|
}
|
|
|
|
ptx.UnsignedTx.AddTxIn(wire.NewTxIn(outpoint, nil, nil))
|
|
ptx.Inputs = append(ptx.Inputs, psbt.PInput{})
|
|
|
|
scriptBytes, err := hex.DecodeString(utxo.GetScript())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := updater.AddInWitnessUtxo(
|
|
&wire.TxOut{
|
|
Value: int64(utxo.GetValue()),
|
|
PkScript: scriptBytes,
|
|
},
|
|
len(ptx.UnsignedTx.TxIn)-1,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// add new change output if necessary
|
|
if newChange > 0 {
|
|
if newChange <= dustLimit {
|
|
newChange = 0
|
|
exceedingValue += newChange
|
|
} else {
|
|
changeScript, err := getChange()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ptx.UnsignedTx.AddTxOut(&wire.TxOut{
|
|
Value: int64(newChange),
|
|
PkScript: changeScript,
|
|
})
|
|
ptx.Outputs = append(ptx.Outputs, psbt.POutput{})
|
|
}
|
|
}
|
|
|
|
b64, err = ptx.B64Encode()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
newFeeAmount, err := b.wallet.EstimateFees(ctx, b64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
feeAmount = newFeeAmount
|
|
change = newChange
|
|
}
|
|
|
|
// remove input taproot leaf script
|
|
// used only to compute an accurate fee estimation
|
|
for i := range ptx.Inputs {
|
|
ptx.Inputs[i].TaprootLeafScript = nil
|
|
}
|
|
|
|
return ptx, nil
|
|
}
|
|
|
|
func (b *txBuilder) minRelayFeeConnectorTx() (uint64, error) {
|
|
return b.wallet.MinRelayFee(context.Background(), uint64(common.ConnectorTxSize))
|
|
}
|
|
|
|
func (b *txBuilder) VerifyAndCombinePartialTx(dest string, src string) (string, error) {
|
|
roundTx, err := psbt.NewFromRawBytes(strings.NewReader(dest), true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
sourceTx, err := psbt.NewFromRawBytes(strings.NewReader(src), true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if sourceTx.UnsignedTx.TxHash().String() != roundTx.UnsignedTx.TxHash().String() {
|
|
return "", fmt.Errorf("txids do not match")
|
|
}
|
|
|
|
for i, in := range sourceTx.Inputs {
|
|
isMultisigTaproot := len(in.TaprootLeafScript) > 0
|
|
if isMultisigTaproot {
|
|
// check if the source tx signs the leaf
|
|
sourceInput := sourceTx.Inputs[i]
|
|
|
|
if len(sourceInput.TaprootScriptSpendSig) == 0 {
|
|
continue
|
|
}
|
|
|
|
partialSig := sourceInput.TaprootScriptSpendSig[0]
|
|
preimage, err := b.getTaprootPreimage(sourceTx, i, sourceInput.TaprootLeafScript[0].Script)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
sig, err := schnorr.ParseSignature(partialSig.Signature)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
pubkey, err := schnorr.ParsePubKey(partialSig.XOnlyPubKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !sig.Verify(preimage, pubkey) {
|
|
return "", fmt.Errorf(
|
|
"invalid signature for input %s:%d",
|
|
sourceTx.UnsignedTx.TxIn[i].PreviousOutPoint.Hash.String(),
|
|
sourceTx.UnsignedTx.TxIn[i].PreviousOutPoint.Index,
|
|
)
|
|
}
|
|
|
|
roundTx.Inputs[i].TaprootScriptSpendSig = sourceInput.TaprootScriptSpendSig
|
|
roundTx.Inputs[i].TaprootLeafScript = sourceInput.TaprootLeafScript
|
|
}
|
|
}
|
|
|
|
return roundTx.B64Encode()
|
|
}
|
|
|
|
func (b *txBuilder) createConnectors(
|
|
roundTx string, requests []domain.TxRequest, connectorScript []byte, feeAmount uint64,
|
|
) ([]*psbt.Packet, error) {
|
|
partialTx, err := psbt.NewFromRawBytes(strings.NewReader(roundTx), true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
connectorAmount, err := b.wallet.GetDustAmount(context.Background())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
connectorOutput := &wire.TxOut{
|
|
PkScript: connectorScript,
|
|
Value: int64(connectorAmount),
|
|
}
|
|
|
|
numberOfConnectors := countSpentVtxos(requests)
|
|
|
|
previousInput := &wire.OutPoint{
|
|
Hash: partialTx.UnsignedTx.TxHash(),
|
|
Index: 1,
|
|
}
|
|
if numberOfConnectors == 1 {
|
|
outputs := []*wire.TxOut{connectorOutput}
|
|
connectorTx, err := craftConnectorTx(previousInput, connectorScript, outputs, feeAmount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return []*psbt.Packet{connectorTx}, nil
|
|
}
|
|
|
|
totalConnectorAmount := (connectorAmount + feeAmount) * numberOfConnectors
|
|
if numberOfConnectors > 1 {
|
|
totalConnectorAmount -= feeAmount
|
|
}
|
|
|
|
connectors := make([]*psbt.Packet, 0, numberOfConnectors-1)
|
|
for i := uint64(0); i < numberOfConnectors-1; i++ {
|
|
outputs := []*wire.TxOut{connectorOutput}
|
|
totalConnectorAmount -= connectorAmount
|
|
totalConnectorAmount -= feeAmount
|
|
if totalConnectorAmount > 0 {
|
|
outputs = append(outputs, &wire.TxOut{
|
|
PkScript: connectorScript,
|
|
Value: int64(totalConnectorAmount),
|
|
})
|
|
}
|
|
connectorTx, err := craftConnectorTx(previousInput, connectorScript, outputs, feeAmount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
previousInput = &wire.OutPoint{
|
|
Hash: connectorTx.UnsignedTx.TxHash(),
|
|
Index: 1,
|
|
}
|
|
|
|
connectors = append(connectors, connectorTx)
|
|
}
|
|
|
|
return connectors, nil
|
|
}
|
|
|
|
func (b *txBuilder) minRelayFeeTreeTx() (uint64, error) {
|
|
return b.wallet.MinRelayFee(context.Background(), uint64(common.TreeTxSize))
|
|
}
|
|
|
|
func (b *txBuilder) selectUtxos(ctx context.Context, sweptRounds []domain.Round, amount uint64) ([]ports.TxInput, uint64, error) {
|
|
selectedConnectorsUtxos := make([]ports.TxInput, 0)
|
|
selectedConnectorsAmount := uint64(0)
|
|
|
|
for _, round := range sweptRounds {
|
|
if selectedConnectorsAmount >= amount {
|
|
break
|
|
}
|
|
connectors, err := b.wallet.ListConnectorUtxos(ctx, round.ConnectorAddress)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
for _, connector := range connectors {
|
|
if selectedConnectorsAmount >= amount {
|
|
break
|
|
}
|
|
|
|
selectedConnectorsUtxos = append(selectedConnectorsUtxos, connector)
|
|
selectedConnectorsAmount += connector.GetValue()
|
|
}
|
|
}
|
|
|
|
if len(selectedConnectorsUtxos) > 0 {
|
|
if err := b.wallet.LockConnectorUtxos(ctx, castToOutpoints(selectedConnectorsUtxos)); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
|
|
if selectedConnectorsAmount >= amount {
|
|
return selectedConnectorsUtxos, selectedConnectorsAmount - amount, nil
|
|
}
|
|
|
|
utxos, change, err := b.wallet.SelectUtxos(ctx, "", amount-selectedConnectorsAmount)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return append(selectedConnectorsUtxos, utxos...), change, nil
|
|
}
|
|
|
|
func (b *txBuilder) getTaprootPreimage(partial *psbt.Packet, inputIndex int, leafScript []byte) ([]byte, error) {
|
|
prevouts := make(map[wire.OutPoint]*wire.TxOut)
|
|
|
|
for i, input := range partial.Inputs {
|
|
if input.WitnessUtxo == nil {
|
|
return nil, fmt.Errorf("missing witness utxo on input #%d", i)
|
|
}
|
|
|
|
outpoint := partial.UnsignedTx.TxIn[i].PreviousOutPoint
|
|
prevouts[outpoint] = input.WitnessUtxo
|
|
}
|
|
|
|
prevoutFetcher := txscript.NewMultiPrevOutFetcher(prevouts)
|
|
|
|
return txscript.CalcTapscriptSignaturehash(
|
|
txscript.NewTxSigHashes(partial.UnsignedTx, prevoutFetcher),
|
|
txscript.SigHashDefault,
|
|
partial.UnsignedTx,
|
|
inputIndex,
|
|
prevoutFetcher,
|
|
txscript.NewBaseTapLeaf(leafScript),
|
|
)
|
|
}
|
|
|
|
func (b *txBuilder) onchainNetwork() *chaincfg.Params {
|
|
mutinyNetSigNetParams := chaincfg.CustomSignetParams(common.MutinyNetChallenge, nil)
|
|
mutinyNetSigNetParams.TargetTimePerBlock = common.MutinyNetBlockTime
|
|
switch b.net.Name {
|
|
case common.Bitcoin.Name:
|
|
return &chaincfg.MainNetParams
|
|
case common.BitcoinTestNet.Name:
|
|
return &chaincfg.TestNet3Params
|
|
case common.BitcoinRegTest.Name:
|
|
return &chaincfg.RegressionNetParams
|
|
case common.BitcoinSigNet.Name:
|
|
return &mutinyNetSigNetParams
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func castToOutpoints(inputs []ports.TxInput) []ports.TxOutpoint {
|
|
outpoints := make([]ports.TxOutpoint, 0, len(inputs))
|
|
for _, input := range inputs {
|
|
outpoints = append(outpoints, input)
|
|
}
|
|
return outpoints
|
|
}
|
|
|
|
func extractSweepLeaf(input psbt.PInput) (sweepLeaf *psbt.TaprootTapLeafScript, internalKey *secp256k1.PublicKey, lifetime *common.Locktime, err error) {
|
|
for _, leaf := range input.TaprootLeafScript {
|
|
closure := &tree.CSVSigClosure{}
|
|
valid, err := closure.Decode(leaf.Script)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
if valid && (lifetime == nil || closure.Locktime.LessThan(*lifetime)) {
|
|
sweepLeaf = leaf
|
|
lifetime = &closure.Locktime
|
|
}
|
|
}
|
|
|
|
internalKey, err = schnorr.ParsePubKey(input.TaprootInternalKey)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
if sweepLeaf == nil {
|
|
return nil, nil, nil, fmt.Errorf("sweep leaf not found")
|
|
}
|
|
|
|
return sweepLeaf, internalKey, lifetime, nil
|
|
}
|
|
|
|
type sweepBitcoinInput struct {
|
|
inputArgs wire.OutPoint
|
|
sweepLeaf *psbt.TaprootTapLeafScript
|
|
internalPubkey *secp256k1.PublicKey
|
|
amount int64
|
|
}
|
|
|
|
func (s *sweepBitcoinInput) GetAmount() uint64 {
|
|
return uint64(s.amount)
|
|
}
|
|
|
|
func (s *sweepBitcoinInput) GetControlBlock() []byte {
|
|
return s.sweepLeaf.ControlBlock
|
|
}
|
|
|
|
func (s *sweepBitcoinInput) GetHash() chainhash.Hash {
|
|
return s.inputArgs.Hash
|
|
}
|
|
|
|
func (s *sweepBitcoinInput) GetIndex() uint32 {
|
|
return s.inputArgs.Index
|
|
}
|
|
|
|
func (s *sweepBitcoinInput) GetInternalKey() *secp256k1.PublicKey {
|
|
return s.internalPubkey
|
|
}
|
|
|
|
func (s *sweepBitcoinInput) GetLeafScript() []byte {
|
|
return s.sweepLeaf.Script
|
|
}
|