diff --git a/server/internal/core/application/covenantless.go b/server/internal/core/application/covenantless.go index 69eddbd..666dfb7 100644 --- a/server/internal/core/application/covenantless.go +++ b/server/internal/core/application/covenantless.go @@ -47,7 +47,7 @@ type covenantlessService struct { currentRoundLock sync.Mutex currentRound *domain.Round treeSigningSessions map[string]*musigSigningSession - asyncPaymentsCache map[domain.VtxoKey]struct { + asyncPaymentsCache map[string]struct { // redeem txid -> receivers receivers []domain.Receiver expireAt int64 } @@ -70,7 +70,7 @@ func NewCovenantlessService( } sweeper := newSweeper(walletSvc, repoManager, builder, scheduler) - asyncPaymentsCache := make(map[domain.VtxoKey]struct { + asyncPaymentsCache := make(map[string]struct { receivers []domain.Receiver expireAt int64 }) @@ -142,14 +142,114 @@ func (s *covenantlessService) Stop() { func (s *covenantlessService) CompleteAsyncPayment( ctx context.Context, redeemTx string, unconditionalForfeitTxs []string, ) error { - // TODO check that the user signed both transactions - redeemPtx, err := psbt.NewFromRawBytes(strings.NewReader(redeemTx), true) if err != nil { return fmt.Errorf("failed to parse redeem tx: %s", err) } redeemTxid := redeemPtx.UnsignedTx.TxID() + asyncPayData, ok := s.asyncPaymentsCache[redeemTxid] + if !ok { + return fmt.Errorf("async payment not found") + } + + txs := append([]string{redeemTx}, unconditionalForfeitTxs...) + vtxoRepo := s.repoManager.Vtxos() + + for _, tx := range txs { + ptx, err := psbt.NewFromRawBytes(strings.NewReader(tx), true) + if err != nil { + return fmt.Errorf("failed to parse tx: %s", err) + } + + for inputIndex, input := range ptx.Inputs { + if input.WitnessUtxo == nil { + return fmt.Errorf("missing witness utxo") + } + + if len(input.TaprootLeafScript) == 0 { + return fmt.Errorf("missing tapscript leaf") + } + + if len(input.TaprootScriptSpendSig) == 0 { + return fmt.Errorf("missing tapscript spend sig") + } + + vtxoOutpoint := ptx.UnsignedTx.TxIn[inputIndex].PreviousOutPoint + + // verify that the vtxo is spendable + + vtxo, err := vtxoRepo.GetVtxos(ctx, []domain.VtxoKey{{Txid: vtxoOutpoint.Hash.String(), VOut: vtxoOutpoint.Index}}) + if err != nil { + return fmt.Errorf("failed to get vtxo: %s", err) + } + + if len(vtxo) == 0 { + return fmt.Errorf("vtxo not found") + } + + if vtxo[0].Spent { + return fmt.Errorf("vtxo already spent") + } + + if vtxo[0].Redeemed { + return fmt.Errorf("vtxo already redeemed") + } + + if vtxo[0].Swept { + return fmt.Errorf("vtxo already swept") + } + + // verify that the user signs the tx using the right public key + + vtxoPublicKey, err := hex.DecodeString(vtxo[0].Pubkey) + if err != nil { + return fmt.Errorf("failed to decode pubkey: %s", err) + } + + pubkey, err := secp256k1.ParsePubKey(vtxoPublicKey) + if err != nil { + return fmt.Errorf("failed to parse pubkey: %s", err) + } + + xonlyPubkey := schnorr.SerializePubKey(pubkey) + + // find signature belonging to the pubkey + found := false + + for _, sig := range input.TaprootScriptSpendSig { + if bytes.Equal(sig.XOnlyPubKey, xonlyPubkey) { + found = true + break + } + } + + if !found { + return fmt.Errorf("signature not found for pubkey") + } + + // verify witness utxo + + pkscript, err := s.builder.GetVtxoScript(pubkey, s.pubkey) + if err != nil { + return fmt.Errorf("failed to get vtxo script: %s", err) + } + + if !bytes.Equal(input.WitnessUtxo.PkScript, pkscript) { + return fmt.Errorf("witness utxo script mismatch") + } + + if input.WitnessUtxo.Value != int64(vtxo[0].Amount) { + return fmt.Errorf("witness utxo value mismatch") + } + } + + // verify the tapscript signatures + if valid, _, err := s.builder.VerifyTapscriptPartialSigs(tx); err != nil || !valid { + return fmt.Errorf("invalid tx signature: %s", err) + } + } + spentVtxos := make([]domain.VtxoKey, 0, len(unconditionalForfeitTxs)) for _, in := range redeemPtx.UnsignedTx.TxIn { spentVtxos = append(spentVtxos, domain.VtxoKey{ @@ -158,11 +258,6 @@ func (s *covenantlessService) CompleteAsyncPayment( }) } - asyncPayData, ok := s.asyncPaymentsCache[spentVtxos[0]] - if !ok { - return fmt.Errorf("async payment not found") - } - vtxos := make([]domain.Vtxo, 0, len(asyncPayData.receivers)) for i, receiver := range asyncPayData.receivers { vtxos = append(vtxos, domain.Vtxo{ @@ -189,7 +284,7 @@ func (s *covenantlessService) CompleteAsyncPayment( } log.Infof("spent %d vtxos", len(spentVtxos)) - delete(s.asyncPaymentsCache, spentVtxos[0]) + delete(s.asyncPaymentsCache, redeemTxid) return nil } @@ -218,6 +313,7 @@ func (s *covenantlessService) CreateAsyncPayment( if vtxo.Swept { return "", nil, fmt.Errorf("all vtxos must be swept") } + if vtxo.ExpireAt < expiration { expiration = vtxo.ExpireAt } @@ -230,7 +326,12 @@ func (s *covenantlessService) CreateAsyncPayment( return "", nil, fmt.Errorf("failed to build async payment txs: %s", err) } - s.asyncPaymentsCache[inputs[0]] = struct { + redeemTx, err := psbt.NewFromRawBytes(strings.NewReader(res.RedeemTx), true) + if err != nil { + return "", nil, fmt.Errorf("failed to parse redeem tx: %s", err) + } + + s.asyncPaymentsCache[redeemTx.UnsignedTx.TxID()] = struct { receivers []domain.Receiver expireAt int64 }{ diff --git a/server/internal/core/application/utils.go b/server/internal/core/application/utils.go index 4f1916a..097c85d 100644 --- a/server/internal/core/application/utils.go +++ b/server/internal/core/application/utils.go @@ -185,7 +185,7 @@ func (m *forfeitTxsMap) push(txs []string) { defer m.lock.Unlock() for _, tx := range txs { - signed, txid, _ := m.builder.VerifyForfeitTx(tx) + signed, txid, _ := m.builder.VerifyTapscriptPartialSigs(tx) m.forfeitTxs[txid] = &signedTx{tx, signed} } } @@ -195,7 +195,7 @@ func (m *forfeitTxsMap) sign(txs []string) error { defer m.lock.Unlock() for _, tx := range txs { - valid, txid, err := m.builder.VerifyForfeitTx(tx) + valid, txid, err := m.builder.VerifyTapscriptPartialSigs(tx) if err != nil { return err } diff --git a/server/internal/core/ports/tx_builder.go b/server/internal/core/ports/tx_builder.go index 013cca8..865c80d 100644 --- a/server/internal/core/ports/tx_builder.go +++ b/server/internal/core/ports/tx_builder.go @@ -32,7 +32,7 @@ type TxBuilder interface { 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) - VerifyForfeitTx(tx string) (valid bool, txid string, err error) + VerifyTapscriptPartialSigs(tx string) (valid bool, txid string, err error) FinalizeAndExtractForfeit(tx string) (txhex string, err error) // FindLeaves returns all the leaves txs that are reachable from the given outpoint FindLeaves(congestionTree tree.CongestionTree, fromtxid string, vout uint32) (leaves []tree.Node, err error) diff --git a/server/internal/infrastructure/tx-builder/covenant/builder.go b/server/internal/infrastructure/tx-builder/covenant/builder.go index 6c00fd5..9ad1d30 100644 --- a/server/internal/infrastructure/tx-builder/covenant/builder.go +++ b/server/internal/infrastructure/tx-builder/covenant/builder.go @@ -1,6 +1,7 @@ package txbuilder import ( + "bytes" "context" "encoding/hex" "fmt" @@ -249,27 +250,46 @@ func (b *txBuilder) GetSweepInput(parentblocktime int64, node tree.Node) (expira return expirationTime, sweepInput, nil } -func (b *txBuilder) VerifyForfeitTx(tx string) (bool, string, error) { +func (b *txBuilder) VerifyTapscriptPartialSigs(tx string) (bool, string, error) { ptx, _ := psetv2.NewPsetFromBase64(tx) utx, _ := ptx.UnsignedTx() txid := utx.TxHash().String() for index, input := range ptx.Inputs { + if len(input.TapLeafScript) == 0 { + continue + } + if input.WitnessUtxo == nil { + return false, txid, fmt.Errorf("missing witness utxo for input %d, cannot verify signature", index) + } + + // verify taproot leaf script + tapLeaf := input.TapLeafScript[0] + + rootHash := tapLeaf.ControlBlock.RootHash(tapLeaf.Script) + tapKeyFromControlBlock := taproot.ComputeTaprootOutputKey(tree.UnspendableKey(), rootHash[:]) + + pkscript, err := p2trScript(tapKeyFromControlBlock) + if err != nil { + return false, txid, err + } + + if !bytes.Equal(pkscript, input.WitnessUtxo.Script) { + return false, txid, fmt.Errorf("invalid control block for input %d", index) + } + + leafHash := taproot.NewBaseTapElementsLeaf(tapLeaf.Script).TapHash() + + preimage, err := b.getTaprootPreimage( + tx, + index, + &leafHash, + ) + if err != nil { + return false, txid, err + } + for _, tapScriptSig := range input.TapScriptSig { - leafHash, err := chainhash.NewHash(tapScriptSig.LeafHash) - if err != nil { - return false, txid, err - } - - preimage, err := b.getTaprootPreimage( - tx, - index, - leafHash, - ) - if err != nil { - return false, txid, err - } - sig, err := schnorr.ParseSignature(tapScriptSig.Signature) if err != nil { return false, txid, err @@ -280,15 +300,13 @@ func (b *txBuilder) VerifyForfeitTx(tx string) (bool, string, error) { return false, txid, err } - if sig.Verify(preimage, pubkey) { - return true, txid, nil - } else { - return false, txid, fmt.Errorf("invalid signature") + if !sig.Verify(preimage, pubkey) { + return false, txid, fmt.Errorf("invalid signature for tx %s", txid) } } } - return false, txid, nil + return true, txid, nil } func (b *txBuilder) FinalizeAndExtractForfeit(tx string) (string, error) { @@ -388,7 +406,7 @@ func (b *txBuilder) getLeafScriptAndTree( unspendableKey := tree.UnspendableKey() taprootKey := taproot.ComputeTaprootOutputKey(unspendableKey, root[:]) - outputScript, err := taprootOutputScript(taprootKey) + outputScript, err := p2trScript(taprootKey) if err != nil { return nil, nil, err } diff --git a/server/internal/infrastructure/tx-builder/covenant/sweep.go b/server/internal/infrastructure/tx-builder/covenant/sweep.go index ba32c68..9cc6775 100644 --- a/server/internal/infrastructure/tx-builder/covenant/sweep.go +++ b/server/internal/infrastructure/tx-builder/covenant/sweep.go @@ -79,7 +79,7 @@ func sweepTransaction( root := leaf.ControlBlock.RootHash(leaf.Script) taprootKey := taproot.ComputeTaprootOutputKey(leaf.ControlBlock.InternalKey, root) - script, err := taprootOutputScript(taprootKey) + script, err := p2trScript(taprootKey) if err != nil { return nil, err } diff --git a/server/internal/infrastructure/tx-builder/covenant/utils.go b/server/internal/infrastructure/tx-builder/covenant/utils.go index 5f73736..d2981fb 100644 --- a/server/internal/infrastructure/tx-builder/covenant/utils.go +++ b/server/internal/infrastructure/tx-builder/covenant/utils.go @@ -136,7 +136,7 @@ func addInputs( return nil } -func taprootOutputScript(taprootKey *secp256k1.PublicKey) ([]byte, error) { +func p2trScript(taprootKey *secp256k1.PublicKey) ([]byte, error) { return txscript.NewScriptBuilder().AddOp(txscript.OP_1).AddData(schnorr.SerializePubKey(taprootKey)).Script() } diff --git a/server/internal/infrastructure/tx-builder/covenantless/builder.go b/server/internal/infrastructure/tx-builder/covenantless/builder.go index 80269e1..c474b99 100644 --- a/server/internal/infrastructure/tx-builder/covenantless/builder.go +++ b/server/internal/infrastructure/tx-builder/covenantless/builder.go @@ -38,22 +38,51 @@ func NewTxBuilder( return &txBuilder{wallet, net, roundLifetime, exitDelay, boardingExitDelay} } -func (b *txBuilder) VerifyForfeitTx(tx string) (bool, string, error) { +func (b *txBuilder) VerifyTapscriptPartialSigs(tx string) (bool, string, error) { ptx, _ := psbt.NewFromRawBytes(strings.NewReader(tx), true) - txid := ptx.UnsignedTx.TxHash().String() + txid := ptx.UnsignedTx.TxID() for index, input := range ptx.Inputs { - // TODO (@louisinger): verify control block - for _, tapScriptSig := range input.TaprootScriptSpendSig { - preimage, err := b.getTaprootPreimage( - tx, - index, - input.TaprootLeafScript[0].Script, - ) - if err != nil { - return false, txid, err - } + if len(input.TaprootLeafScript) == 0 { + continue + } + if input.WitnessUtxo == nil { + return false, txid, fmt.Errorf("missing witness utxo for input %d, cannot verify signature", index) + } + + // verify taproot leaf script + tapLeaf := input.TaprootLeafScript[0] + if len(tapLeaf.ControlBlock) == 0 { + return false, txid, fmt.Errorf("missing control block for input %d", index) + } + + controlBlock, err := txscript.ParseControlBlock(tapLeaf.ControlBlock) + if err != nil { + return false, txid, err + } + + rootHash := controlBlock.RootHash(tapLeaf.Script) + tapKeyFromControlBlock := txscript.ComputeTaprootOutputKey(bitcointree.UnspendableKey(), rootHash[:]) + pkscript, err := p2trScript(tapKeyFromControlBlock) + if err != nil { + return false, txid, err + } + + if !bytes.Equal(pkscript, input.WitnessUtxo.PkScript) { + return false, txid, fmt.Errorf("invalid control block for input %d", index) + } + + preimage, err := b.getTaprootPreimage( + tx, + index, + tapLeaf.Script, + ) + if err != nil { + return false, txid, err + } + + for _, tapScriptSig := range input.TaprootScriptSpendSig { sig, err := schnorr.ParseSignature(tapScriptSig.Signature) if err != nil { return false, txid, err @@ -64,15 +93,13 @@ func (b *txBuilder) VerifyForfeitTx(tx string) (bool, string, error) { return false, txid, err } - if sig.Verify(preimage, pubkey) { - return true, txid, nil - } else { + if !sig.Verify(preimage, pubkey) { return false, txid, fmt.Errorf("invalid signature for tx %s", txid) } } } - return false, txid, nil + return true, txid, nil } func (b *txBuilder) FinalizeAndExtractForfeit(tx string) (string, error) { @@ -359,8 +386,7 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( return nil, err } - // TODO generate a fresh new address to get the forfeit funds - aspScript, err := p2trScript(aspPubKey, b.onchainNetwork()) + aspScript, err := p2trScript(aspPubKey) if err != nil { return nil, err } @@ -494,8 +520,13 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( }) } + sequences := make([]uint32, len(ins)) + for i := range sequences { + sequences[i] = wire.MaxTxInSequenceNum + } + redeemPtx, err := psbt.New( - ins, outs, 2, 0, []uint32{wire.MaxTxInSequenceNum}, + ins, outs, 2, 0, sequences, ) if err != nil { return nil, err @@ -1087,8 +1118,7 @@ func (b *txBuilder) minRelayFeeForfeitTx() (uint64, error) { func (b *txBuilder) createForfeitTxs( aspPubkey *secp256k1.PublicKey, payments []domain.Payment, connectors []*psbt.Packet, feeAmount uint64, ) ([]string, error) { - // TODO generate a fresh new address to receive the forfeited funds - aspScript, err := p2trScript(aspPubkey, b.onchainNetwork()) + aspScript, err := p2trScript(aspPubkey) if err != nil { return nil, err } diff --git a/server/internal/infrastructure/tx-builder/covenantless/utils.go b/server/internal/infrastructure/tx-builder/covenantless/utils.go index ab57739..5e42810 100644 --- a/server/internal/infrastructure/tx-builder/covenantless/utils.go +++ b/server/internal/infrastructure/tx-builder/covenantless/utils.go @@ -4,24 +4,12 @@ import ( "github.com/ark-network/ark/common/bitcointree" "github.com/ark-network/ark/server/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.NewAddressWitnessPubKeyHash( - btcutil.Hash160(tapKey.SerializeCompressed()), - net, - ) - if err != nil { - return nil, err - } - - return txscript.PayToAddrScript(payment) +func p2trScript(taprootKey *secp256k1.PublicKey) ([]byte, error) { + return txscript.NewScriptBuilder().AddOp(txscript.OP_1).AddData(schnorr.SerializePubKey(taprootKey)).Script() } func getOnchainReceivers(