diff --git a/client/go.mod b/client/go.mod index 822bca6..944491e 100644 --- a/client/go.mod +++ b/client/go.mod @@ -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 ) diff --git a/client/go.sum b/client/go.sum index 0826d6c..e0aa70d 100644 --- a/client/go.sum +++ b/client/go.sum @@ -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= diff --git a/common/.gitignore b/common/.gitignore index dbe9c82..1d74e21 100644 --- a/common/.gitignore +++ b/common/.gitignore @@ -1 +1 @@ -.vscode/ \ No newline at end of file +.vscode/ diff --git a/common/Makefile b/common/Makefile new file mode 100644 index 0000000..d9ddca9 --- /dev/null +++ b/common/Makefile @@ -0,0 +1,2 @@ +test: + go test -v ./... \ No newline at end of file diff --git a/common/bitcointree/builder.go b/common/bitcointree/builder.go new file mode 100644 index 0000000..1f9bc5d --- /dev/null +++ b/common/bitcointree/builder.go @@ -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() +} diff --git a/common/bitcointree/musig2.go b/common/bitcointree/musig2.go new file mode 100644 index 0000000..1633ad3 --- /dev/null +++ b/common/bitcointree/musig2.go @@ -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 +} diff --git a/common/bitcointree/musig2_test.go b/common/bitcointree/musig2_test.go new file mode 100644 index 0000000..33fcbb4 --- /dev/null +++ b/common/bitcointree/musig2_test.go @@ -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 +} diff --git a/common/bitcointree/script.go b/common/bitcointree/script.go new file mode 100644 index 0000000..12e04f8 --- /dev/null +++ b/common/bitcointree/script.go @@ -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() +} diff --git a/common/bitcointree/testdata/musig2.json b/common/bitcointree/testdata/musig2.json new file mode 100644 index 0000000..e0c230e --- /dev/null +++ b/common/bitcointree/testdata/musig2.json @@ -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 + } + ] + } + ] +} +} \ No newline at end of file diff --git a/common/bitcointree/type.go b/common/bitcointree/type.go new file mode 100644 index 0000000..afe7f9d --- /dev/null +++ b/common/bitcointree/type.go @@ -0,0 +1,6 @@ +package bitcointree + +type Receiver struct { + Pubkey string + Amount uint64 +} diff --git a/common/bitcointree/validation.go b/common/bitcointree/validation.go new file mode 100644 index 0000000..75db289 --- /dev/null +++ b/common/bitcointree/validation.go @@ -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 +} diff --git a/common/go.mod b/common/go.mod index dc75cc3..cc27e28 100644 --- a/common/go.mod +++ b/common/go.mod @@ -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 diff --git a/server/api-spec/protobuf/ark/v1/service.proto b/server/api-spec/protobuf/ark/v1/service.proto index 8b051e6..53c9c1c 100755 --- a/server/api-spec/protobuf/ark/v1/service.proto +++ b/server/api-spec/protobuf/ark/v1/service.proto @@ -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; } diff --git a/server/go.mod b/server/go.mod index 5dec05f..c492158 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 ) diff --git a/server/go.sum b/server/go.sum index 73b6d1c..c770c8a 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/internal/core/application/service.go b/server/internal/core/application/service.go index 9e8bfca..04a3dea 100644 --- a/server/internal/core/application/service.go +++ b/server/internal/core/application/service.go @@ -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)) diff --git a/server/internal/core/application/sweeper.go b/server/internal/core/application/sweeper.go index 7ca26fb..44f262c 100644 --- a/server/internal/core/application/sweeper.go +++ b/server/internal/core/application/sweeper.go @@ -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 } diff --git a/server/internal/core/domain/events.go b/server/internal/core/domain/events.go index f8da116..2bd0ca7 100644 --- a/server/internal/core/domain/events.go +++ b/server/internal/core/domain/events.go @@ -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 diff --git a/server/internal/core/ports/tx_builder.go b/server/internal/core/ports/tx_builder.go index 077f337..e28f4cc 100644 --- a/server/internal/core/ports/tx_builder.go +++ b/server/internal/core/ports/tx_builder.go @@ -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) } diff --git a/server/internal/infrastructure/tx-builder/covenant/builder.go b/server/internal/infrastructure/tx-builder/covenant/builder.go index bb1a9cb..e7aa58e 100644 --- a/server/internal/infrastructure/tx-builder/covenant/builder.go +++ b/server/internal/infrastructure/tx-builder/covenant/builder.go @@ -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 +} diff --git a/server/internal/infrastructure/tx-builder/covenant/sweep.go b/server/internal/infrastructure/tx-builder/covenant/sweep.go index 4ba6a7a..250fec5 100644 --- a/server/internal/infrastructure/tx-builder/covenant/sweep.go +++ b/server/internal/infrastructure/tx-builder/covenant/sweep.go @@ -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 } diff --git a/server/internal/infrastructure/tx-builder/covenantless/builder.go b/server/internal/infrastructure/tx-builder/covenantless/builder.go new file mode 100644 index 0000000..8840e62 --- /dev/null +++ b/server/internal/infrastructure/tx-builder/covenantless/builder.go @@ -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 +} diff --git a/server/internal/infrastructure/tx-builder/covenantless/builder_test.go b/server/internal/infrastructure/tx-builder/covenantless/builder_test.go new file mode 100644 index 0000000..3286f65 --- /dev/null +++ b/server/internal/infrastructure/tx-builder/covenantless/builder_test.go @@ -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 +} diff --git a/server/internal/infrastructure/tx-builder/covenantless/connectors.go b/server/internal/infrastructure/tx-builder/covenantless/connectors.go new file mode 100644 index 0000000..f7e029d --- /dev/null +++ b/server/internal/infrastructure/tx-builder/covenantless/connectors.go @@ -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 +} diff --git a/server/internal/infrastructure/tx-builder/covenantless/forfeit.go b/server/internal/infrastructure/tx-builder/covenantless/forfeit.go new file mode 100644 index 0000000..51ac060 --- /dev/null +++ b/server/internal/infrastructure/tx-builder/covenantless/forfeit.go @@ -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 +} diff --git a/server/internal/infrastructure/tx-builder/covenantless/mocks_test.go b/server/internal/infrastructure/tx-builder/covenantless/mocks_test.go new file mode 100644 index 0000000..b642f85 --- /dev/null +++ b/server/internal/infrastructure/tx-builder/covenantless/mocks_test.go @@ -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 +} diff --git a/server/internal/infrastructure/tx-builder/covenantless/sweep.go b/server/internal/infrastructure/tx-builder/covenantless/sweep.go new file mode 100644 index 0000000..26b4a54 --- /dev/null +++ b/server/internal/infrastructure/tx-builder/covenantless/sweep.go @@ -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 +} diff --git a/server/internal/infrastructure/tx-builder/covenantless/testdata/fixtures.json b/server/internal/infrastructure/tx-builder/covenantless/testdata/fixtures.json new file mode 100644 index 0000000..3b7d830 --- /dev/null +++ b/server/internal/infrastructure/tx-builder/covenantless/testdata/fixtures.json @@ -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": [] + } +} \ No newline at end of file diff --git a/server/internal/infrastructure/tx-builder/covenantless/utils.go b/server/internal/infrastructure/tx-builder/covenantless/utils.go new file mode 100644 index 0000000..072a7f2 --- /dev/null +++ b/server/internal/infrastructure/tx-builder/covenantless/utils.go @@ -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 +}