Covenant-less TxBuilder (#178)

* initial commit

* wip

* add bitcointree pkg in common

* add bitcoin txbuilder

* fix BuildPoolTx test

* fix sweeper

* v0 musig2 congestion tree

* bitcointree: add signatures support

* add Makefile in common

* fix lint

* fix go.mod and TxBuilder

* go mod tidy

* rename "pset" --> "psbt"

* add GetSweepInput method in TxBuilder

* fix extractSweepLeaf (bitcoin tx builder)
This commit is contained in:
Louis Singer
2024-05-31 12:49:52 +02:00
committed by GitHub
parent b5bac540ef
commit 329ba555db
29 changed files with 3586 additions and 124 deletions

View File

@@ -35,8 +35,8 @@ require (
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240521202816-d264139d666e // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect
google.golang.org/grpc v1.64.0
google.golang.org/protobuf v1.34.1 // indirect
)

View File

@@ -128,10 +128,10 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20240521202816-d264139d666e h1:SkdGTrROJl2jRGT/Fxv5QUf9jtdKCQh4KQJXbXVLAi0=
google.golang.org/genproto/googleapis/api v0.0.0-20240521202816-d264139d666e/go.mod h1:LweJcLbyVij6rCex8YunD8DYR5VDonap/jYl3ZRxcIU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw=
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=

2
common/.gitignore vendored
View File

@@ -1 +1 @@
.vscode/
.vscode/

2
common/Makefile Normal file
View File

@@ -0,0 +1,2 @@
test:
go test -v ./...

View File

@@ -0,0 +1,375 @@
package bitcointree
import (
"encoding/hex"
"fmt"
"github.com/ark-network/ark/common/tree"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
// CraftSharedOutput returns the taproot script and the amount of the initial root output
func CraftSharedOutput(
cosigners []*secp256k1.PublicKey, aspPubkey *secp256k1.PublicKey, receivers []Receiver,
feeSatsPerNode uint64, roundLifetime, unilateralExitDelay int64,
) ([]byte, int64, error) {
aggregatedKey, _, err := createAggregatedKeyWithSweep(
cosigners, aspPubkey, roundLifetime,
)
if err != nil {
return nil, 0, err
}
root, err := createRootNode(aggregatedKey, aspPubkey, receivers, feeSatsPerNode, unilateralExitDelay)
if err != nil {
return nil, 0, err
}
amount := root.getAmount() + int64(feeSatsPerNode)
scriptPubKey, err := taprootOutputScript(aggregatedKey.FinalKey)
if err != nil {
return nil, 0, err
}
return scriptPubKey, amount, err
}
// CraftCongestionTree creates all the tree's transactions
func CraftCongestionTree(
initialInput *wire.OutPoint, cosigners []*secp256k1.PublicKey, aspPubkey *secp256k1.PublicKey, receivers []Receiver,
feeSatsPerNode uint64, roundLifetime, unilateralExitDelay int64,
) (tree.CongestionTree, error) {
aggregatedKey, sweepTapLeaf, err := createAggregatedKeyWithSweep(
cosigners, aspPubkey, roundLifetime,
)
if err != nil {
return nil, err
}
root, err := createRootNode(aggregatedKey, aspPubkey, receivers, feeSatsPerNode, unilateralExitDelay)
if err != nil {
return nil, err
}
congestionTree := make(tree.CongestionTree, 0)
ins := []*wire.OutPoint{initialInput}
nodes := []node{root}
for len(nodes) > 0 {
nextNodes := make([]node, 0)
nextInputsArgs := make([]*wire.OutPoint, 0)
treeLevel := make([]tree.Node, 0)
for i, node := range nodes {
treeNode, err := getTreeNode(node, ins[i], schnorr.SerializePubKey(aggregatedKey.PreTweakedKey), sweepTapLeaf)
if err != nil {
return nil, err
}
nodeTxHash, err := chainhash.NewHashFromStr(treeNode.Txid)
if err != nil {
return nil, err
}
treeLevel = append(treeLevel, treeNode)
children := node.getChildren()
for i, child := range children {
nextNodes = append(nextNodes, child)
nextInputsArgs = append(nextInputsArgs, &wire.OutPoint{
Hash: *nodeTxHash,
Index: uint32(i),
})
}
}
congestionTree = append(congestionTree, treeLevel)
nodes = append([]node{}, nextNodes...)
ins = append([]*wire.OutPoint{}, nextInputsArgs...)
}
return congestionTree, nil
}
type node interface {
getAmount() int64 // returns the input amount of the node = sum of all receivers' amounts + fees
getOutputs() ([]*wire.TxOut, error)
getChildren() []node
}
type leaf struct {
aspKey *secp256k1.PublicKey
vtxoKey *secp256k1.PublicKey
exitDelay int64
amount int64
}
type branch struct {
aggregatedKey *musig2.AggregateKey
children []node
feeAmount int64
}
func (b *branch) getChildren() []node {
return b.children
}
func (l *leaf) getChildren() []node {
return []node{}
}
func (b *branch) getAmount() int64 {
amount := int64(0)
for _, child := range b.children {
amount += child.getAmount()
amount += b.feeAmount
}
return amount
}
func (l *leaf) getAmount() int64 {
return l.amount
}
func (l *leaf) getOutputs() ([]*wire.TxOut, error) {
redeemClosure := &CSVSigClosure{
Pubkey: l.vtxoKey,
Seconds: uint(l.exitDelay),
}
redeemLeaf, err := redeemClosure.Leaf()
if err != nil {
return nil, err
}
forfeitClosure := &ForfeitClosure{
Pubkey: l.vtxoKey,
AspPubkey: l.aspKey,
}
forfeitLeaf, err := forfeitClosure.Leaf()
if err != nil {
return nil, err
}
leafTaprootTree := txscript.AssembleTaprootScriptTree(
*redeemLeaf, *forfeitLeaf,
)
root := leafTaprootTree.RootNode.TapHash()
taprootKey := txscript.ComputeTaprootOutputKey(
UnspendableKey(),
root[:],
)
script, err := taprootOutputScript(taprootKey)
if err != nil {
return nil, err
}
output := &wire.TxOut{
Value: l.amount,
PkScript: script,
}
return []*wire.TxOut{output}, nil
}
func (b *branch) getOutputs() ([]*wire.TxOut, error) {
sharedOutputScript, err := taprootOutputScript(b.aggregatedKey.FinalKey)
if err != nil {
return nil, err
}
outputs := make([]*wire.TxOut, 0)
for _, child := range b.children {
outputs = append(outputs, &wire.TxOut{
Value: child.getAmount() + b.feeAmount,
PkScript: sharedOutputScript,
})
}
return outputs, nil
}
func getTreeNode(
n node,
input *wire.OutPoint,
inputTapInternalKey []byte,
inputSweepTapLeaf *psbt.TaprootTapLeafScript,
) (tree.Node, error) {
partialTx, err := getTx(n, input, inputTapInternalKey, inputSweepTapLeaf)
if err != nil {
return tree.Node{}, err
}
txid := partialTx.UnsignedTx.TxHash().String()
tx, err := partialTx.B64Encode()
if err != nil {
return tree.Node{}, err
}
return tree.Node{
Txid: txid,
Tx: tx,
ParentTxid: input.Hash.String(),
Leaf: len(n.getChildren()) == 0,
}, nil
}
func getTx(
n node,
input *wire.OutPoint,
inputTapInternalKey []byte,
inputSweepTapLeaf *psbt.TaprootTapLeafScript,
) (*psbt.Packet, error) {
outputs, err := n.getOutputs()
if err != nil {
return nil, err
}
tx, err := psbt.New([]*wire.OutPoint{input}, outputs, 2, 0, []uint32{wire.MaxTxInSequenceNum})
if err != nil {
return nil, err
}
updater, err := psbt.NewUpdater(tx)
if err != nil {
return nil, err
}
if err := updater.AddInSighashType(0, int(txscript.SigHashDefault)); err != nil {
return nil, err
}
tx.Inputs[0].TaprootInternalKey = inputTapInternalKey
tx.Inputs[0].TaprootLeafScript = []*psbt.TaprootTapLeafScript{inputSweepTapLeaf}
return tx, nil
}
func createRootNode(
aggregatedKey *musig2.AggregateKey, aspPubkey *secp256k1.PublicKey, receivers []Receiver,
feeSatsPerNode uint64, unilateralExitDelay int64,
) (root node, err error) {
if len(receivers) == 0 {
return nil, fmt.Errorf("no receivers provided")
}
nodes := make([]node, 0, len(receivers))
for _, r := range receivers {
pubkeyBytes, err := hex.DecodeString(r.Pubkey)
if err != nil {
return nil, err
}
receiverKey, err := secp256k1.ParsePubKey(pubkeyBytes)
if err != nil {
return nil, err
}
leafNode := &leaf{
aspKey: aspPubkey,
vtxoKey: receiverKey,
exitDelay: unilateralExitDelay,
amount: int64(r.Amount),
}
nodes = append(nodes, leafNode)
}
for len(nodes) > 1 {
nodes, err = createUpperLevel(nodes, aggregatedKey, int64(feeSatsPerNode))
if err != nil {
return
}
}
return nodes[0], nil
}
func createAggregatedKeyWithSweep(
cosigners []*secp256k1.PublicKey, aspPubkey *secp256k1.PublicKey, roundLifetime int64,
) (*musig2.AggregateKey, *psbt.TaprootTapLeafScript, error) {
sweepClosure := &CSVSigClosure{
Pubkey: aspPubkey,
Seconds: uint(roundLifetime),
}
sweepLeaf, err := sweepClosure.Leaf()
if err != nil {
return nil, nil, err
}
tapTree := txscript.AssembleTaprootScriptTree(*sweepLeaf)
tapTreeRoot := tapTree.RootNode.TapHash()
aggregatedKey, err := AggregateKeys(
cosigners, tapTreeRoot[:],
)
if err != nil {
return nil, nil, err
}
index := tapTree.LeafProofIndex[sweepLeaf.TapHash()]
proof := tapTree.LeafMerkleProofs[index]
controlBlock := proof.ToControlBlock(aggregatedKey.PreTweakedKey)
controlBlockBytes, err := controlBlock.ToBytes()
if err != nil {
return nil, nil, err
}
tapLeaf := &psbt.TaprootTapLeafScript{
ControlBlock: controlBlockBytes,
Script: sweepLeaf.Script,
LeafVersion: sweepLeaf.LeafVersion,
}
return aggregatedKey, tapLeaf, nil
}
func createUpperLevel(nodes []node, aggregatedKey *musig2.AggregateKey, feeAmount int64) ([]node, error) {
if len(nodes)%2 != 0 {
last := nodes[len(nodes)-1]
pairs, err := createUpperLevel(nodes[:len(nodes)-1], aggregatedKey, feeAmount)
if err != nil {
return nil, err
}
return append(pairs, last), nil
}
pairs := make([]node, 0, len(nodes)/2)
for i := 0; i < len(nodes); i += 2 {
left := nodes[i]
right := nodes[i+1]
branchNode := &branch{
aggregatedKey: aggregatedKey,
feeAmount: feeAmount,
children: []node{left, right},
}
pairs = append(pairs, branchNode)
}
return pairs, nil
}
func taprootOutputScript(taprootKey *secp256k1.PublicKey) ([]byte, error) {
return txscript.NewScriptBuilder().AddOp(txscript.OP_1).AddData(
schnorr.SerializePubKey(taprootKey),
).Script()
}

View File

@@ -0,0 +1,505 @@
package bitcointree
import (
"errors"
"io"
"strings"
"github.com/ark-network/ark/common/tree"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
var (
ErrCongestionTreeNotSet = errors.New("congestion tree not set")
ErrAggregateKeyNotSet = errors.New("aggregate key not set")
)
type TreeNonces [][][66]byte // public nonces
type TreePartialSigs [][]*musig2.PartialSignature
type SignerSession interface {
GetNonces(*btcec.PublicKey) (TreeNonces, error) // generate of return cached nonce for this session
SetKeys([]*btcec.PublicKey, TreeNonces) error // set the keys for this session (with the combined nonces)
Sign(*btcec.PrivateKey) (TreePartialSigs, error) // sign the tree
}
type CoordinatorSession interface {
AddNonce(*btcec.PublicKey, TreeNonces) error
AggregateNonces() (TreeNonces, error)
AddSig(*btcec.PublicKey, TreePartialSigs) error
// SignTree combines the signatures and add them to the tree's psbts
SignTree() (tree.CongestionTree, error)
}
func AggregateKeys(
pubkeys []*btcec.PublicKey,
scriptRoot []byte,
) (*musig2.AggregateKey, error) {
key, _, _, err := musig2.AggregateKeys(pubkeys, true,
musig2.WithTaprootKeyTweak(scriptRoot),
)
if err != nil {
return nil, err
}
return key, nil
}
func ValidateTreeSigs(
minRelayFee int64,
scriptRoot []byte,
finalAggregatedKey *btcec.PublicKey,
tree tree.CongestionTree,
) error {
prevoutFetcher, err := prevOutFetcherFactory(minRelayFee, finalAggregatedKey)
if err != nil {
return err
}
for _, level := range tree {
for _, node := range level {
partialTx, err := psbt.NewFromRawBytes(strings.NewReader(node.Tx), true)
if err != nil {
return err
}
sig := partialTx.Inputs[0].TaprootKeySpendSig
if len(sig) == 0 {
return errors.New("unsigned tree input")
}
schnorrSig, err := schnorr.ParseSignature(sig)
if err != nil {
return err
}
inputFetcher := prevoutFetcher(partialTx)
message, err := txscript.CalcTaprootSignatureHash(
txscript.NewTxSigHashes(partialTx.UnsignedTx, inputFetcher),
txscript.SigHashDefault,
partialTx.UnsignedTx,
0,
inputFetcher,
)
if err != nil {
return err
}
if !schnorrSig.Verify(message, finalAggregatedKey) {
return errors.New("invalid signature")
}
}
}
return nil
}
func (n TreeNonces) Decode(r io.Reader, matrixFormat []int) error {
for i := 0; i < len(matrixFormat); i++ {
for j := 0; j < matrixFormat[i]; j++ {
// read 66 bytes
nonce := make([]byte, 66)
_, err := r.Read(nonce)
if err != nil {
return err
}
n[i][j] = [66]byte(nonce)
}
}
return nil
}
func (n TreeNonces) Encode(w io.Writer) error {
for i := 0; i < len(n); i++ {
for j := 0; j < len(n[i]); j++ {
nonce := n[i][j][:]
_, err := w.Write(nonce)
if err != nil {
return err
}
}
}
return nil
}
func (n TreePartialSigs) Decode(r io.Reader, matrixFormat []int) error {
for i := 0; i < len(matrixFormat); i++ {
for j := 0; j < matrixFormat[i]; j++ {
sig := &musig2.PartialSignature{}
if err := sig.Decode(r); err != nil {
return err
}
}
}
return nil
}
func (n TreePartialSigs) Encode(w io.Writer) error {
for i := 0; i < len(n); i++ {
for j := 0; j < len(n[i]); j++ {
if err := n[i][j].Encode(w); err != nil {
return err
}
}
}
return nil
}
func NewTreeSignerSession(
congestionTree tree.CongestionTree,
minRelayFee int64,
scriptRoot []byte,
) SignerSession {
return &treeSignerSession{
tree: congestionTree,
minRelayFee: minRelayFee,
scriptRoot: scriptRoot,
}
}
type treeSignerSession struct {
tree tree.CongestionTree
myNonces [][]*musig2.Nonces
keys []*btcec.PublicKey
aggregateNonces TreeNonces
minRelayFee int64
scriptRoot []byte
prevoutFetcher func(*psbt.Packet) txscript.PrevOutputFetcher
}
func (t *treeSignerSession) generateNonces(key *btcec.PublicKey) error {
if t.tree == nil {
return ErrCongestionTreeNotSet
}
myNonces := make([][]*musig2.Nonces, 0)
for _, level := range t.tree {
levelNonces := make([]*musig2.Nonces, 0)
for range level {
nonce, err := musig2.GenNonces(
musig2.WithPublicKey(key),
)
if err != nil {
return err
}
levelNonces = append(levelNonces, nonce)
}
myNonces = append(myNonces, levelNonces)
}
t.myNonces = myNonces
return nil
}
func (t *treeSignerSession) GetNonces(key *btcec.PublicKey) (TreeNonces, error) {
if t.tree == nil {
return nil, ErrCongestionTreeNotSet
}
if t.myNonces == nil {
if err := t.generateNonces(key); err != nil {
return nil, err
}
}
nonces := make(TreeNonces, 0)
for _, level := range t.myNonces {
levelNonces := make([][66]byte, 0)
for _, nonce := range level {
levelNonces = append(levelNonces, nonce.PubNonce)
}
nonces = append(nonces, levelNonces)
}
return nonces, nil
}
func (t *treeSignerSession) SetKeys(keys []*btcec.PublicKey, nonces TreeNonces) error {
if t.keys != nil {
return errors.New("keys already set")
}
if t.aggregateNonces != nil {
return errors.New("nonces already set")
}
aggregateKey, err := AggregateKeys(keys, t.scriptRoot)
if err != nil {
return err
}
prevoutFetcher, err := prevOutFetcherFactory(t.minRelayFee, aggregateKey.FinalKey)
if err != nil {
return err
}
t.prevoutFetcher = prevoutFetcher
t.aggregateNonces = nonces
t.keys = keys
return nil
}
func (t *treeSignerSession) Sign(seckey *secp256k1.PrivateKey) (TreePartialSigs, error) {
if t.tree == nil {
return nil, ErrCongestionTreeNotSet
}
if t.keys == nil {
return nil, ErrAggregateKeyNotSet
}
if t.aggregateNonces == nil {
return nil, errors.New("nonces not set")
}
sigs := make(TreePartialSigs, 0)
for i, level := range t.tree {
levelSigs := make([]*musig2.PartialSignature, 0)
for j, node := range level {
partialTx, err := psbt.NewFromRawBytes(strings.NewReader(node.Tx), true)
if err != nil {
return nil, err
}
// sign the node
sig, err := t.signPartial(partialTx, i, j, seckey)
if err != nil {
return nil, err
}
levelSigs = append(levelSigs, sig)
}
sigs = append(sigs, levelSigs)
}
return sigs, nil
}
func (t *treeSignerSession) signPartial(partialTx *psbt.Packet, posx int, posy int, seckey *btcec.PrivateKey) (*musig2.PartialSignature, error) {
inputFetcher := t.prevoutFetcher(partialTx)
myNonce := t.myNonces[posx][posy]
aggregatedNonce := t.aggregateNonces[posx][posy]
message, err := txscript.CalcTaprootSignatureHash(
txscript.NewTxSigHashes(partialTx.UnsignedTx, inputFetcher),
txscript.SigHashDefault,
partialTx.UnsignedTx,
0,
inputFetcher,
)
if err != nil {
return nil, err
}
return musig2.Sign(
myNonce.SecNonce, seckey, aggregatedNonce, t.keys, [32]byte(message),
musig2.WithSortedKeys(), musig2.WithTaprootSignTweak(t.scriptRoot),
)
}
type treeCoordinatorSession struct {
scriptRoot []byte
tree tree.CongestionTree
keys []*btcec.PublicKey
nonces []TreeNonces
sigs []TreePartialSigs
prevoutFetcher func(*psbt.Packet) txscript.PrevOutputFetcher
}
func NewTreeCoordinatorSession(congestionTree tree.CongestionTree, minRelayFee int64, scriptRoot []byte, keys []*btcec.PublicKey) (CoordinatorSession, error) {
aggregateKey, err := AggregateKeys(keys, scriptRoot)
if err != nil {
return nil, err
}
prevoutFetcher, err := prevOutFetcherFactory(minRelayFee, aggregateKey.FinalKey)
if err != nil {
return nil, err
}
return &treeCoordinatorSession{
scriptRoot: scriptRoot,
tree: congestionTree,
keys: keys,
nonces: make([]TreeNonces, len(keys)),
sigs: make([]TreePartialSigs, len(keys)),
prevoutFetcher: prevoutFetcher,
}, nil
}
func (t *treeCoordinatorSession) getPubkeyIndex(pubkey *btcec.PublicKey) int {
for i, key := range t.keys {
if key.IsEqual(pubkey) {
return i
}
}
return -1
}
func (t *treeCoordinatorSession) AddNonce(pubkey *btcec.PublicKey, nonce TreeNonces) error {
index := t.getPubkeyIndex(pubkey)
if index == -1 {
return errors.New("public key not found")
}
t.nonces[index] = nonce
return nil
}
func (t *treeCoordinatorSession) AddSig(pubkey *btcec.PublicKey, sig TreePartialSigs) error {
index := t.getPubkeyIndex(pubkey)
if index == -1 {
return errors.New("public key not found")
}
t.sigs[index] = sig
return nil
}
func (t *treeCoordinatorSession) AggregateNonces() (TreeNonces, error) {
for _, nonce := range t.nonces {
if nonce == nil {
return nil, errors.New("nonces not set")
}
}
aggregatedNonces := make(TreeNonces, 0)
for i, level := range t.tree {
levelNonces := make([][66]byte, 0)
for j := range level {
nonces := make([][66]byte, 0)
for _, n := range t.nonces {
nonces = append(nonces, n[i][j])
}
aggregatedNonce, err := musig2.AggregateNonces(nonces)
if err != nil {
return nil, err
}
levelNonces = append(levelNonces, aggregatedNonce)
}
aggregatedNonces = append(aggregatedNonces, levelNonces)
}
return aggregatedNonces, nil
}
// SignTree implements CoordinatorSession.
func (t *treeCoordinatorSession) SignTree() (tree.CongestionTree, error) {
for _, sig := range t.sigs {
if sig == nil {
return nil, errors.New("signatures not set")
}
}
aggregatedKey, err := AggregateKeys(t.keys, t.scriptRoot)
if err != nil {
return nil, err
}
for i, level := range t.tree {
for j, node := range level {
partialTx, err := psbt.NewFromRawBytes(strings.NewReader(node.Tx), true)
if err != nil {
return nil, err
}
sigs := make([]*musig2.PartialSignature, 0)
for _, sig := range t.sigs {
sigs = append(sigs, sig[i][j])
}
inputFetcher := t.prevoutFetcher(partialTx)
message, err := txscript.CalcTaprootSignatureHash(
txscript.NewTxSigHashes(partialTx.UnsignedTx, inputFetcher),
txscript.SigHashDefault,
partialTx.UnsignedTx,
0,
inputFetcher,
)
combinedSig := musig2.CombineSigs(
sigs[0].R, sigs,
musig2.WithTaprootTweakedCombine([32]byte(message), t.keys, t.scriptRoot, true),
)
if err != nil {
return nil, err
}
if !combinedSig.Verify(message, aggregatedKey.FinalKey) {
return nil, errors.New("invalid signature")
}
partialTx.Inputs[0].TaprootKeySpendSig = combinedSig.Serialize()
encodedSignedTx, err := partialTx.B64Encode()
if err != nil {
return nil, err
}
node.Tx = encodedSignedTx
t.tree[i][j] = node
}
}
return t.tree, nil
}
// given a final aggregated key and a min-relay-fee, returns the expected prevout
func prevOutFetcherFactory(
feeAmount int64, finalAggregatedKey *btcec.PublicKey,
) (
func(partial *psbt.Packet) txscript.PrevOutputFetcher, error,
) {
pkscript, err := taprootOutputScript(finalAggregatedKey)
if err != nil {
return nil, err
}
return func(partial *psbt.Packet) txscript.PrevOutputFetcher {
outputsAmount := int64(0)
for _, output := range partial.UnsignedTx.TxOut {
outputsAmount += output.Value
}
return &treePrevOutFetcher{
prevout: &wire.TxOut{
Value: outputsAmount + feeAmount,
PkScript: pkscript,
},
}
}, nil
}
type treePrevOutFetcher struct {
prevout *wire.TxOut
}
func (f *treePrevOutFetcher) FetchPrevOutput(wire.OutPoint) *wire.TxOut {
return f.prevout
}

View File

@@ -0,0 +1,176 @@
package bitcointree_test
import (
"encoding/json"
"os"
"testing"
"github.com/ark-network/ark/common/bitcointree"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/stretchr/testify/require"
)
const (
minRelayFee = 1000
exitDelay = 512
lifetime = 1024
)
var testTxid, _ = chainhash.NewHashFromStr("49f8664acc899be91902f8ade781b7eeb9cbe22bdd9efbc36e56195de21bcd12")
func TestRoundTripSignTree(t *testing.T) {
fixtures := parseFixtures(t)
for _, f := range fixtures.Valid {
alice, err := secp256k1.GeneratePrivateKey()
require.NoError(t, err)
bob, err := secp256k1.GeneratePrivateKey()
require.NoError(t, err)
asp, err := secp256k1.GeneratePrivateKey()
require.NoError(t, err)
cosigners := make([]*secp256k1.PublicKey, 0)
cosigners = append(cosigners, alice.PubKey())
cosigners = append(cosigners, bob.PubKey())
cosigners = append(cosigners, asp.PubKey())
// Create a new tree
tree, err := bitcointree.CraftCongestionTree(
&wire.OutPoint{
Hash: *testTxid,
Index: 0,
},
cosigners,
asp.PubKey(),
f.Receivers,
minRelayFee,
lifetime,
exitDelay,
)
require.NoError(t, err)
sweepClosure := bitcointree.CSVSigClosure{
Pubkey: asp.PubKey(),
Seconds: lifetime,
}
sweepTapLeaf, err := sweepClosure.Leaf()
require.NoError(t, err)
sweepTapTree := txscript.AssembleTaprootScriptTree(*sweepTapLeaf)
root := sweepTapTree.RootNode.TapHash()
aspCoordinator, err := bitcointree.NewTreeCoordinatorSession(
tree,
minRelayFee,
root.CloneBytes(),
[]*secp256k1.PublicKey{alice.PubKey(), bob.PubKey(), asp.PubKey()},
)
require.NoError(t, err)
aliceSession := bitcointree.NewTreeSignerSession(tree, minRelayFee, root.CloneBytes())
bobSession := bitcointree.NewTreeSignerSession(tree, minRelayFee, root.CloneBytes())
aspSession := bitcointree.NewTreeSignerSession(tree, minRelayFee, root.CloneBytes())
aliceNonces, err := aliceSession.GetNonces(alice.PubKey())
require.NoError(t, err)
bobNonces, err := bobSession.GetNonces(bob.PubKey())
require.NoError(t, err)
aspNonces, err := aspSession.GetNonces(asp.PubKey())
require.NoError(t, err)
err = aspCoordinator.AddNonce(alice.PubKey(), aliceNonces)
require.NoError(t, err)
err = aspCoordinator.AddNonce(bob.PubKey(), bobNonces)
require.NoError(t, err)
err = aspCoordinator.AddNonce(asp.PubKey(), aspNonces)
require.NoError(t, err)
aggregatedNonce, err := aspCoordinator.AggregateNonces()
require.NoError(t, err)
// coordinator sends the combined nonce to all signers
err = aliceSession.SetKeys(
cosigners,
aggregatedNonce,
)
require.NoError(t, err)
err = bobSession.SetKeys(
cosigners,
aggregatedNonce,
)
require.NoError(t, err)
err = aspSession.SetKeys(
cosigners,
aggregatedNonce,
)
require.NoError(t, err)
aliceSig, err := aliceSession.Sign(alice)
require.NoError(t, err)
bobSig, err := bobSession.Sign(bob)
require.NoError(t, err)
aspSig, err := aspSession.Sign(asp)
require.NoError(t, err)
// coordinator receives the signatures and combines them
err = aspCoordinator.AddSig(alice.PubKey(), aliceSig)
require.NoError(t, err)
err = aspCoordinator.AddSig(bob.PubKey(), bobSig)
require.NoError(t, err)
err = aspCoordinator.AddSig(asp.PubKey(), aspSig)
require.NoError(t, err)
signedTree, err := aspCoordinator.SignTree()
require.NoError(t, err)
// verify the tree
aggregatedKey, err := bitcointree.AggregateKeys(cosigners, root.CloneBytes())
require.NoError(t, err)
err = bitcointree.ValidateTreeSigs(
minRelayFee,
root.CloneBytes(),
aggregatedKey.FinalKey,
signedTree,
)
require.NoError(t, err)
}
}
type fixture struct {
Valid []struct {
Receivers []bitcointree.Receiver `json:"receivers"`
} `json:"valid"`
}
func parseFixtures(t *testing.T) fixture {
file, err := os.ReadFile("testdata/musig2.json")
require.NoError(t, err)
v := map[string]interface{}{}
err = json.Unmarshal(file, &v)
require.NoError(t, err)
vv := v["treeSignature"].(map[string]interface{})
file, _ = json.Marshal(vv)
var fixtures fixture
err = json.Unmarshal(file, &fixtures)
require.NoError(t, err)
return fixtures
}

View File

@@ -0,0 +1,197 @@
package bitcointree
import (
"bytes"
"fmt"
"github.com/ark-network/ark/common"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/txscript"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
type Closure interface {
Leaf() (*txscript.TapLeaf, error)
Decode(script []byte) (bool, error)
}
type CSVSigClosure struct {
Pubkey *secp256k1.PublicKey
Seconds uint
}
type ForfeitClosure struct {
Pubkey *secp256k1.PublicKey
AspPubkey *secp256k1.PublicKey
}
func DecodeClosure(script []byte) (Closure, error) {
var closure Closure
closure = &CSVSigClosure{}
if valid, err := closure.Decode(script); err == nil && valid {
return closure, nil
}
closure = &ForfeitClosure{}
if valid, err := closure.Decode(script); err == nil && valid {
return closure, nil
}
return nil, fmt.Errorf("invalid closure script")
}
func (f *ForfeitClosure) Leaf() (*txscript.TapLeaf, error) {
aspKeyBytes := schnorr.SerializePubKey(f.AspPubkey)
userKeyBytes := schnorr.SerializePubKey(f.Pubkey)
script, err := txscript.NewScriptBuilder().AddData(aspKeyBytes).
AddOp(txscript.OP_CHECKSIGVERIFY).AddData(userKeyBytes).
AddOp(txscript.OP_CHECKSIG).Script()
if err != nil {
return nil, err
}
tapLeaf := txscript.NewBaseTapLeaf(script)
return &tapLeaf, nil
}
func (f *ForfeitClosure) Decode(script []byte) (bool, error) {
valid, aspPubKey, err := decodeChecksigScript(script)
if err != nil {
return false, err
}
if !valid {
return false, nil
}
valid, pubkey, err := decodeChecksigScript(script[33:])
if err != nil {
return false, err
}
if !valid {
return false, nil
}
f.Pubkey = pubkey
f.AspPubkey = aspPubKey
rebuilt, err := f.Leaf()
if err != nil {
return false, err
}
if !bytes.Equal(rebuilt.Script, script) {
return false, nil
}
return true, nil
}
func (d *CSVSigClosure) Leaf() (*txscript.TapLeaf, error) {
script, err := encodeCsvWithChecksigScript(d.Pubkey, d.Seconds)
if err != nil {
return nil, err
}
tapLeaf := txscript.NewBaseTapLeaf(script)
return &tapLeaf, nil
}
func (d *CSVSigClosure) Decode(script []byte) (bool, error) {
csvIndex := bytes.Index(
script, []byte{txscript.OP_CHECKSEQUENCEVERIFY, txscript.OP_DROP},
)
if csvIndex == -1 || csvIndex == 0 {
return false, nil
}
sequence := script[1:csvIndex]
seconds, err := common.BIP68Decode(sequence)
if err != nil {
return false, err
}
checksigScript := script[csvIndex+2:]
valid, pubkey, err := decodeChecksigScript(checksigScript)
if err != nil {
return false, err
}
if !valid {
return false, nil
}
rebuilt, err := encodeCsvWithChecksigScript(pubkey, seconds)
if err != nil {
return false, err
}
if !bytes.Equal(rebuilt, script) {
return false, nil
}
d.Pubkey = pubkey
d.Seconds = seconds
return valid, nil
}
func decodeChecksigScript(script []byte) (bool, *secp256k1.PublicKey, error) {
data32Index := bytes.Index(script, []byte{txscript.OP_DATA_32})
if data32Index == -1 {
return false, nil, nil
}
key := script[data32Index+1 : data32Index+33]
if len(key) != 32 {
return false, nil, nil
}
pubkey, err := schnorr.ParsePubKey(key)
if err != nil {
return false, nil, err
}
return true, pubkey, nil
}
// checkSequenceVerifyScript without checksig
func encodeCsvScript(seconds uint) ([]byte, error) {
sequence, err := common.BIP68Encode(seconds)
if err != nil {
return nil, err
}
return txscript.NewScriptBuilder().AddData(sequence).AddOps([]byte{
txscript.OP_CHECKSEQUENCEVERIFY,
txscript.OP_DROP,
}).Script()
}
// checkSequenceVerifyScript + checksig
func encodeCsvWithChecksigScript(
pubkey *secp256k1.PublicKey, seconds uint,
) ([]byte, error) {
script, err := encodeChecksigScript(pubkey)
if err != nil {
return nil, err
}
csvScript, err := encodeCsvScript(seconds)
if err != nil {
return nil, err
}
return append(csvScript, script...), nil
}
func encodeChecksigScript(pubkey *secp256k1.PublicKey) ([]byte, error) {
key := schnorr.SerializePubKey(pubkey)
return txscript.NewScriptBuilder().AddData(key).
AddOp(txscript.OP_CHECKSIG).Script()
}

50
common/bitcointree/testdata/musig2.json vendored Normal file
View File

@@ -0,0 +1,50 @@
{
"treeSignature": {
"valid": [
{
"receivers": [
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1100
}
]
},
{
"receivers": [
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1100
},
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 8000
}
]
},
{
"receivers": [
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1100
},
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1100
},
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1100
},
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1000
},
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1100
}
]
}
]
}
}

View File

@@ -0,0 +1,6 @@
package bitcointree
type Receiver struct {
Pubkey string
Amount uint64
}

View File

@@ -0,0 +1,255 @@
package bitcointree
import (
"bytes"
"errors"
"fmt"
"strings"
"github.com/ark-network/ark/common/tree"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
var (
ErrInvalidPoolTransaction = errors.New("invalid pool transaction")
ErrInvalidPoolTransactionOutputs = errors.New("invalid number of outputs in pool transaction")
ErrEmptyTree = errors.New("empty congestion tree")
ErrInvalidRootLevel = errors.New("root level must have only one node")
ErrNoLeaves = errors.New("no leaves in the tree")
ErrNodeTransactionEmpty = errors.New("node transaction is empty")
ErrNodeTxidEmpty = errors.New("node txid is empty")
ErrNodeParentTxidEmpty = errors.New("node parent txid is empty")
ErrNodeTxidDifferent = errors.New("node txid differs from node transaction")
ErrNumberOfInputs = errors.New("node transaction should have only one input")
ErrNumberOfOutputs = errors.New("node transaction should have only three or two outputs")
ErrParentTxidInput = errors.New("parent txid should be the input of the node transaction")
ErrNumberOfChildren = errors.New("node branch transaction should have two children")
ErrLeafChildren = errors.New("leaf node should have max 1 child")
ErrInvalidChildTxid = errors.New("invalid child txid")
ErrNumberOfTapscripts = errors.New("input should have 1 tapscript leaf")
ErrInternalKey = errors.New("invalid taproot internal key")
ErrInvalidTaprootScript = errors.New("invalid taproot script")
ErrInvalidControlBlock = errors.New("invalid control block")
ErrInvalidTaprootScriptLen = errors.New("invalid taproot script length (expected 32 bytes)")
ErrInvalidLeafTaprootScript = errors.New("invalid leaf taproot script")
ErrInvalidAmount = errors.New("children amount is different from parent amount")
ErrInvalidSweepSequence = errors.New("invalid sweep sequence")
ErrInvalidASP = errors.New("invalid ASP")
ErrMissingFeeOutput = errors.New("missing fee output")
ErrInvalidLeftOutput = errors.New("invalid left output")
ErrInvalidRightOutput = errors.New("invalid right output")
ErrMissingSweepTapscript = errors.New("missing sweep tapscript")
ErrInvalidLeaf = errors.New("leaf node shouldn't have children")
ErrWrongPoolTxID = errors.New("root input should be the pool tx outpoint")
)
// 0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0
var unspendablePoint = []byte{
0x02, 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a,
0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0,
}
const (
sharedOutputIndex = 0
)
func UnspendableKey() *secp256k1.PublicKey {
key, _ := secp256k1.ParsePubKey(unspendablePoint)
return key
}
// ValidateCongestionTree checks if the given congestion tree is valid
// poolTxID & poolTxIndex & poolTxAmount are used to validate the root input outpoint
// aspPublicKey & roundLifetime are used to validate the sweep tapscript leaves
// besides that, the function validates:
// - the number of nodes
// - the number of leaves
// - children coherence with parent
// - every control block and taproot output scripts
// - input and output amounts
func ValidateCongestionTree(
tree tree.CongestionTree, poolTx string, aspPublicKey *secp256k1.PublicKey,
roundLifetime int64, cosigners []*secp256k1.PublicKey, minRelayFee int64,
) error {
poolTransaction, err := psbt.NewFromRawBytes(strings.NewReader(poolTx), true)
if err != nil {
return ErrInvalidPoolTransaction
}
if len(poolTransaction.Outputs) < sharedOutputIndex+1 {
return ErrInvalidPoolTransactionOutputs
}
poolTxAmount := poolTransaction.UnsignedTx.TxOut[sharedOutputIndex].Value
nbNodes := tree.NumberOfNodes()
if nbNodes == 0 {
return ErrEmptyTree
}
if len(tree[0]) != 1 {
return ErrInvalidRootLevel
}
// check that root input is connected to the pool tx
rootPsetB64 := tree[0][0].Tx
rootPset, err := psbt.NewFromRawBytes(strings.NewReader(rootPsetB64), true)
if err != nil {
return fmt.Errorf("invalid root transaction: %w", err)
}
if len(rootPset.Inputs) != 1 {
return ErrNumberOfInputs
}
rootInput := rootPset.UnsignedTx.TxIn[0]
if chainhash.Hash(rootInput.PreviousOutPoint.Hash).String() != poolTransaction.UnsignedTx.TxHash().String() ||
rootInput.PreviousOutPoint.Index != sharedOutputIndex {
return ErrWrongPoolTxID
}
sumRootValue := minRelayFee
for _, output := range rootPset.UnsignedTx.TxOut {
sumRootValue += output.Value
}
if sumRootValue != poolTxAmount {
return ErrInvalidAmount
}
if len(tree.Leaves()) == 0 {
return ErrNoLeaves
}
sweepClosure := &CSVSigClosure{
Seconds: uint(roundLifetime),
Pubkey: aspPublicKey,
}
sweepLeaf, err := sweepClosure.Leaf()
if err != nil {
return err
}
tapTree := txscript.AssembleTaprootScriptTree(*sweepLeaf)
root := tapTree.RootNode.TapHash()
signers := append(cosigners, aspPublicKey)
aggregatedKey, err := AggregateKeys(signers, root[:])
if err != nil {
return err
}
// iterates over all the nodes of the tree
for _, level := range tree {
for _, node := range level {
if err := validateNodeTransaction(
node, tree, aggregatedKey, minRelayFee,
); err != nil {
return err
}
}
}
return nil
}
func validateNodeTransaction(
node tree.Node, tree tree.CongestionTree,
expectedAggregatedKey *musig2.AggregateKey, minRelayFee int64,
) error {
if node.Tx == "" {
return ErrNodeTransactionEmpty
}
if node.Txid == "" {
return ErrNodeTxidEmpty
}
if node.ParentTxid == "" {
return ErrNodeParentTxidEmpty
}
decodedPset, err := psbt.NewFromRawBytes(strings.NewReader(node.Tx), true)
if err != nil {
return fmt.Errorf("invalid node transaction: %w", err)
}
if decodedPset.UnsignedTx.TxHash().String() != node.Txid {
return ErrNodeTxidDifferent
}
if len(decodedPset.Inputs) != 1 {
return ErrNumberOfInputs
}
input := decodedPset.Inputs[0]
if len(input.TaprootLeafScript) != 1 {
return ErrNumberOfTapscripts
}
prevTxid := decodedPset.UnsignedTx.TxIn[0].PreviousOutPoint.Hash.String()
if prevTxid != node.ParentTxid {
return ErrParentTxidInput
}
children := tree.Children(node.Txid)
if node.Leaf && len(children) >= 1 {
return ErrLeafChildren
}
for childIndex, child := range children {
childTx, err := psbt.NewFromRawBytes(strings.NewReader(child.Tx), true)
if err != nil {
return fmt.Errorf("invalid child transaction: %w", err)
}
parentOutput := decodedPset.UnsignedTx.TxOut[childIndex]
previousScriptKey := parentOutput.PkScript[2:]
if len(previousScriptKey) != 32 {
return ErrInvalidTaprootScript
}
inputData := decodedPset.Inputs[0]
inputTapInternalKey, err := schnorr.ParsePubKey(inputData.TaprootInternalKey)
if err != nil {
return fmt.Errorf("invalid internal key: %w", err)
}
if !bytes.Equal(inputData.TaprootInternalKey, schnorr.SerializePubKey(expectedAggregatedKey.PreTweakedKey)) {
return ErrInternalKey
}
inputTapLeaf := inputData.TaprootLeafScript[0]
ctrlBlock, err := txscript.ParseControlBlock(inputTapLeaf.ControlBlock)
if err != nil {
return ErrInvalidControlBlock
}
rootHash := ctrlBlock.RootHash(inputTapLeaf.Script)
tapKey := txscript.ComputeTaprootOutputKey(inputTapInternalKey, rootHash)
if !bytes.Equal(schnorr.SerializePubKey(tapKey), schnorr.SerializePubKey(expectedAggregatedKey.FinalKey)) {
return ErrInvalidTaprootScript
}
sumChildAmount := minRelayFee
for _, output := range childTx.UnsignedTx.TxOut {
sumChildAmount += output.Value
}
if sumChildAmount != parentOutput.Value {
return ErrInvalidAmount
}
}
return nil
}

View File

@@ -6,13 +6,13 @@ require (
github.com/btcsuite/btcd v0.23.1
github.com/btcsuite/btcd/btcec/v2 v2.3.2
github.com/btcsuite/btcd/btcutil v1.1.3
github.com/btcsuite/btcd/btcutil/psbt v1.1.4
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0
github.com/stretchr/testify v1.8.0
)
require (
github.com/btcsuite/btcd/btcutil/psbt v1.1.4 // indirect
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
github.com/vulpemventures/fastsha256 v0.0.0-20160815193821-637e65642941 // indirect

View File

@@ -23,6 +23,7 @@ service ArkService {
body: "*"
};
};
// TODO BTC: signTree rpc
rpc GetRound(GetRoundRequest) returns (GetRoundResponse) {
option (google.api.http) = {
get: "/v1/round/{txid}"
@@ -94,6 +95,7 @@ message GetRoundResponse {
message GetEventStreamRequest {}
message GetEventStreamResponse {
oneof event {
// TODO: BTC add "signTree" event
RoundFinalizationEvent round_finalization = 1;
RoundFinalizedEvent round_finalized = 2;
RoundFailed round_failed = 3;
@@ -104,6 +106,7 @@ message PingRequest {
string payment_id = 1;
}
message PingResponse {
// TODO: improve this response (returns oneof the round event)
repeated string forfeit_txs = 1;
}

View File

@@ -17,7 +17,7 @@ require (
github.com/stretchr/testify v1.9.0
github.com/timshannon/badgerhold/v4 v4.0.3
github.com/vulpemventures/go-elements v0.5.3
google.golang.org/genproto/googleapis/api v0.0.0-20240521202816-d264139d666e
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157
google.golang.org/grpc v1.64.0
google.golang.org/protobuf v1.34.1
)
@@ -34,7 +34,7 @@ require (
github.com/btcsuite/btcd v0.24.0
github.com/btcsuite/btcd/btcec/v2 v2.3.3
github.com/btcsuite/btcd/btcutil v1.1.5
github.com/btcsuite/btcd/btcutil/psbt v1.1.9 // indirect
github.com/btcsuite/btcd/btcutil/psbt v1.1.9
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -69,11 +69,11 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 // indirect
golang.org/x/net v0.25.0
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -281,8 +281,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 h1:vpzMC/iZhYFAjJzHU0Cfuq+w1vLLsF2vLkDrPjzKYck=
golang.org/x/exp v0.0.0-20240529005216-23cca8864a10/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -379,10 +379,10 @@ google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRn
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/api v0.0.0-20240521202816-d264139d666e h1:SkdGTrROJl2jRGT/Fxv5QUf9jtdKCQh4KQJXbXVLAi0=
google.golang.org/genproto/googleapis/api v0.0.0-20240521202816-d264139d666e/go.mod h1:LweJcLbyVij6rCex8YunD8DYR5VDonap/jYl3ZRxcIU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw=
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=

View File

@@ -395,9 +395,10 @@ func (s *service) startFinalization() {
log.WithError(err).Warn("failed to create pool tx")
return
}
log.Debugf("pool tx created for round %s", round.Id)
// TODO BTC make the senders sign the tree
connectors, forfeitTxs, err := s.builder.BuildForfeitTxs(s.pubkey, unsignedPoolTx, payments, s.minRelayFee)
if err != nil {
changes = round.Fail(fmt.Errorf("failed to create connectors and forfeit txs: %s", err))

View File

@@ -9,9 +9,7 @@ import (
"github.com/ark-network/ark/common/tree"
"github.com/ark-network/ark/internal/core/domain"
"github.com/ark-network/ark/internal/core/ports"
"github.com/btcsuite/btcd/chaincfg/chainhash"
log "github.com/sirupsen/logrus"
"github.com/vulpemventures/go-elements/psetv2"
)
// sweeper is an unexported service running while the main application service is started
@@ -158,8 +156,8 @@ func (s *sweeper) createTask(
ctx,
[]domain.VtxoKey{
{
Txid: input.InputArgs.Txid,
VOut: input.InputArgs.TxIndex,
Txid: input.GetHash().String(),
VOut: input.GetIndex(),
},
},
)
@@ -169,20 +167,14 @@ func (s *sweeper) createTask(
}
} else {
// if it's not a vtxo, find all the vtxos leaves reachable from that input
vtxosLeaves, err := congestionTree.FindLeaves(input.InputArgs.Txid, input.InputArgs.TxIndex)
vtxosLeaves, err := congestionTree.FindLeaves(input.GetHash().String(), input.GetIndex())
if err != nil {
log.WithError(err).Error("error while finding vtxos leaves")
continue
}
for _, leaf := range vtxosLeaves {
pset, err := psetv2.NewPsetFromBase64(leaf.Tx)
if err != nil {
log.Error(fmt.Errorf("error while decoding pset: %w", err))
continue
}
vtxo, err := extractVtxoOutpoint(pset)
vtxo, err := extractVtxoOutpoint(leaf)
if err != nil {
log.Error(err)
continue
@@ -308,7 +300,7 @@ func (s *sweeper) findSweepableOutputs(
}
var expirationTime int64
var sweepInputs []ports.SweepInput
var sweepInput ports.SweepInput
if !isConfirmed {
if _, ok := blocktimeCache[node.ParentTxid]; !ok {
@@ -320,7 +312,7 @@ func (s *sweeper) findSweepableOutputs(
blocktimeCache[node.ParentTxid] = blocktime
}
expirationTime, sweepInputs, err = s.nodeToSweepInputs(blocktimeCache[node.ParentTxid], node)
expirationTime, sweepInput, err = s.builder.GetSweepInput(blocktimeCache[node.ParentTxid], node)
if err != nil {
return nil, err
}
@@ -341,7 +333,7 @@ func (s *sweeper) findSweepableOutputs(
if _, ok := sweepableOutputs[expirationTime]; !ok {
sweepableOutputs[expirationTime] = make([]ports.SweepInput, 0)
}
sweepableOutputs[expirationTime] = append(sweepableOutputs[expirationTime], sweepInputs...)
sweepableOutputs[expirationTime] = append(sweepableOutputs[expirationTime], sweepInput)
}
nodesToCheck = newNodesToCheck
@@ -350,47 +342,6 @@ func (s *sweeper) findSweepableOutputs(
return sweepableOutputs, nil
}
func (s *sweeper) nodeToSweepInputs(parentBlocktime int64, node tree.Node) (int64, []ports.SweepInput, error) {
pset, err := psetv2.NewPsetFromBase64(node.Tx)
if err != nil {
return -1, nil, err
}
if len(pset.Inputs) != 1 {
return -1, nil, fmt.Errorf("invalid node pset, expect 1 input, got %d", len(pset.Inputs))
}
// if the tx is not onchain, it means that the input is an existing shared output
input := pset.Inputs[0]
txid := chainhash.Hash(input.PreviousTxid).String()
index := input.PreviousTxIndex
sweepLeaf, lifetime, err := extractSweepLeaf(input)
if err != nil {
return -1, nil, err
}
expirationTime := parentBlocktime + lifetime
amount := uint64(0)
for _, out := range pset.Outputs {
amount += out.Value
}
sweepInputs := []ports.SweepInput{
{
InputArgs: psetv2.InputArgs{
Txid: txid,
TxIndex: index,
},
SweepLeaf: *sweepLeaf,
Amount: amount,
},
}
return expirationTime, sweepInputs, nil
}
func (s *sweeper) updateVtxoExpirationTime(
tree tree.CongestionTree,
expirationTime int64,
@@ -399,12 +350,7 @@ func (s *sweeper) updateVtxoExpirationTime(
vtxos := make([]domain.VtxoKey, 0)
for _, leaf := range leaves {
pset, err := psetv2.NewPsetFromBase64(leaf.Tx)
if err != nil {
return err
}
vtxo, err := extractVtxoOutpoint(pset)
vtxo, err := extractVtxoOutpoint(leaf)
if err != nil {
return err
}
@@ -421,7 +367,7 @@ func computeSubTrees(congestionTree tree.CongestionTree, inputs []ports.SweepInp
// for each sweepable input, create a sub congestion tree
// it allows to skip the part of the tree that has been broadcasted in the next task
for _, input := range inputs {
subTree, err := computeSubTree(congestionTree, input.InputArgs.Txid)
subTree, err := computeSubTree(congestionTree, input.GetHash().String())
if err != nil {
log.WithError(err).Error("error while finding sub tree")
continue
@@ -507,40 +453,13 @@ func containsTree(tr0 tree.CongestionTree, tr1 tree.CongestionTree) (bool, error
return false, nil
}
// given a congestion tree input, searches and returns the sweep leaf and its lifetime in seconds
func extractSweepLeaf(input psetv2.Input) (sweepLeaf *psetv2.TapLeafScript, lifetime int64, err error) {
for _, leaf := range input.TapLeafScript {
closure := &tree.CSVSigClosure{}
valid, err := closure.Decode(leaf.Script)
if err != nil {
return nil, 0, err
}
if valid && closure.Seconds > uint(lifetime) {
sweepLeaf = &leaf
lifetime = int64(closure.Seconds)
}
}
if sweepLeaf == nil {
return nil, 0, fmt.Errorf("sweep leaf not found")
}
return sweepLeaf, lifetime, nil
}
// assuming the pset is a leaf in the congestion tree, returns the vtxo outpoint
func extractVtxoOutpoint(pset *psetv2.Pset) (*domain.VtxoKey, error) {
if len(pset.Outputs) != 2 {
return nil, fmt.Errorf("invalid leaf pset, expect 2 outputs, got %d", len(pset.Outputs))
func extractVtxoOutpoint(leaf tree.Node) (*domain.VtxoKey, error) {
if !leaf.Leaf {
return nil, fmt.Errorf("node is not a leaf")
}
utx, err := pset.UnsignedTx()
if err != nil {
return nil, err
}
return &domain.VtxoKey{
Txid: utx.TxHash().String(),
Txid: leaf.Txid,
VOut: 0,
}, nil
}

View File

@@ -19,7 +19,7 @@ type RoundStarted struct {
type RoundFinalizationStarted struct {
Id string
CongestionTree tree.CongestionTree
CongestionTree tree.CongestionTree // BTC: signed
Connectors []string
ConnectorAddress string
UnsignedForfeitTxs []string

View File

@@ -3,14 +3,17 @@ package ports
import (
"github.com/ark-network/ark/common/tree"
"github.com/ark-network/ark/internal/core/domain"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/vulpemventures/go-elements/psetv2"
)
type SweepInput struct {
InputArgs psetv2.InputArgs
SweepLeaf psetv2.TapLeafScript
Amount uint64
type SweepInput interface {
GetAmount() uint64
GetHash() chainhash.Hash
GetIndex() uint32
GetLeafScript() []byte
GetControlBlock() []byte
GetInternalKey() *secp256k1.PublicKey
}
type TxBuilder interface {
@@ -18,4 +21,5 @@ type TxBuilder interface {
BuildForfeitTxs(aspPubkey *secp256k1.PublicKey, poolTx string, payments []domain.Payment, minRelayFee uint64) (connectors []string, forfeitTxs []string, err error)
BuildSweepTx(inputs []SweepInput) (signedSweepTx string, err error)
GetVtxoScript(userPubkey, aspPubkey *secp256k1.PublicKey) ([]byte, error)
GetSweepInput(parentblocktime int64, node tree.Node) (expirationtime int64, sweepInput SweepInput, err error)
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/ark-network/ark/common/tree"
"github.com/ark-network/ark/internal/core/domain"
"github.com/ark-network/ark/internal/core/ports"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/vulpemventures/go-elements/address"
"github.com/vulpemventures/go-elements/network"
@@ -171,6 +172,45 @@ func (b *txBuilder) BuildPoolTx(
return
}
func (b *txBuilder) GetSweepInput(parentblocktime int64, node tree.Node) (expirationtime int64, sweepInput ports.SweepInput, err error) {
pset, err := psetv2.NewPsetFromBase64(node.Tx)
if err != nil {
return -1, nil, err
}
if len(pset.Inputs) != 1 {
return -1, nil, fmt.Errorf("invalid node pset, expect 1 input, got %d", len(pset.Inputs))
}
// if the tx is not onchain, it means that the input is an existing shared output
input := pset.Inputs[0]
txid := chainhash.Hash(input.PreviousTxid).String()
index := input.PreviousTxIndex
sweepLeaf, lifetime, err := extractSweepLeaf(input)
if err != nil {
return -1, nil, err
}
expirationTime := parentblocktime + lifetime
amount := uint64(0)
for _, out := range pset.Outputs {
amount += out.Value
}
sweepInput = &sweepLiquidInput{
inputArgs: psetv2.InputArgs{
Txid: txid,
TxIndex: index,
},
sweepLeaf: sweepLeaf,
amount: amount,
}
return expirationTime, sweepInput, nil
}
func (b *txBuilder) getLeafScriptAndTree(
userPubkey, aspPubkey *secp256k1.PublicKey,
) ([]byte, *taproot.IndexedElementsTapScriptTree, error) {
@@ -534,3 +574,55 @@ func (b *txBuilder) getConnectorAddress(poolTx string) (string, error) {
return pay.WitnessPubKeyHash()
}
func extractSweepLeaf(input psetv2.Input) (sweepLeaf *psetv2.TapLeafScript, lifetime int64, err error) {
for _, leaf := range input.TapLeafScript {
closure := &tree.CSVSigClosure{}
valid, err := closure.Decode(leaf.Script)
if err != nil {
return nil, 0, err
}
if valid && closure.Seconds > uint(lifetime) {
sweepLeaf = &leaf
lifetime = int64(closure.Seconds)
}
}
if sweepLeaf == nil {
return nil, 0, fmt.Errorf("sweep leaf not found")
}
return sweepLeaf, lifetime, nil
}
type sweepLiquidInput struct {
inputArgs psetv2.InputArgs
sweepLeaf *psetv2.TapLeafScript
amount uint64
}
func (s *sweepLiquidInput) GetAmount() uint64 {
return s.amount
}
func (s *sweepLiquidInput) GetControlBlock() []byte {
ctrlBlock, _ := s.sweepLeaf.ControlBlock.ToBytes()
return ctrlBlock
}
func (s *sweepLiquidInput) GetHash() chainhash.Hash {
h, _ := chainhash.NewHashFromStr(s.inputArgs.Txid)
return *h
}
func (s *sweepLiquidInput) GetIndex() uint32 {
return s.inputArgs.TxIndex
}
func (s *sweepLiquidInput) GetInternalKey() *secp256k1.PublicKey {
return s.sweepLeaf.ControlBlock.InternalKey
}
func (s *sweepLiquidInput) GetLeafScript() []byte {
return s.sweepLeaf.Script
}

View File

@@ -32,9 +32,8 @@ func sweepTransaction(
amount := uint64(0)
for i, input := range sweepInputs {
leaf := input.SweepLeaf
sweepClosure := &tree.CSVSigClosure{}
isSweep, err := sweepClosure.Decode(leaf.Script)
isSweep, err := sweepClosure.Decode(input.GetLeafScript())
if err != nil {
return nil, err
}
@@ -43,12 +42,27 @@ func sweepTransaction(
return nil, fmt.Errorf("invalid sweep script")
}
amount += input.Amount
amount += input.GetAmount()
if err := updater.AddInputs([]psetv2.InputArgs{input.InputArgs}); err != nil {
if err := updater.AddInputs([]psetv2.InputArgs{
{
Txid: input.GetHash().String(),
TxIndex: input.GetIndex(),
},
}); err != nil {
return nil, err
}
ctrlBlock, err := taproot.ParseControlBlock(input.GetControlBlock())
if err != nil {
return nil, err
}
leaf := psetv2.TapLeafScript{
TapElementsLeaf: taproot.NewBaseTapElementsLeaf(input.GetLeafScript()),
ControlBlock: *ctrlBlock,
}
if err := updater.AddInTapLeafScript(i, leaf); err != nil {
return nil, err
}
@@ -58,7 +72,7 @@ func sweepTransaction(
return nil, err
}
value, err := elementsutil.ValueToBytes(input.Amount)
value, err := elementsutil.ValueToBytes(input.GetAmount())
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,735 @@
package txbuilder
import (
"bytes"
"context"
"encoding/hex"
"fmt"
"strings"
"github.com/ark-network/ark/common/bitcointree"
"github.com/ark-network/ark/common/tree"
"github.com/ark-network/ark/internal/core/domain"
"github.com/ark-network/ark/internal/core/ports"
"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/decred/dcrd/dcrec/secp256k1/v4"
"github.com/decred/dcrd/dcrec/secp256k1/v4/schnorr"
)
const (
connectorAmount = uint64(1000)
dustLimit = uint64(1000)
)
type txBuilder struct {
wallet ports.WalletService
net *chaincfg.Params
roundLifetime int64 // in seconds
exitDelay int64 // in seconds
}
func NewTxBuilder(
wallet ports.WalletService, net *chaincfg.Params, roundLifetime int64, exitDelay int64,
) ports.TxBuilder {
return &txBuilder{wallet, net, roundLifetime, exitDelay}
}
func (b *txBuilder) GetVtxoScript(userPubkey, aspPubkey *secp256k1.PublicKey) ([]byte, error) {
outputScript, _, err := b.getLeafScriptAndTree(userPubkey, aspPubkey)
if err != nil {
return nil, err
}
return outputScript, 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.SignPsetWithKey(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) BuildForfeitTxs(
aspPubkey *secp256k1.PublicKey, poolTx string, payments []domain.Payment, minRelayFee uint64,
) (connectors []string, forfeitTxs []string, err error) {
connectorPkScript, err := b.getConnectorPkScript(poolTx)
if err != nil {
return nil, nil, err
}
connectorTxs, err := b.createConnectors(poolTx, payments, connectorPkScript, minRelayFee)
if err != nil {
return nil, nil, err
}
forfeitTxs, err = b.createForfeitTxs(aspPubkey, payments, connectorTxs, minRelayFee)
if err != nil {
return nil, nil, err
}
for _, tx := range connectorTxs {
buf, _ := tx.B64Encode()
connectors = append(connectors, buf)
}
return connectors, forfeitTxs, nil
}
func (b *txBuilder) BuildPoolTx(
aspPubkey *secp256k1.PublicKey, payments []domain.Payment, minRelayFee uint64, sweptRounds []domain.Round,
) (poolTx string, congestionTree tree.CongestionTree, connectorAddress string, err error) {
var sharedOutputScript []byte
var sharedOutputAmount int64
var senders []*secp256k1.PublicKey
senders, err = getCosigners(payments)
if err != nil {
return
}
cosigners := append(senders, aspPubkey)
receivers := getOffchainReceivers(payments)
if !isOnchainOnly(payments) {
sharedOutputScript, sharedOutputAmount, err = bitcointree.CraftSharedOutput(
cosigners, aspPubkey, receivers, minRelayFee, b.roundLifetime, b.exitDelay,
)
if err != nil {
return
}
}
connectorAddress, err = b.wallet.DeriveConnectorAddress(context.Background())
if err != nil {
return
}
ptx, err := b.createPoolTx(
sharedOutputAmount, sharedOutputScript, payments, aspPubkey, connectorAddress, minRelayFee, sweptRounds,
)
if err != nil {
return
}
poolTx, err = ptx.B64Encode()
if err != nil {
return
}
if !isOnchainOnly(payments) {
initialOutpoint := &wire.OutPoint{
Hash: ptx.UnsignedTx.TxHash(),
Index: 0,
}
congestionTree, err = bitcointree.CraftCongestionTree(
initialOutpoint, cosigners, aspPubkey, receivers, minRelayFee, b.roundLifetime, b.exitDelay,
)
if err != nil {
return
}
}
return
}
func (b *txBuilder) GetSweepInput(parentblocktime int64, node tree.Node) (expirationtime int64, sweepInput ports.SweepInput, err error) {
partialTx, err := psbt.NewFromRawBytes(strings.NewReader(node.Tx), true)
if err != nil {
return -1, nil, err
}
if len(partialTx.Inputs) != 1 {
return -1, 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 -1, nil, err
}
expirationTime := parentblocktime + lifetime
amount := int64(0)
for _, out := range partialTx.UnsignedTx.TxOut {
amount += out.Value
}
sweepInput = &sweepBitcoinInput{
inputArgs: wire.OutPoint{
Hash: txid,
Index: index,
},
internalPubkey: internalKey,
sweepLeaf: sweepLeaf,
amount: amount,
}
return expirationTime, sweepInput, nil
}
func (b *txBuilder) getLeafScriptAndTree(
userPubkey, aspPubkey *secp256k1.PublicKey,
) ([]byte, *txscript.IndexedTapScriptTree, error) {
redeemClosure := &bitcointree.CSVSigClosure{
Pubkey: userPubkey,
Seconds: uint(b.exitDelay),
}
redeemLeaf, err := redeemClosure.Leaf()
if err != nil {
return nil, nil, err
}
forfeitClosure := &bitcointree.ForfeitClosure{
Pubkey: userPubkey,
AspPubkey: aspPubkey,
}
forfeitLeaf, err := forfeitClosure.Leaf()
if err != nil {
return nil, nil, err
}
taprootTree := txscript.AssembleTaprootScriptTree(
*redeemLeaf, *forfeitLeaf,
)
root := taprootTree.RootNode.TapHash()
unspendableKey := tree.UnspendableKey()
taprootKey := txscript.ComputeTaprootOutputKey(unspendableKey, root[:])
outputScript, err := taprootOutputScript(taprootKey)
if err != nil {
return nil, nil, err
}
return outputScript, taprootTree, nil
}
func (b *txBuilder) createPoolTx(
sharedOutputAmount int64, sharedOutputScript []byte,
payments []domain.Payment, aspPubKey *secp256k1.PublicKey, connectorAddress string, minRelayFee uint64,
sweptRounds []domain.Round,
) (*psbt.Packet, error) {
aspScript, err := p2trScript(aspPubKey, b.net)
if err != nil {
return nil, err
}
connectorAddr, err := btcutil.DecodeAddress(connectorAddress, b.net)
if err != nil {
return nil, err
}
connectorScript, err := txscript.PayToAddrScript(connectorAddr)
if err != nil {
return nil, err
}
receivers := getOnchainReceivers(payments)
nbOfInputs := countSpentVtxos(payments)
connectorsAmount := (connectorAmount + minRelayFee) * nbOfInputs
if nbOfInputs > 1 {
connectorsAmount -= minRelayFee
}
targetAmount := connectorsAmount
outputs := make([]*wire.TxOut, 0)
if sharedOutputScript != nil && sharedOutputAmount > 0 {
targetAmount += uint64(sharedOutputAmount)
outputs = append(outputs, &wire.TxOut{
Value: sharedOutputAmount,
PkScript: sharedOutputScript,
})
}
outputs = append(outputs, &wire.TxOut{
Value: int64(connectorAmount),
PkScript: connectorScript,
})
for _, receiver := range receivers {
targetAmount += receiver.Amount
receiverAddr, err := btcutil.DecodeAddress(receiver.OnchainAddress, b.net)
if err != nil {
return nil, err
}
receiverScript, err := txscript.PayToAddrScript(receiverAddr)
if err != nil {
return nil, err
}
outputs = append(outputs, &wire.TxOut{
Value: int64(receiver.Amount),
PkScript: receiverScript,
})
}
ctx := context.Background()
utxos, change, err := b.selectUtxos(ctx, sweptRounds, targetAmount)
if err != nil {
return nil, err
}
var dust uint64
if change > 0 {
if change < dustLimit {
dust = change
change = 0
} else {
outputs = append(outputs, &wire.TxOut{
Value: int64(change),
PkScript: aspScript,
})
}
}
ins := make([]*wire.OutPoint, 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(),
})
}
ptx, err := psbt.New(ins, outputs, 2, 0, []uint32{wire.MaxTxInSequenceNum})
if err != nil {
return nil, err
}
updater, err := psbt.NewUpdater(ptx)
if err != nil {
return nil, err
}
for _, utxo := range utxos {
script, err := hex.DecodeString(utxo.GetScript())
if err != nil {
return nil, err
}
if err := updater.AddInWitnessUtxo(&wire.TxOut{
Value: int64(utxo.GetValue()),
PkScript: script,
}, 0); err != nil {
return nil, err
}
}
b64, err := ptx.B64Encode()
if err != nil {
return nil, err
}
feeAmount, err := b.wallet.EstimateFees(ctx, b64)
if err != nil {
return nil, err
}
if dust > feeAmount {
feeAmount = dust
} else {
feeAmount += dust
}
if dust == 0 {
if feeAmount == change {
// fees = change, remove change output
ptx.UnsignedTx.TxOut = ptx.UnsignedTx.TxOut[:len(ptx.UnsignedTx.TxOut)-1]
ptx.Outputs = ptx.Outputs[:len(ptx.Outputs)-1]
} else if feeAmount < change {
// change covers the fees, reduce change amount
ptx.UnsignedTx.TxOut[len(ptx.Outputs)-1].Value = int64(change - feeAmount)
} else {
// change is not enough to cover fees, re-select utxos
if change > 0 {
// remove change output if present
ptx.UnsignedTx.TxOut = ptx.UnsignedTx.TxOut[:len(ptx.UnsignedTx.TxOut)-1]
ptx.Outputs = ptx.Outputs[:len(ptx.Outputs)-1]
}
newUtxos, change, err := b.selectUtxos(ctx, sweptRounds, feeAmount-change)
if err != nil {
return nil, err
}
if change > 0 {
ptx.UnsignedTx.AddTxOut(&wire.TxOut{
Value: int64(change),
PkScript: aspScript,
})
ptx.Outputs = append(ptx.Outputs, psbt.POutput{})
}
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
}
}
}
} else if feeAmount-dust > 0 {
newUtxos, change, err := b.selectUtxos(ctx, sweptRounds, feeAmount-dust)
if err != nil {
return nil, err
}
if change > 0 {
if change > dustLimit {
ptx.UnsignedTx.AddTxOut(&wire.TxOut{
Value: int64(change),
PkScript: aspScript,
})
ptx.Outputs = append(ptx.Outputs, psbt.POutput{})
}
}
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
}
}
}
return ptx, nil
}
func (b *txBuilder) createConnectors(
poolTx string, payments []domain.Payment, connectorScript []byte, minRelayFee uint64,
) ([]*psbt.Packet, error) {
partialTx, err := psbt.NewFromRawBytes(strings.NewReader(poolTx), true)
if err != nil {
return nil, err
}
connectorOutput := &wire.TxOut{
PkScript: connectorScript,
Value: int64(connectorAmount),
}
numberOfConnectors := countSpentVtxos(payments)
previousInput := &wire.OutPoint{
Hash: partialTx.UnsignedTx.TxHash(),
Index: 1,
}
if numberOfConnectors == 1 {
outputs := []*wire.TxOut{connectorOutput}
connectorTx, err := craftConnectorTx(previousInput, connectorScript, outputs, minRelayFee)
if err != nil {
return nil, err
}
return []*psbt.Packet{connectorTx}, nil
}
totalConnectorAmount := (connectorAmount + minRelayFee) * numberOfConnectors
if numberOfConnectors > 1 {
totalConnectorAmount -= minRelayFee
}
connectors := make([]*psbt.Packet, 0, numberOfConnectors-1)
for i := uint64(0); i < numberOfConnectors-1; i++ {
outputs := []*wire.TxOut{connectorOutput}
totalConnectorAmount -= connectorAmount
totalConnectorAmount -= minRelayFee
if totalConnectorAmount > 0 {
outputs = append(outputs, &wire.TxOut{
PkScript: connectorScript,
Value: int64(totalConnectorAmount),
})
}
connectorTx, err := craftConnectorTx(previousInput, connectorScript, outputs, minRelayFee)
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) createForfeitTxs(
aspPubkey *secp256k1.PublicKey, payments []domain.Payment, connectors []*psbt.Packet, minRelayFee uint64,
) ([]string, error) {
aspScript, err := p2trScript(aspPubkey, b.net)
if err != nil {
return nil, err
}
forfeitTxs := make([]string, 0)
for _, payment := range payments {
for _, vtxo := range payment.Inputs {
pubkeyBytes, err := hex.DecodeString(vtxo.Pubkey)
if err != nil {
return nil, fmt.Errorf("failed to decode pubkey: %s", err)
}
vtxoPubkey, err := secp256k1.ParsePubKey(pubkeyBytes)
if err != nil {
return nil, err
}
vtxoScript, vtxoTaprootTree, err := b.getLeafScriptAndTree(vtxoPubkey, aspPubkey)
if err != nil {
return nil, err
}
var forfeitProof *txscript.TapscriptProof
for _, proof := range vtxoTaprootTree.LeafMerkleProofs {
isForfeit, err := (&bitcointree.ForfeitClosure{}).Decode(proof.Script)
if !isForfeit || err != nil {
continue
}
forfeitProof = &proof
break
}
if forfeitProof == nil {
return nil, fmt.Errorf("forfeit proof not found")
}
for _, connector := range connectors {
txs, err := craftForfeitTxs(
connector, vtxo, vtxoScript, aspScript, minRelayFee,
)
if err != nil {
return nil, err
}
forfeitTxs = append(forfeitTxs, txs...)
}
}
}
return forfeitTxs, nil
}
func (b *txBuilder) getConnectorPkScript(poolTx string) ([]byte, error) {
partialTx, err := psbt.NewFromRawBytes(strings.NewReader(poolTx), true)
if err != nil {
return nil, err
}
if len(partialTx.Outputs) < 1 {
return nil, fmt.Errorf("connector output not found in pool tx")
}
return partialTx.UnsignedTx.TxOut[0].PkScript, nil
}
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 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 int64, err error) {
for _, leaf := range input.TaprootLeafScript {
closure := &bitcointree.CSVSigClosure{}
valid, err := closure.Decode(leaf.Script)
if err != nil {
return nil, nil, 0, err
}
if valid && closure.Seconds > 0 {
sweepLeaf = leaf
lifetime = int64(closure.Seconds)
}
}
internalKey, err = schnorr.ParsePubKey(input.TaprootInternalKey)
if err != nil {
return nil, nil, 0, err
}
if sweepLeaf == nil {
return nil, nil, 0, 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
}

View File

@@ -0,0 +1,252 @@
package txbuilder_test
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"strings"
"testing"
"github.com/ark-network/ark/common/bitcointree"
"github.com/ark-network/ark/internal/core/domain"
"github.com/ark-network/ark/internal/core/ports"
txbuilder "github.com/ark-network/ark/internal/infrastructure/tx-builder/covenantless"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/chaincfg"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
const (
testingKey = "0218d5ca8b58797b7dbd65c075dd7ba7784b3f38ab71b1a5a8e3f94ba0257654a6"
connectorAddress = "bc1py00yhcjpcj0k0sqra0etq0u3yy0purmspppsw0shyzyfe8c83tmq5h6kc2"
minRelayFee = uint64(30)
roundLifetime = int64(1209344)
unilateralExitDelay = int64(512)
)
var (
wallet *mockedWallet
pubkey *secp256k1.PublicKey
)
func TestMain(m *testing.M) {
wallet = &mockedWallet{}
wallet.On("EstimateFees", mock.Anything, mock.Anything).
Return(uint64(100), nil)
wallet.On("SelectUtxos", mock.Anything, mock.Anything, mock.Anything).
Return(randomInput, uint64(0), nil)
wallet.On("DeriveConnectorAddress", mock.Anything).
Return(connectorAddress, nil)
pubkeyBytes, _ := hex.DecodeString(testingKey)
pubkey, _ = secp256k1.ParsePubKey(pubkeyBytes)
os.Exit(m.Run())
}
func TestBuildPoolTx(t *testing.T) {
builder := txbuilder.NewTxBuilder(
wallet, &chaincfg.MainNetParams, roundLifetime, unilateralExitDelay,
)
fixtures, err := parsePoolTxFixtures()
require.NoError(t, err)
require.NotEmpty(t, fixtures)
if len(fixtures.Valid) > 0 {
t.Run("valid", func(t *testing.T) {
for _, f := range fixtures.Valid {
poolTx, congestionTree, connAddr, err := builder.BuildPoolTx(
pubkey, f.Payments, minRelayFee, []domain.Round{},
)
require.NoError(t, err)
require.NotEmpty(t, poolTx)
require.NotEmpty(t, congestionTree)
require.Equal(t, connectorAddress, connAddr)
require.Equal(t, f.ExpectedNumOfNodes, congestionTree.NumberOfNodes())
require.Len(t, congestionTree.Leaves(), f.ExpectedNumOfLeaves)
cosigners := make([]*secp256k1.PublicKey, 0)
for _, payment := range f.Payments {
for _, input := range payment.Inputs {
pubkeyBytes, err := hex.DecodeString(input.Pubkey)
require.NoError(t, err)
pubkey, err := secp256k1.ParsePubKey(pubkeyBytes)
require.NoError(t, err)
cosigners = append(cosigners, pubkey)
}
}
err = bitcointree.ValidateCongestionTree(
congestionTree, poolTx, pubkey, roundLifetime, cosigners, int64(minRelayFee),
)
require.NoError(t, err)
}
})
}
if len(fixtures.Invalid) > 0 {
t.Run("invalid", func(t *testing.T) {
for _, f := range fixtures.Invalid {
poolTx, congestionTree, connAddr, err := builder.BuildPoolTx(
pubkey, f.Payments, minRelayFee, []domain.Round{},
)
require.EqualError(t, err, f.ExpectedErr)
require.Empty(t, poolTx)
require.Empty(t, connAddr)
require.Empty(t, congestionTree)
}
})
}
}
func TestBuildForfeitTxs(t *testing.T) {
builder := txbuilder.NewTxBuilder(
wallet, &chaincfg.MainNetParams, 1209344, unilateralExitDelay,
)
fixtures, err := parseForfeitTxsFixtures()
require.NoError(t, err)
require.NotEmpty(t, fixtures)
if len(fixtures.Valid) > 0 {
t.Run("valid", func(t *testing.T) {
for _, f := range fixtures.Valid {
connectors, forfeitTxs, err := builder.BuildForfeitTxs(
pubkey, f.PoolTx, f.Payments, minRelayFee,
)
require.NoError(t, err)
require.Len(t, connectors, f.ExpectedNumOfConnectors)
require.Len(t, forfeitTxs, f.ExpectedNumOfForfeitTxs)
expectedInputTxid := f.PoolTxid
// Verify the chain of connectors
for _, connector := range connectors {
tx, err := psbt.NewFromRawBytes(strings.NewReader(connector), true)
require.NoError(t, err)
require.NotNil(t, tx)
require.Len(t, tx.Inputs, 1)
require.Len(t, tx.Outputs, 2)
inputTxid := tx.UnsignedTx.TxIn[0].PreviousOutPoint.Hash.String()
require.Equal(t, expectedInputTxid, inputTxid)
require.Equal(t, 1, int(tx.UnsignedTx.TxIn[0].PreviousOutPoint.Index))
expectedInputTxid = tx.UnsignedTx.TxHash().String()
}
// decode and check forfeit txs
for _, forfeitTx := range forfeitTxs {
tx, err := psbt.NewFromRawBytes(strings.NewReader(forfeitTx), true)
require.NoError(t, err)
require.Len(t, tx.Inputs, 2)
require.Len(t, tx.Outputs, 1)
}
}
})
}
if len(fixtures.Invalid) > 0 {
t.Run("invalid", func(t *testing.T) {
for _, f := range fixtures.Invalid {
connectors, forfeitTxs, err := builder.BuildForfeitTxs(
pubkey, f.PoolTx, f.Payments, minRelayFee,
)
require.EqualError(t, err, f.ExpectedErr)
require.Empty(t, connectors)
require.Empty(t, forfeitTxs)
}
})
}
}
func randomInput() []ports.TxInput {
txid := randomHex(32)
input := &mockedInput{}
input.On("GetAsset").Return("5ac9f65c0efcc4775e0baec4ec03abdde22473cd3cf33c0419ca290e0751b225")
input.On("GetValue").Return(uint64(1000))
input.On("GetScript").Return("a914ea9f486e82efb3dd83a69fd96e3f0113757da03c87")
input.On("GetTxid").Return(txid)
input.On("GetIndex").Return(uint32(0))
return []ports.TxInput{input}
}
func randomHex(len int) string {
buf := make([]byte, len)
// nolint
rand.Read(buf)
return hex.EncodeToString(buf)
}
type poolTxFixtures struct {
Valid []struct {
Payments []domain.Payment
ExpectedNumOfNodes int
ExpectedNumOfLeaves int
}
Invalid []struct {
Payments []domain.Payment
ExpectedErr string
}
}
func parsePoolTxFixtures() (*poolTxFixtures, error) {
file, err := os.ReadFile("testdata/fixtures.json")
if err != nil {
return nil, err
}
v := map[string]interface{}{}
if err := json.Unmarshal(file, &v); err != nil {
return nil, err
}
vv := v["buildPoolTx"].(map[string]interface{})
file, _ = json.Marshal(vv)
var fixtures poolTxFixtures
if err := json.Unmarshal(file, &fixtures); err != nil {
return nil, err
}
return &fixtures, nil
}
type forfeitTxsFixtures struct {
Valid []struct {
Payments []domain.Payment
ExpectedNumOfConnectors int
ExpectedNumOfForfeitTxs int
PoolTx string
PoolTxid string
}
Invalid []struct {
Payments []domain.Payment
ExpectedErr string
PoolTx string
}
}
func parseForfeitTxsFixtures() (*forfeitTxsFixtures, error) {
file, err := os.ReadFile("testdata/fixtures.json")
if err != nil {
return nil, err
}
v := map[string]interface{}{}
if err := json.Unmarshal(file, &v); err != nil {
return nil, err
}
vv := v["buildForfeitTxs"].(map[string]interface{})
file, _ = json.Marshal(vv)
var fixtures forfeitTxsFixtures
if err := json.Unmarshal(file, &fixtures); err != nil {
return nil, err
}
return &fixtures, nil
}

View File

@@ -0,0 +1,57 @@
package txbuilder
import (
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/wire"
)
func craftConnectorTx(
input *wire.OutPoint, inputScript []byte, outputs []*wire.TxOut, feeAmount uint64,
) (*psbt.Packet, error) {
ptx, err := psbt.New(
[]*wire.OutPoint{input},
outputs,
2,
0,
[]uint32{wire.MaxTxInSequenceNum},
)
if err != nil {
return nil, err
}
updater, err := psbt.NewUpdater(ptx)
if err != nil {
return nil, err
}
inputAmount := int64(feeAmount)
for _, output := range outputs {
inputAmount += output.Value
}
if err := updater.AddInWitnessUtxo(&wire.TxOut{
Value: inputAmount,
PkScript: inputScript,
}, 0); err != nil {
return nil, err
}
return ptx, nil
}
func getConnectorInputs(partialTx *psbt.Packet) ([]*wire.OutPoint, []*wire.TxOut) {
inputs := make([]*wire.OutPoint, 0)
witnessUtxos := make([]*wire.TxOut, 0)
for i, output := range partialTx.UnsignedTx.TxOut {
if output.Value == int64(connectorAmount) {
inputs = append(inputs, &wire.OutPoint{
Hash: partialTx.UnsignedTx.TxHash(),
Index: uint32(i),
})
witnessUtxos = append(witnessUtxos, output)
}
}
return inputs, witnessUtxos
}

View File

@@ -0,0 +1,74 @@
package txbuilder
import (
"github.com/ark-network/ark/internal/core/domain"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
)
func craftForfeitTxs(
connectorTx *psbt.Packet,
vtxo domain.Vtxo,
vtxoScript, aspScript []byte,
minRelayFee uint64,
) (forfeitTxs []string, err error) {
connectors, prevouts := getConnectorInputs(connectorTx)
for i, connectorInput := range connectors {
connectorPrevout := prevouts[i]
vtxoHash, err := chainhash.NewHashFromStr(vtxo.Txid)
if err != nil {
return nil, err
}
vtxoInput := &wire.OutPoint{
Hash: *vtxoHash,
Index: vtxo.VOut,
}
partialTx, err := psbt.New(
[]*wire.OutPoint{connectorInput, vtxoInput},
[]*wire.TxOut{{
Value: int64(vtxo.Amount) + int64(connectorAmount) - int64(minRelayFee),
PkScript: aspScript,
}},
2,
0,
[]uint32{wire.MaxTxInSequenceNum, wire.MaxTxInSequenceNum},
)
if err != nil {
return nil, err
}
updater, err := psbt.NewUpdater(partialTx)
if err != nil {
return nil, err
}
if err := updater.AddInWitnessUtxo(connectorPrevout, 0); err != nil {
return nil, err
}
if err := updater.AddInWitnessUtxo(&wire.TxOut{
Value: int64(vtxo.Amount),
PkScript: vtxoScript,
}, 1); err != nil {
return nil, err
}
if err := updater.AddInSighashType(txscript.SigHashDefault, 1); err != nil {
return nil, err
}
tx, err := partialTx.B64Encode()
if err != nil {
return nil, err
}
forfeitTxs = append(forfeitTxs, tx)
}
return forfeitTxs, nil
}

View File

@@ -0,0 +1,234 @@
package txbuilder_test
import (
"context"
"github.com/ark-network/ark/internal/core/ports"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/stretchr/testify/mock"
)
type mockedWallet struct {
mock.Mock
}
// BroadcastTransaction implements ports.WalletService.
func (m *mockedWallet) BroadcastTransaction(ctx context.Context, txHex string) (string, error) {
args := m.Called(ctx, txHex)
var res string
if a := args.Get(0); a != nil {
res = a.(string)
}
return res, args.Error(1)
}
// Close implements ports.WalletService.
func (m *mockedWallet) Close() {
m.Called()
}
// DeriveAddresses implements ports.WalletService.
func (m *mockedWallet) DeriveAddresses(ctx context.Context, num int) ([]string, error) {
args := m.Called(ctx, num)
var res []string
if a := args.Get(0); a != nil {
res = a.([]string)
}
return res, args.Error(1)
}
// DeriveConnectorAddress implements ports.WalletService.
func (m *mockedWallet) DeriveConnectorAddress(ctx context.Context) (string, error) {
args := m.Called(ctx)
var res string
if a := args.Get(0); a != nil {
res = a.(string)
}
return res, args.Error(1)
}
// GetPubkey implements ports.WalletService.
func (m *mockedWallet) GetPubkey(ctx context.Context) (*secp256k1.PublicKey, error) {
args := m.Called(ctx)
var res *secp256k1.PublicKey
if a := args.Get(0); a != nil {
res = a.(*secp256k1.PublicKey)
}
return res, args.Error(1)
}
// SignPset implements ports.WalletService.
func (m *mockedWallet) SignPset(ctx context.Context, pset string, extractRawTx bool) (string, error) {
args := m.Called(ctx, pset, extractRawTx)
var res string
if a := args.Get(0); a != nil {
res = a.(string)
}
return res, args.Error(1)
}
// Status implements ports.WalletService.
func (m *mockedWallet) Status(ctx context.Context) (ports.WalletStatus, error) {
args := m.Called(ctx)
var res ports.WalletStatus
if a := args.Get(0); a != nil {
res = a.(ports.WalletStatus)
}
return res, args.Error(1)
}
func (m *mockedWallet) SelectUtxos(ctx context.Context, asset string, amount uint64) ([]ports.TxInput, uint64, error) {
args := m.Called(ctx, asset, amount)
var res0 func() []ports.TxInput
if a := args.Get(0); a != nil {
res0 = a.(func() []ports.TxInput)
}
var res1 uint64
if a := args.Get(1); a != nil {
res1 = a.(uint64)
}
return res0(), res1, args.Error(2)
}
func (m *mockedWallet) EstimateFees(ctx context.Context, pset string) (uint64, error) {
args := m.Called(ctx, pset)
var res uint64
if a := args.Get(0); a != nil {
res = a.(uint64)
}
return res, args.Error(1)
}
func (m *mockedWallet) IsTransactionConfirmed(ctx context.Context, txid string) (bool, int64, error) {
args := m.Called(ctx, txid)
var res bool
if a := args.Get(0); a != nil {
res = a.(bool)
}
var blocktime int64
if b := args.Get(1); b != nil {
blocktime = b.(int64)
}
return res, blocktime, args.Error(2)
}
func (m *mockedWallet) SignPsetWithKey(ctx context.Context, pset string, inputIndexes []int) (string, error) {
args := m.Called(ctx, pset, inputIndexes)
var res string
if a := args.Get(0); a != nil {
res = a.(string)
}
return res, args.Error(1)
}
func (m *mockedWallet) WatchScripts(
ctx context.Context, scripts []string,
) error {
args := m.Called(ctx, scripts)
return args.Error(0)
}
func (m *mockedWallet) UnwatchScripts(
ctx context.Context, scripts []string,
) error {
args := m.Called(ctx, scripts)
return args.Error(0)
}
func (m *mockedWallet) GetNotificationChannel(ctx context.Context) <-chan map[string]ports.VtxoWithValue {
args := m.Called(ctx)
var res <-chan map[string]ports.VtxoWithValue
if a := args.Get(0); a != nil {
res = a.(<-chan map[string]ports.VtxoWithValue)
}
return res
}
func (m *mockedWallet) ListConnectorUtxos(ctx context.Context, addr string) ([]ports.TxInput, error) {
args := m.Called(ctx, addr)
var res []ports.TxInput
if a := args.Get(0); a != nil {
res = a.([]ports.TxInput)
}
return res, args.Error(1)
}
func (m *mockedWallet) LockConnectorUtxos(ctx context.Context, utxos []ports.TxOutpoint) error {
args := m.Called(ctx, utxos)
return args.Error(0)
}
func (m *mockedWallet) WaitForSync(ctx context.Context, txid string) error {
args := m.Called(ctx, txid)
return args.Error(0)
}
type mockedInput struct {
mock.Mock
}
func (m *mockedInput) GetTxid() string {
args := m.Called()
var res string
if a := args.Get(0); a != nil {
res = a.(string)
}
return res
}
func (m *mockedInput) GetIndex() uint32 {
args := m.Called()
var res uint32
if a := args.Get(0); a != nil {
res = a.(uint32)
}
return res
}
func (m *mockedInput) GetScript() string {
args := m.Called()
var res string
if a := args.Get(0); a != nil {
res = a.(string)
}
return res
}
func (m *mockedInput) GetAsset() string {
args := m.Called()
var res string
if a := args.Get(0); a != nil {
res = a.(string)
}
return res
}
func (m *mockedInput) GetValue() uint64 {
args := m.Called()
var res uint64
if a := args.Get(0); a != nil {
res = a.(uint64)
}
return res
}

View File

@@ -0,0 +1,146 @@
package txbuilder
import (
"context"
"fmt"
"github.com/ark-network/ark/common"
"github.com/ark-network/ark/common/bitcointree"
"github.com/ark-network/ark/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/txscript"
"github.com/btcsuite/btcd/wire"
)
func sweepTransaction(
wallet ports.WalletService,
sweepInputs []ports.SweepInput,
) (*psbt.Packet, error) {
ins := make([]*wire.OutPoint, 0)
sequences := make([]uint32, 0)
for _, input := range sweepInputs {
ins = append(ins, &wire.OutPoint{
Hash: input.GetHash(),
Index: input.GetIndex(),
})
sweepClosure := bitcointree.CSVSigClosure{}
valid, err := sweepClosure.Decode(input.GetLeafScript())
if err != nil {
return nil, err
}
if !valid {
return nil, fmt.Errorf("invalid csv script")
}
sequence, err := common.BIP68EncodeAsNumber(sweepClosure.Seconds)
if err != nil {
return nil, err
}
sequences = append(sequences, sequence)
}
sweepPartialTx, err := psbt.New(
ins,
nil,
2,
0,
sequences,
)
if err != nil {
return nil, err
}
updater, err := psbt.NewUpdater(sweepPartialTx)
if err != nil {
return nil, err
}
amount := int64(0)
for i, sweepInput := range sweepInputs {
sweepPartialTx.Inputs[i].TaprootLeafScript = []*psbt.TaprootTapLeafScript{
{
ControlBlock: sweepInput.GetControlBlock(),
Script: sweepInput.GetLeafScript(),
LeafVersion: txscript.BaseLeafVersion,
},
}
sweepPartialTx.Inputs[i].TaprootInternalKey = schnorr.SerializePubKey(sweepInput.GetInternalKey())
amount += int64(sweepInput.GetAmount())
ctrlBlock, err := txscript.ParseControlBlock(sweepInput.GetControlBlock())
if err != nil {
return nil, err
}
root := ctrlBlock.RootHash(sweepInput.GetLeafScript())
prevoutTaprootKey := txscript.ComputeTaprootOutputKey(
sweepInput.GetInternalKey(),
root,
)
script, err := taprootOutputScript(prevoutTaprootKey)
if err != nil {
return nil, err
}
prevout := &wire.TxOut{
Value: int64(sweepInput.GetAmount()),
PkScript: script,
}
if err := updater.AddInWitnessUtxo(prevout, i); err != nil {
return nil, err
}
}
ctx := context.Background()
sweepAddress, err := wallet.DeriveAddresses(ctx, 1)
if err != nil {
return nil, err
}
addr, err := btcutil.DecodeAddress(sweepAddress[0], nil)
if err != nil {
return nil, err
}
script, err := txscript.PayToAddrScript(addr)
if err != nil {
return nil, err
}
sweepPartialTx.UnsignedTx.AddTxOut(&wire.TxOut{
Value: amount,
PkScript: script,
})
sweepPartialTx.Outputs = append(sweepPartialTx.Outputs, psbt.POutput{})
b64, err := sweepPartialTx.B64Encode()
if err != nil {
return nil, err
}
fees, err := wallet.EstimateFees(ctx, b64)
if err != nil {
return nil, err
}
if amount < int64(fees) {
return nil, fmt.Errorf("insufficient funds (%d) to cover fees (%d) for sweep transaction", amount, fees)
}
sweepPartialTx.UnsignedTx.TxOut[0].Value = amount - int64(fees)
return sweepPartialTx, nil
}

View File

@@ -0,0 +1,259 @@
{
"buildPoolTx": {
"valid": [
{
"payments": [
{
"id": "0",
"inputs": [
{
"txid": "fd68e3c5796cc7db0a8036d486d5f625b6b2f2c014810ac020e1ac23e82c59d6",
"vout": 0,
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1100
}
],
"receivers": [
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1100
}
]
}
],
"expectedNumOfNodes": 1,
"expectedNumOfLeaves": 1
},
{
"payments": [
{
"id": "0",
"inputs": [
{
"txid": "fd68e3c5796cc7db0a8036d486d5f625b6b2f2c014810ac020e1ac23e82c59d6",
"vout": 0,
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1100
}
],
"receivers": [
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 600
},
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 500
}
]
}
],
"expectedNumOfNodes": 3,
"expectedNumOfLeaves": 2
},
{
"payments": [
{
"id": "0",
"inputs": [
{
"txid": "fd68e3c5796cc7db0a8036d486d5f625b6b2f2c014810ac020e1ac23e82c59d6",
"vout": 0,
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1100
}
],
"receivers": [
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 600
},
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 500
}
]
},
{
"id": "0",
"inputs": [
{
"txid": "fd68e3c5796cc7db0a8036d486d5f625b6b2f2c014810ac020e1ac23e82c59d6",
"vout": 0,
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1100
}
],
"receivers": [
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 600
},
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 500
}
]
},
{
"id": "0",
"inputs": [
{
"txid": "fd68e3c5796cc7db0a8036d486d5f625b6b2f2c014810ac020e1ac23e82c59d6",
"vout": 0,
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 1100
}
],
"receivers": [
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 600
},
{
"pubkey": "020000000000000000000000000000000000000000000000000000000000000002",
"amount": 500
}
]
}
],
"expectedNumOfNodes": 11,
"expectedNumOfLeaves": 6
},
{
"payments": [
{
"id": "a242cdd8-f3d5-46c0-ae98-94135a2bee3f",
"inputs": [
{
"txid": "755c820771284d85ea4bbcc246565b4eddadc44237a7e57a0f9cb78a840d1d41",
"vout": 0,
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"txid": "66a0df86fcdeb84b8877adfe0b2c556dba30305d72ddbd4c49355f6930355357",
"vout": 0,
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"txid": "9913159bc7aa493ca53cbb9cbc88f97ba01137c814009dc7ef520c3fafc67909",
"vout": 1,
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 500
},
{
"txid": "5e10e77a7cdedc153be5193a4b6055a7802706ded4f2a9efefe86ed2f9a6ae60",
"vout": 0,
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"txid": "5e10e77a7cdedc153be5193a4b6055a7802706ded4f2a9efefe86ed2f9a6ae60",
"vout": 1,
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
}
],
"receivers": [
{
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 500
}
]
}
],
"expectedNumOfNodes": 9,
"expectedNumOfLeaves": 5
}
],
"invalid": []
},
"buildForfeitTxs": {
"valid": [
{
"payments": [
{
"id": "a242cdd8-f3d5-46c0-ae98-94135a2bee3f",
"inputs": [
{
"txid": "755c820771284d85ea4bbcc246565b4eddadc44237a7e57a0f9cb78a840d1d41",
"vout": 0,
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"txid": "66a0df86fcdeb84b8877adfe0b2c556dba30305d72ddbd4c49355f6930355357",
"vout": 0,
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"txid": "9913159bc7aa493ca53cbb9cbc88f97ba01137c814009dc7ef520c3fafc67909",
"vout": 1,
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 500
},
{
"txid": "5e10e77a7cdedc153be5193a4b6055a7802706ded4f2a9efefe86ed2f9a6ae60",
"vout": 0,
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"txid": "5e10e77a7cdedc153be5193a4b6055a7802706ded4f2a9efefe86ed2f9a6ae60",
"vout": 1,
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
}
],
"receivers": [
{
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 1000
},
{
"pubkey": "02c87e5c1758df5ad42a918ec507b6e8dfcdcebf22f64f58eb4ad5804257d658a5",
"amount": 500
}
]
}
],
"poolTx": "cHNidP8BALICAAAAAnonOnsJBkHUUaKf/7fdS0/sVyBCgDPusYzGSZZiXPbtAAAAAAD/////VLtr81ZII3QJnXgrIwgcnbsq3aa4L3qdHOAn2evlFtEAAAAAAP////8CohIAAAAAAAAiUSBZarBUuSIHnlkuIoel9MmvexqTGK8jCZaRjt8L+Pb3s+gDAAAAAAAAIlEgI95L4kHEn2fAA+vysD+RIR4eD3AIQwc+FyCInJ8HivYAAAAAAAEBIOgDAAAAAAAAF6kU6p9IboLvs92Dpp/Zbj8BE3V9oDyHAAEBIOgDAAAAAAAAF6kU6p9IboLvs92Dpp/Zbj8BE3V9oDyHAAAA",
"poolTxid": "7c0c10756cdb9ab8e605f1c82e25989761308cf4c60e6a6f42b72d46144c4ce0",
"expectedNumOfForfeitTxs": 25,
"expectedNumOfConnectors": 4
}
],
"invalid": []
}
}

View File

@@ -0,0 +1,106 @@
package txbuilder
import (
"encoding/hex"
"github.com/ark-network/ark/common/bitcointree"
"github.com/ark-network/ark/internal/core/domain"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/txscript"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
func p2trScript(publicKey *secp256k1.PublicKey, net *chaincfg.Params) ([]byte, error) {
tapKey := txscript.ComputeTaprootKeyNoScript(publicKey)
payment, err := btcutil.NewAddressTaproot(
schnorr.SerializePubKey(tapKey),
net,
)
if err != nil {
return nil, err
}
return payment.ScriptAddress(), nil
}
func getOnchainReceivers(
payments []domain.Payment,
) []domain.Receiver {
receivers := make([]domain.Receiver, 0)
for _, payment := range payments {
for _, receiver := range payment.Receivers {
if receiver.IsOnchain() {
receivers = append(receivers, receiver)
}
}
}
return receivers
}
// TODO: use ephemeral keys ?
func getCosigners(
payments []domain.Payment,
) ([]*secp256k1.PublicKey, error) {
cosigners := make([]*secp256k1.PublicKey, 0)
for _, payment := range payments {
for _, input := range payment.Inputs {
pubkeyBytes, err := hex.DecodeString(input.Pubkey)
if err != nil {
return nil, err
}
pubkey, err := secp256k1.ParsePubKey(pubkeyBytes)
if err != nil {
return nil, err
}
cosigners = append(cosigners, pubkey)
}
}
return cosigners, nil
}
func getOffchainReceivers(
payments []domain.Payment,
) []bitcointree.Receiver {
receivers := make([]bitcointree.Receiver, 0)
for _, payment := range payments {
for _, receiver := range payment.Receivers {
if !receiver.IsOnchain() {
receivers = append(receivers, bitcointree.Receiver{
Pubkey: receiver.Pubkey,
Amount: receiver.Amount,
})
}
}
}
return receivers
}
func countSpentVtxos(payments []domain.Payment) uint64 {
var sum uint64
for _, payment := range payments {
sum += uint64(len(payment.Inputs))
}
return sum
}
func taprootOutputScript(taprootKey *secp256k1.PublicKey) ([]byte, error) {
return txscript.NewScriptBuilder().AddOp(txscript.OP_1).AddData(schnorr.SerializePubKey(taprootKey)).Script()
}
func isOnchainOnly(payments []domain.Payment) bool {
for _, p := range payments {
for _, r := range p.Receivers {
if !r.IsOnchain() {
return false
}
}
}
return true
}