diff --git a/common/bitcointree/builder.go b/common/bitcointree/builder.go index e5a40e96c..59449bb25 100644 --- a/common/bitcointree/builder.go +++ b/common/bitcointree/builder.go @@ -17,8 +17,12 @@ import ( // CraftSharedOutput returns the taproot script and the amount of the initial root output func CraftSharedOutput( - cosigners []*secp256k1.PublicKey, aspPubkey *secp256k1.PublicKey, receivers []tree.VtxoLeaf, - feeSatsPerNode uint64, roundLifetime int64, + cosigners []*secp256k1.PublicKey, + aspPubkey *secp256k1.PublicKey, + receivers []tree.VtxoLeaf, + feeSatsPerNode, + dustAmount uint64, + roundLifetime int64, ) ([]byte, int64, error) { aggregatedKey, _, err := createAggregatedKeyWithSweep( cosigners, aspPubkey, roundLifetime, @@ -27,7 +31,7 @@ func CraftSharedOutput( return nil, 0, err } - root, err := createRootNode(aggregatedKey, cosigners, receivers, feeSatsPerNode) + root, err := createRootNode(aggregatedKey, cosigners, receivers, feeSatsPerNode, dustAmount) if err != nil { return nil, 0, err } @@ -44,8 +48,13 @@ func CraftSharedOutput( // CraftCongestionTree creates all the tree's transactions func CraftCongestionTree( - initialInput *wire.OutPoint, cosigners []*secp256k1.PublicKey, aspPubkey *secp256k1.PublicKey, receivers []tree.VtxoLeaf, - feeSatsPerNode uint64, roundLifetime int64, + initialInput *wire.OutPoint, + cosigners []*secp256k1.PublicKey, + aspPubkey *secp256k1.PublicKey, + receivers []tree.VtxoLeaf, + feeSatsPerNode, + dustAmount uint64, + roundLifetime int64, ) (tree.CongestionTree, error) { aggregatedKey, sweepTapLeaf, err := createAggregatedKeyWithSweep( cosigners, aspPubkey, roundLifetime, @@ -54,7 +63,7 @@ func CraftCongestionTree( return nil, err } - root, err := createRootNode(aggregatedKey, cosigners, receivers, feeSatsPerNode) + root, err := createRootNode(aggregatedKey, cosigners, receivers, feeSatsPerNode, dustAmount) if err != nil { return nil, err } @@ -107,11 +116,13 @@ type node interface { getAmount() int64 // returns the input amount of the node = sum of all receivers' amounts + fees getOutputs() ([]*wire.TxOut, error) getChildren() []node + getTxVersion() int32 } type leaf struct { - amount int64 - pubkey *secp256k1.PublicKey + amount int64 + dustAmount int64 + pubkey *secp256k1.PublicKey } type branch struct { @@ -121,6 +132,14 @@ type branch struct { feeAmount int64 } +func (b *branch) getTxVersion() int32 { + return 2 +} + +func (l *leaf) getTxVersion() int32 { + return 3 +} + func (b *branch) getChildren() []node { return b.children } @@ -133,7 +152,9 @@ func (b *branch) getAmount() int64 { amount := int64(0) for _, child := range b.children { amount += child.getAmount() - amount += b.feeAmount + if child.getTxVersion() == 2 { + amount += b.feeAmount + } } return amount @@ -149,12 +170,17 @@ func (l *leaf) getOutputs() ([]*wire.TxOut, error) { return nil, err } - output := &wire.TxOut{ - Value: l.amount, + vtxoOutput := &wire.TxOut{ + Value: l.amount - ANCHOR_AMOUNT, PkScript: script, } - return []*wire.TxOut{output}, nil + anchorOutput := &wire.TxOut{ + PkScript: ANCHOR_PKSCRIPT, + Value: ANCHOR_AMOUNT, + } + + return []*wire.TxOut{vtxoOutput, anchorOutput}, nil } func (b *branch) getOutputs() ([]*wire.TxOut, error) { @@ -166,8 +192,13 @@ func (b *branch) getOutputs() ([]*wire.TxOut, error) { outputs := make([]*wire.TxOut, 0) for _, child := range b.children { + value := child.getAmount() + if child.getTxVersion() == 2 { + value += b.feeAmount + } + outputs = append(outputs, &wire.TxOut{ - Value: child.getAmount() + b.feeAmount, + Value: value, PkScript: sharedOutputScript, }) } @@ -202,6 +233,9 @@ func getTreeNode( }, nil } +// getTx returns the psbt associated with the node +// the psbt contains the inputs of the parent and the outputs of the children (or VTXOs if it's a leaf) +// it also contains the internal key used to "unroll" and the sweep tascript branch of the input func getTx( n node, input *wire.OutPoint, @@ -214,7 +248,13 @@ func getTx( return nil, err } - tx, err := psbt.New([]*wire.OutPoint{input}, outputs, 2, 0, []uint32{wire.MaxTxInSequenceNum}) + tx, err := psbt.New( + []*wire.OutPoint{input}, + outputs, + n.getTxVersion(), + 0, + []uint32{wire.MaxTxInSequenceNum}, + ) if err != nil { return nil, err } @@ -244,7 +284,8 @@ func createRootNode( aggregatedKey *musig2.AggregateKey, cosigners []*secp256k1.PublicKey, receivers []tree.VtxoLeaf, - feeSatsPerNode uint64, + feeSatsPerNode, + dustAmount uint64, ) (root node, err error) { if len(receivers) == 0 { return nil, fmt.Errorf("no receivers provided") @@ -263,8 +304,9 @@ func createRootNode( } leafNode := &leaf{ - amount: int64(r.Amount), - pubkey: pubkey, + amount: int64(r.Amount), + dustAmount: int64(dustAmount), + pubkey: pubkey, } nodes = append(nodes, leafNode) } diff --git a/common/bitcointree/musig2_test.go b/common/bitcointree/musig2_test.go index b391f4975..28df46082 100644 --- a/common/bitcointree/musig2_test.go +++ b/common/bitcointree/musig2_test.go @@ -17,6 +17,7 @@ import ( ) const ( + dustAmount = 100 minRelayFee = 1000 exitDelay = 512 lifetime = 1024 @@ -46,6 +47,7 @@ func TestRoundTripSignTree(t *testing.T) { asp.PubKey(), castReceivers(f.Receivers), minRelayFee, + dustAmount, lifetime, ) require.NoError(t, err) @@ -60,6 +62,7 @@ func TestRoundTripSignTree(t *testing.T) { asp.PubKey(), castReceivers(f.Receivers), minRelayFee, + dustAmount, lifetime, ) require.NoError(t, err) diff --git a/common/bitcointree/psbt.go b/common/bitcointree/psbt.go index 379f422f6..db4287988 100644 --- a/common/bitcointree/psbt.go +++ b/common/bitcointree/psbt.go @@ -11,6 +11,10 @@ var ( COSIGNER_PSBT_KEY_PREFIX = []byte("cosigner") ) +// P2A script = p2wsh(OP_TRUE) +var ANCHOR_PKSCRIPT = []byte{0, 32, 74, 232, 21, 114, 240, 110, 27, 136, 253, 92, 237, 122, 26, 0, 9, 69, 67, 46, 131, 225, 85, 30, 111, 114, 30, 233, 192, 11, 140, 195, 50, 96} +var ANCHOR_AMOUNT = int64(330) // dust amount for P2A + func AddCosignerKey(inIndex int, ptx *psbt.Packet, key *secp256k1.PublicKey) error { currentCosigners, err := GetCosignerKeys(ptx.Inputs[inIndex]) if err != nil { diff --git a/pkg/client-sdk/covenantless_client.go b/pkg/client-sdk/covenantless_client.go index d7c7e34b5..4ad4d917e 100644 --- a/pkg/client-sdk/covenantless_client.go +++ b/pkg/client-sdk/covenantless_client.go @@ -1891,22 +1891,30 @@ func (a *covenantlessArkClient) validateOffChainReceiver( return err } + var amount uint64 + for _, output := range tx.UnsignedTx.TxOut { if len(output.PkScript) == 0 { continue } - if bytes.Equal(output.PkScript[2:], vtxoTapKey) { - if output.Value != int64(receiver.Amount) { - continue - } + if bytes.Equal(output.PkScript, bitcointree.ANCHOR_PKSCRIPT) { + amount += uint64(output.Value) + continue + } - found = true - break + if len(output.PkScript) == 34 { + if bytes.Equal(output.PkScript[2:], vtxoTapKey) { + found = true + amount += uint64(output.Value) + } } } if found { + if amount != receiver.Amount { + continue + } break } } diff --git a/server/internal/core/application/covenantless.go b/server/internal/core/application/covenantless.go index cf41a576a..49d44c2e6 100644 --- a/server/internal/core/application/covenantless.go +++ b/server/internal/core/application/covenantless.go @@ -20,9 +20,12 @@ import ( "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/mempool" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" log "github.com/sirupsen/logrus" ) @@ -254,6 +257,10 @@ func (s *covenantlessService) CompleteAsyncPayment( vtxos := make([]domain.Vtxo, 0, len(asyncPayData.receivers)) for outIndex, out := range redeemPtx.UnsignedTx.TxOut { + if bytes.Equal(out.PkScript, bitcointree.ANCHOR_PKSCRIPT) { + continue // skip anchor output + } + vtxoTapKey, err := schnorr.ParsePubKey(out.PkScript[2:]) if err != nil { return fmt.Errorf("failed to parse vtxo taproot key: %s", err) @@ -351,7 +358,7 @@ func (s *covenantlessService) CreateAsyncPayment( vtxosInputs = append(vtxosInputs, vtxo) } - redeemTx, err := s.builder.BuildAsyncPaymentTransactions( + redeemTx, err := s.builder.BuildTxOOR( vtxosInputs, descriptors, forfeitLeaves, receivers, ) if err != nil { @@ -751,6 +758,7 @@ func (s *covenantlessService) startFinalization() { if err := s.saveEvents(ctx, round.Id, round.Events()); err != nil { log.WithError(err).Warn("failed to store new round events") + return } if round.IsFailed() { @@ -1142,11 +1150,9 @@ func (s *covenantlessService) listenToScannerNotifications() { vtxo := vtxos[0] if !vtxo.Redeemed { - go func() { - if err := s.markAsRedeemed(ctx, vtxo); err != nil { - log.WithError(err).Warnf("failed to mark vtxo %s:%d as redeemed", vtxo.Txid, vtxo.VOut) - } - }() + if err := s.markAsRedeemed(ctx, vtxo); err != nil { + log.WithError(err).Warnf("failed to mark vtxo %s:%d as redeemed", vtxo.Txid, vtxo.VOut) + } } if vtxo.Spent { @@ -1166,10 +1172,10 @@ func (s *covenantlessService) listenToScannerNotifications() { func (s *covenantlessService) getNextConnector( ctx context.Context, round domain.Round, -) (string, uint32, error) { +) (string, uint32, *btcutil.Tx, error) { lastConnectorPtx, err := psbt.NewFromRawBytes(strings.NewReader(round.Connectors[len(round.Connectors)-1]), true) if err != nil { - return "", 0, err + return "", 0, nil, err } lastOutput := lastConnectorPtx.UnsignedTx.TxOut[len(lastConnectorPtx.UnsignedTx.TxOut)-1] @@ -1177,35 +1183,40 @@ func (s *covenantlessService) getNextConnector( utxos, err := s.wallet.ListConnectorUtxos(ctx, round.ConnectorAddress) if err != nil { - return "", 0, err + return "", 0, nil, err } log.Debugf("found %d connector utxos, dust amount is %d", len(utxos), connectorAmount) // if we do not find any utxos, we make sure to wait for the connector outpoint to be confirmed then we retry if len(utxos) <= 0 { if err := s.wallet.WaitForSync(ctx, round.Txid); err != nil { - return "", 0, err + return "", 0, nil, err } utxos, err = s.wallet.ListConnectorUtxos(ctx, round.ConnectorAddress) if err != nil { - return "", 0, err + return "", 0, nil, err } } // search for an already existing connector for _, u := range utxos { + // skip if the utxo is the round tx output + if u.GetTxid() == round.Txid { + continue + } + if u.GetValue() == uint64(connectorAmount) { - return u.GetTxid(), u.GetIndex(), nil + return u.GetTxid(), u.GetIndex(), nil, nil } } for _, u := range utxos { - if u.GetValue() > uint64(connectorAmount) { + if u.GetTxid() == round.Txid || u.GetValue() > uint64(connectorAmount) { for _, b64 := range round.Connectors { ptx, err := psbt.NewFromRawBytes(strings.NewReader(b64), true) if err != nil { - return "", 0, err + return "", 0, nil, err } for _, i := range ptx.UnsignedTx.TxIn { @@ -1213,34 +1224,39 @@ func (s *covenantlessService) getNextConnector( connectorOutpoint := txOutpoint{u.GetTxid(), u.GetIndex()} if err := s.wallet.LockConnectorUtxos(ctx, []ports.TxOutpoint{connectorOutpoint}); err != nil { - return "", 0, err + return "", 0, nil, err } - // sign & broadcast the connector tx - signedConnectorTx, err := s.wallet.SignTransaction(ctx, b64, true) + // sign the connector tx + signedConnectorTxHex, err := s.wallet.SignTransaction(ctx, b64, true) if err != nil { - return "", 0, err + return "", 0, nil, err } - connectorTxid, err := s.wallet.BroadcastTransaction(ctx, signedConnectorTx) + // broadcast the connector tx + txid, err := s.wallet.BroadcastTransaction(ctx, signedConnectorTxHex) if err != nil { - return "", 0, fmt.Errorf("failed to broadcast connector tx: %s", err) + return "", 0, nil, err + } + + var signedConnectorTx wire.MsgTx + if err := signedConnectorTx.Deserialize(hex.NewDecoder(strings.NewReader(signedConnectorTxHex))); err != nil { + return "", 0, nil, err } - log.Debugf("broadcasted connector tx %s", connectorTxid) // wait for the connector tx to be in the mempool - if err := s.wallet.WaitForSync(ctx, connectorTxid); err != nil { - return "", 0, err + if err := s.wallet.WaitForSync(ctx, txid); err != nil { + return "", 0, nil, err } - return connectorTxid, 0, nil + return txid, 0, btcutil.NewTx(&signedConnectorTx), nil } } } } } - return "", 0, fmt.Errorf("no connector utxos found") + return "", 0, nil, fmt.Errorf("no connector utxos found") } func (s *covenantlessService) updateVtxoSet(round *domain.Round) { @@ -1340,6 +1356,9 @@ func (s *covenantlessService) getNewVtxos(round *domain.Round) []domain.Vtxo { continue } for i, out := range tx.UnsignedTx.TxOut { + if bytes.Equal(out.PkScript, bitcointree.ANCHOR_PKSCRIPT) { + continue // skip anchor output + } vtxoTapKey, err := schnorr.ParsePubKey(out.PkScript[2:]) if err != nil { log.WithError(err).Warn("failed to parse vtxo tap key") @@ -1479,71 +1498,404 @@ func (s *covenantlessService) reactToFraud(ctx context.Context, vtxo domain.Vtxo defer mutx.Unlock() roundRepo := s.repoManager.Rounds() + settledVtxos := make(map[domain.VtxoKey]domain.Round) + + type pkgTx struct { + tx *btcutil.Tx + isConnector bool + } + + txsPkg := make([]pkgTx, 0) + round, err := roundRepo.GetRoundWithTxid(ctx, vtxo.SpentBy) if err != nil { + log.Debugf("vtxo %s:%d has been spent by oor", vtxo.Txid, vtxo.VOut) + vtxosRepo := s.repoManager.Vtxos() - // if the round is not found, the utxo may be spent by an async payment redeem tx - vtxos, err := vtxosRepo.GetVtxos(ctx, []domain.VtxoKey{ - {Txid: vtxo.SpentBy, VOut: 0}, - }) + // if the round is not found, it means spentBy is a OOR redeem tx + // get all the associated vtxos + vtxos, err := vtxosRepo.GetVtxosByTxid(ctx, vtxo.SpentBy) if err != nil || len(vtxos) <= 0 { - return fmt.Errorf("failed to retrieve round: %s", err) + return fmt.Errorf("failed to retrieve redeem txs vtxos: %s", err) } - asyncPayVtxo := vtxos[0] - if asyncPayVtxo.Redeemed { // redeem tx is already onchain + // check if the redeem tx is already onchain = at least one vtxo is marked as redeemed + for _, v := range vtxos { + if v.Redeemed { + return nil + } + } + + atLeastOneSpent := false + + // search for a spent vtxo + for _, v := range vtxos { + if v.Spent { + atLeastOneSpent = true + // if the vtxo is settled, add the round to the list + if _, ok := settledVtxos[v.VtxoKey]; !ok { + r, _ := roundRepo.GetRoundWithTxid(ctx, v.SpentBy) + if r != nil { + settledVtxos[v.VtxoKey] = *r + } + } + } + } + + // if no vtxos of the redeem tx are spent, the server is not responsible to broadcast + if !atLeastOneSpent { return nil } - log.Debugf("vtxo %s:%d has been spent by async payment", vtxo.Txid, vtxo.VOut) + redeemTx := vtxos[0].RedeemTx - redeemTxHex, err := s.builder.FinalizeAndExtract(asyncPayVtxo.RedeemTx) + redeemTxHex, err := s.builder.FinalizeAndExtract(redeemTx) if err != nil { return fmt.Errorf("failed to finalize redeem tx: %s", err) } - redeemTxid, err := s.wallet.BroadcastTransaction(ctx, redeemTxHex) + var redeemTxMsg wire.MsgTx + + if err := redeemTxMsg.Deserialize(hex.NewDecoder(strings.NewReader(redeemTxHex))); err != nil { + return fmt.Errorf("failed to deserialize redeem tx: %s", err) + } + + // in case the next state of the chain is only OOR, broadcast the redeem tx + if len(settledVtxos) <= 0 { + log.Debugf("redeem tx %s is not settled, broadcasting only the redeem tx", vtxos[0].Txid) + if err := s.broadcastRedeemTx(ctx, btcutil.NewTx(&redeemTxMsg)); err != nil { + return fmt.Errorf("failed to broadcast redeem tx: %s", err) + } + return nil + } + + // if some rounds are settle, add the redeem tx to the package + log.Debugf("adding redeem tx %s to package", vtxos[0].Txid) + txsPkg = append(txsPkg, pkgTx{tx: btcutil.NewTx(&redeemTxMsg), isConnector: false}) + log.Debugf("redeem tx hex: %s", redeemTxHex) + } else { + // if v.spentBy is a round, the vtxo has been settled + settledVtxos[vtxo.VtxoKey] = *round + } + + outputsToBump := make(map[wire.OutPoint]*wire.TxOut) + + for vtxoKey, r := range settledVtxos { + connectorTxid, connectorVout, connectorTx, err := s.getNextConnector(ctx, r) if err != nil { - return fmt.Errorf("failed to broadcast redeem tx: %s", err) + return fmt.Errorf("failed to get next connector: %s", err) } - log.Debugf("broadcasted redeem tx %s", redeemTxid) + if connectorTx != nil { + txsPkg = append(txsPkg, pkgTx{tx: connectorTx, isConnector: true}) + log.Debugf("found next connector %s:%d", connectorTxid, connectorVout) + log.Debugf("added connector tx %s to package", connectorTx.Hash().String()) + } + + forfeitTx, err := findForfeitTxBitcoin(r.ForfeitTxs, connectorTxid, connectorVout, vtxoKey) + if err != nil { + return fmt.Errorf("failed to find forfeit tx: %s", err) + } + + if err := s.wallet.LockConnectorUtxos(ctx, []ports.TxOutpoint{txOutpoint{connectorTxid, connectorVout}}); err != nil { + return fmt.Errorf("failed to lock connector utxos: %s", err) + } + + signedForfeitTx, err := s.wallet.SignTransactionTapscript(ctx, forfeitTx, nil) + if err != nil { + return fmt.Errorf("failed to sign forfeit tx: %s", err) + } + + forfeitTxHex, err := s.builder.FinalizeAndExtract(signedForfeitTx) + if err != nil { + return fmt.Errorf("failed to finalize forfeit tx: %s", err) + } + + var forfeitTxMsg wire.MsgTx + if err := forfeitTxMsg.Deserialize(hex.NewDecoder(strings.NewReader(forfeitTxHex))); err != nil { + return fmt.Errorf("failed to deserialize forfeit tx: %s", err) + } + + txsPkg = append(txsPkg, pkgTx{tx: btcutil.NewTx(&forfeitTxMsg), isConnector: false}) + + log.Debugf("added forfeit tx %s to package", forfeitTxMsg.TxID()) + + outputToBump := forfeitTxMsg.TxOut[0] + txHash := forfeitTxMsg.TxHash() + outpointToBump := wire.NewOutPoint( + &txHash, + uint32(0), + ) + + outputsToBump[*outpointToBump] = outputToBump + } + + // generate the output script of the bump tx + addrs, err := s.wallet.DeriveAddresses(ctx, 1) + if err != nil { + return fmt.Errorf("failed to derive addresses: %s", err) + } + + addr := addrs[0] + parsedAddr, err := btcutil.DecodeAddress(addr, s.chainParams()) + if err != nil { + return fmt.Errorf("failed to decode address: %s", err) + } + + pkscript, err := txscript.PayToAddrScript(parsedAddr) + if err != nil { + return fmt.Errorf("failed to create pkscript: %s", err) + } + + // estimate the bump tx vsize + weightEstimator := &input.TxWeightEstimator{} + + switch txscript.GetScriptClass(pkscript) { + case txscript.PubKeyHashTy: + weightEstimator.AddP2PKHOutput() + case txscript.WitnessV0PubKeyHashTy: + weightEstimator.AddP2WKHOutput() + case txscript.WitnessV1TaprootTy: + weightEstimator.AddP2TROutput() + default: + return fmt.Errorf("unknown script class: %v", txscript.GetScriptClass(pkscript)) + } + + inputs := make([]*wire.OutPoint, 0) + nSequences := make([]uint32, 0) + + outputsValue := int64(0) + for outpoint, output := range outputsToBump { + outputsValue += output.Value + inputs = append(inputs, &outpoint) + nSequences = append(nSequences, wire.MaxTxInSequenceNum) + scriptClass := txscript.GetScriptClass(output.PkScript) + switch txscript.ScriptClass(scriptClass) { + case txscript.PubKeyHashTy: + weightEstimator.AddP2PKHInput() + case txscript.WitnessV0PubKeyHashTy: + weightEstimator.AddP2WKHInput() + case txscript.WitnessV1TaprootTy: + weightEstimator.AddTaprootKeySpendInput(txscript.SigHashDefault) + default: + return fmt.Errorf("unknown script class: %v", scriptClass) + } + } + + pkgSize := int64(weightEstimator.VSize()) + for _, pkgTx := range txsPkg { + pkgSize += mempool.GetTxVirtualSize(pkgTx.tx) + } + + feeRate := s.wallet.FeeRate(ctx) + fees := feeRate.FeeForVSize(lntypes.VByte(pkgSize)) + profit := outputsValue - int64(fees.ToUnit(btcutil.AmountSatoshi)) + + dust, err := s.wallet.GetDustAmount(ctx) + if err != nil { + return fmt.Errorf("failed to get dust amount: %s", err) + } + + if profit <= int64(dust) { + log.Infof("profit is less than dust, not worth to broadcast package") return nil } - connectorTxid, connectorVout, err := s.getNextConnector(ctx, *round) + // create the bump tx = spend forfeit input and add the profit output + bumpTx, err := psbt.New( + inputs, + []*wire.TxOut{{PkScript: pkscript, Value: profit}}, + 2, + 0, + nSequences, + ) + if err != nil { + return fmt.Errorf("failed to create bump tx: %s", err) + } + + updater, err := psbt.NewUpdater(bumpTx) + if err != nil { + return fmt.Errorf("failed to create updater for bump tx: %s", err) + } + + for idx, input := range updater.Upsbt.UnsignedTx.TxIn { + output, ok := outputsToBump[input.PreviousOutPoint] + if !ok { + return fmt.Errorf("output to bump not found for input %s", input.PreviousOutPoint) + } + + if err := updater.AddInWitnessUtxo(output, idx); err != nil { + return fmt.Errorf("failed to add in witness utxo to bump tx: %s", err) + } + } + + bumpTxBase64, err := bumpTx.B64Encode() if err != nil { - return fmt.Errorf("failed to get next connector: %s", err) + return fmt.Errorf("failed to encode bump tx: %s", err) } - log.Debugf("found next connector %s:%d", connectorTxid, connectorVout) + signedBumpTx, err := s.wallet.SignTransaction(ctx, bumpTxBase64, true) + if err != nil { + return fmt.Errorf("failed to sign bump tx: %s", err) + } + + log.Debugf("bump tx %s signed", bumpTx.UnsignedTx.TxID()) + + // broadcast the packages and the bump tx + for _, pkgTx := range txsPkg { + // skip connector transactions, they are already broadcasted + if pkgTx.isConnector { + continue + } + + var buffer bytes.Buffer + if err := pkgTx.tx.MsgTx().Serialize(&buffer); err != nil { + return fmt.Errorf("failed to serialize tx: %s", err) + } + + txHex := hex.EncodeToString(buffer.Bytes()) + + txid, err := s.wallet.BroadcastTransaction(ctx, txHex) + if err != nil { + log.WithError(err).Debugf("failed to broadcast tx: %s", err) + continue + } + + log.Debugf("tx %s broadcasted", txid) + } + + txid, err := s.wallet.BroadcastTransaction(ctx, signedBumpTx) + if err != nil { + return fmt.Errorf("failed to broadcast bump tx: %s", err) + } + + log.Debugf("bump tx %s broadcasted", txid) + + return nil +} + +func (s *covenantlessService) broadcastRedeemTx(ctx context.Context, tx *btcutil.Tx) error { + // First broadcast the redeem transaction + var buffer bytes.Buffer + if err := tx.MsgTx().Serialize(&buffer); err != nil { + return fmt.Errorf("failed to serialize redeem tx: %s", err) + } + + redeemTxHex := hex.EncodeToString(buffer.Bytes()) + redeemTxid, err := s.wallet.BroadcastTransaction(ctx, redeemTxHex) + if err != nil { + return fmt.Errorf("failed to broadcast redeem tx: %s", err) + } + + log.Debugf("redeem tx %s broadcasted", redeemTxid) + + // Find the anchor output in the redeem tx + var anchorOutput *wire.TxOut + var anchorIndex uint32 + for i, out := range tx.MsgTx().TxOut { + if bytes.Equal(out.PkScript, bitcointree.ANCHOR_PKSCRIPT) { + anchorOutput = out + anchorIndex = uint32(i) + break + } + } + + if anchorOutput == nil { + return fmt.Errorf("anchor output not found in redeem tx") + } + + addrs, err := s.wallet.DeriveAddresses(ctx, 1) + if err != nil { + return fmt.Errorf("failed to derive address: %s", err) + } + + addr := addrs[0] + parsedAddr, err := btcutil.DecodeAddress(addr, s.chainParams()) + if err != nil { + return fmt.Errorf("failed to decode address: %s", err) + } + + pkscript, err := txscript.PayToAddrScript(parsedAddr) + if err != nil { + return fmt.Errorf("failed to create pkscript: %s", err) + } + + weightEstimator := &input.TxWeightEstimator{} + + weightEstimator.AddNestedP2WSHInput(2) // anchor input + switch txscript.GetScriptClass(pkscript) { + case txscript.PubKeyHashTy: + weightEstimator.AddP2PKHOutput() + case txscript.WitnessV0PubKeyHashTy: + weightEstimator.AddP2WKHOutput() + case txscript.WitnessV1TaprootTy: + weightEstimator.AddP2TROutput() + default: + return fmt.Errorf("unknown script class: %v", txscript.GetScriptClass(pkscript)) + } + + pkgSize := int64(weightEstimator.VSize()) + mempool.GetTxVirtualSize(tx) + + // Calculate fees for the bump transaction + feeRate := s.wallet.FeeRate(ctx) + fees := feeRate.FeeForVSize(lntypes.VByte(pkgSize)) + profit := anchorOutput.Value - int64(fees.ToUnit(btcutil.AmountSatoshi)) + + dust, err := s.wallet.GetDustAmount(ctx) + if err != nil { + return fmt.Errorf("failed to get dust amount: %s", err) + } + + if profit <= int64(dust) { + log.Info("profit from anchor output is less than dust, skipping bump tx") + return nil + } + + // Create output for the bump transaction + + // Create the bump transaction + redeemTxHash := tx.Hash() + anchorOutpoint := wire.NewOutPoint(redeemTxHash, anchorIndex) + + bumpTx, err := psbt.New( + []*wire.OutPoint{anchorOutpoint}, + []*wire.TxOut{{PkScript: pkscript, Value: profit}}, + 2, + 0, + []uint32{wire.MaxTxInSequenceNum}, + ) + if err != nil { + return fmt.Errorf("failed to create bump tx: %s", err) + } - forfeitTx, err := findForfeitTxBitcoin(round.ForfeitTxs, connectorTxid, connectorVout, vtxo.VtxoKey) + // Add witness UTXO info + updater, err := psbt.NewUpdater(bumpTx) if err != nil { - return fmt.Errorf("failed to find forfeit tx: %s", err) + return fmt.Errorf("failed to create updater for bump tx: %s", err) } - if err := s.wallet.LockConnectorUtxos(ctx, []ports.TxOutpoint{txOutpoint{connectorTxid, connectorVout}}); err != nil { - return fmt.Errorf("failed to lock connector utxos: %s", err) + if err := updater.AddInWitnessUtxo(anchorOutput, 0); err != nil { + return fmt.Errorf("failed to add witness utxo to bump tx: %s", err) } - signedForfeitTx, err := s.wallet.SignTransactionTapscript(ctx, forfeitTx, nil) + bumpTxBase64, err := bumpTx.B64Encode() if err != nil { - return fmt.Errorf("failed to sign forfeit tx: %s", err) + return fmt.Errorf("failed to encode bump tx: %s", err) } - forfeitTxHex, err := s.builder.FinalizeAndExtract(signedForfeitTx) + // Sign and broadcast the bump transaction + signedBumpTx, err := s.wallet.SignTransaction(ctx, bumpTxBase64, true) if err != nil { - return fmt.Errorf("failed to finalize forfeit tx: %s", err) + return fmt.Errorf("failed to sign bump tx: %s", err) } - forfeitTxid, err := s.wallet.BroadcastTransaction(ctx, forfeitTxHex) + bumpTxid, err := s.wallet.BroadcastTransaction(ctx, signedBumpTx) if err != nil { - return fmt.Errorf("failed to broadcast forfeit tx: %s", err) + return fmt.Errorf("failed to broadcast bump tx: %s", err) } - log.Debugf("broadcasted forfeit tx %s", forfeitTxid) + log.Debugf("bump tx %s broadcasted", bumpTxid) return nil } diff --git a/server/internal/core/domain/round_repo.go b/server/internal/core/domain/round_repo.go index 8864caf67..e3f2ca8c3 100644 --- a/server/internal/core/domain/round_repo.go +++ b/server/internal/core/domain/round_repo.go @@ -26,6 +26,7 @@ type VtxoRepository interface { SpendVtxos(ctx context.Context, vtxos []VtxoKey, txid string) error RedeemVtxos(ctx context.Context, vtxos []VtxoKey) error GetVtxos(ctx context.Context, vtxos []VtxoKey) ([]Vtxo, error) + GetVtxosByTxid(ctx context.Context, txid string) ([]Vtxo, error) GetVtxosForRound(ctx context.Context, txid string) ([]Vtxo, error) SweepVtxos(ctx context.Context, vtxos []VtxoKey) error GetAllVtxos(ctx context.Context, pubkey string) ([]Vtxo, []Vtxo, error) diff --git a/server/internal/core/ports/tx_builder.go b/server/internal/core/ports/tx_builder.go index d4fff63eb..ae502e56b 100644 --- a/server/internal/core/ports/tx_builder.go +++ b/server/internal/core/ports/tx_builder.go @@ -44,7 +44,7 @@ type TxBuilder interface { VerifyTapscriptPartialSigs(tx string) (valid bool, txid 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) - BuildAsyncPaymentTransactions( + BuildTxOOR( vtxosToSpend []domain.Vtxo, descriptors map[domain.VtxoKey]string, forfeitsLeaves map[domain.VtxoKey]chainhash.Hash, diff --git a/server/internal/core/ports/wallet.go b/server/internal/core/ports/wallet.go index 5d2de7516..17a99f0d3 100644 --- a/server/internal/core/ports/wallet.go +++ b/server/internal/core/ports/wallet.go @@ -32,6 +32,7 @@ type WalletService interface { BroadcastTransaction(ctx context.Context, txHex string) (string, error) WaitForSync(ctx context.Context, txid string) error EstimateFees(ctx context.Context, psbt string) (uint64, error) + FeeRate(ctx context.Context) chainfee.SatPerKVByte MinRelayFee(ctx context.Context, vbytes uint64) (uint64, error) MinRelayFeeRate(ctx context.Context) chainfee.SatPerKVByte ListConnectorUtxos(ctx context.Context, connectorAddress string) ([]TxInput, error) diff --git a/server/internal/infrastructure/db/badger/vtxo_repo.go b/server/internal/infrastructure/db/badger/vtxo_repo.go index 2f51a0cb1..6bdaddda8 100644 --- a/server/internal/infrastructure/db/badger/vtxo_repo.go +++ b/server/internal/infrastructure/db/badger/vtxo_repo.go @@ -88,6 +88,13 @@ func (r *vtxoRepository) GetVtxos( return vtxos, nil } +func (r *vtxoRepository) GetVtxosByTxid( + ctx context.Context, txid string, +) ([]domain.Vtxo, error) { + query := badgerhold.Where("Txid").Eq(txid) + return r.findVtxos(ctx, query) +} + func (r *vtxoRepository) GetVtxosForRound( ctx context.Context, txid string, ) ([]domain.Vtxo, error) { diff --git a/server/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go b/server/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go index 1f72aebb6..c9604e367 100644 --- a/server/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go +++ b/server/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go @@ -675,6 +675,52 @@ func (q *Queries) SelectVtxosByPoolTxid(ctx context.Context, poolTx string) ([]S return items, nil } +const selectVtxosByTxid = `-- name: SelectVtxosByTxid :many +SELECT vtxo.txid, vtxo.vout, vtxo.pubkey, vtxo.amount, vtxo.pool_tx, vtxo.spent_by, vtxo.spent, vtxo.redeemed, vtxo.swept, vtxo.expire_at, vtxo.created_at, vtxo.payment_id, vtxo.redeem_tx FROM vtxo +WHERE txid = ? +` + +type SelectVtxosByTxidRow struct { + Vtxo Vtxo +} + +func (q *Queries) SelectVtxosByTxid(ctx context.Context, txid string) ([]SelectVtxosByTxidRow, error) { + rows, err := q.db.QueryContext(ctx, selectVtxosByTxid, txid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SelectVtxosByTxidRow + for rows.Next() { + var i SelectVtxosByTxidRow + if err := rows.Scan( + &i.Vtxo.Txid, + &i.Vtxo.Vout, + &i.Vtxo.Pubkey, + &i.Vtxo.Amount, + &i.Vtxo.PoolTx, + &i.Vtxo.SpentBy, + &i.Vtxo.Spent, + &i.Vtxo.Redeemed, + &i.Vtxo.Swept, + &i.Vtxo.ExpireAt, + &i.Vtxo.CreatedAt, + &i.Vtxo.PaymentID, + &i.Vtxo.RedeemTx, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateVtxoExpireAt = `-- name: UpdateVtxoExpireAt :exec UPDATE vtxo SET expire_at = ? WHERE txid = ? AND vout = ? ` diff --git a/server/internal/infrastructure/db/sqlite/sqlc/query.sql b/server/internal/infrastructure/db/sqlite/sqlc/query.sql index 1df332d22..4ada771b4 100644 --- a/server/internal/infrastructure/db/sqlite/sqlc/query.sql +++ b/server/internal/infrastructure/db/sqlite/sqlc/query.sql @@ -145,6 +145,10 @@ WHERE txid = ? AND vout = ?; SELECT sqlc.embed(vtxo) FROM vtxo WHERE pool_tx = ?; +-- name: SelectVtxosByTxid :many +SELECT sqlc.embed(vtxo) FROM vtxo +WHERE txid = ?; + -- name: MarkVtxoAsRedeemed :exec UPDATE vtxo SET redeemed = true WHERE txid = ? AND vout = ?; diff --git a/server/internal/infrastructure/db/sqlite/vtxo_repo.go b/server/internal/infrastructure/db/sqlite/vtxo_repo.go index f61b3ed80..2d32b5353 100644 --- a/server/internal/infrastructure/db/sqlite/vtxo_repo.go +++ b/server/internal/infrastructure/db/sqlite/vtxo_repo.go @@ -149,6 +149,20 @@ func (v *vxtoRepository) GetVtxos(ctx context.Context, outpoints []domain.VtxoKe return vtxos, nil } +func (v *vxtoRepository) GetVtxosByTxid(ctx context.Context, txid string) ([]domain.Vtxo, error) { + res, err := v.querier.SelectVtxosByTxid(ctx, txid) + if err != nil { + return nil, err + } + + rows := make([]queries.Vtxo, 0, len(res)) + for _, row := range res { + rows = append(rows, row.Vtxo) + } + + return readRows(rows) +} + func (v *vxtoRepository) GetVtxosForRound(ctx context.Context, txid string) ([]domain.Vtxo, error) { res, err := v.querier.SelectVtxosByPoolTxid(ctx, txid) if err != nil { diff --git a/server/internal/infrastructure/tx-builder/covenant/builder.go b/server/internal/infrastructure/tx-builder/covenant/builder.go index 3fa11c371..794446f23 100644 --- a/server/internal/infrastructure/tx-builder/covenant/builder.go +++ b/server/internal/infrastructure/tx-builder/covenant/builder.go @@ -362,7 +362,7 @@ func (b *txBuilder) FindLeaves( return foundLeaves, nil } -func (b *txBuilder) BuildAsyncPaymentTransactions( +func (b *txBuilder) BuildTxOOR( _ []domain.Vtxo, _ map[domain.VtxoKey]string, _ map[domain.VtxoKey]chainhash.Hash, diff --git a/server/internal/infrastructure/tx-builder/covenantless/builder.go b/server/internal/infrastructure/tx-builder/covenantless/builder.go index 4536579cd..86d5ea7e0 100644 --- a/server/internal/infrastructure/tx-builder/covenantless/builder.go +++ b/server/internal/infrastructure/tx-builder/covenantless/builder.go @@ -19,9 +19,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/waddrmgr" "github.com/decred/dcrd/dcrec/secp256k1/v4" - "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) @@ -238,12 +236,7 @@ func (b *txBuilder) BuildForfeitTxs( return nil, nil, err } - minRelayFeeConnectorTx, err := b.minRelayFeeConnectorTx() - if err != nil { - return nil, nil, err - } - - connectorTxs, err := b.createConnectors(poolTx, payments, connectorPkScript, minRelayFeeConnectorTx) + connectorTxs, err := b.createConnectors(poolTx, payments, connectorPkScript) if err != nil { return nil, nil, err } @@ -284,9 +277,16 @@ func (b *txBuilder) BuildRoundTx( return "", nil, "", err } + var dustAmount uint64 + if !isOnchainOnly(payments) { + dustAmount, err = b.wallet.GetDustAmount(context.Background()) + if err != nil { + return "", nil, "", err + } + sharedOutputScript, sharedOutputAmount, err = bitcointree.CraftSharedOutput( - cosigners, aspPubkey, receivers, feeAmount, b.roundLifetime, + cosigners, aspPubkey, receivers, feeAmount, dustAmount, b.roundLifetime, ) if err != nil { return @@ -317,7 +317,7 @@ func (b *txBuilder) BuildRoundTx( } congestionTree, err = bitcointree.CraftCongestionTree( - initialOutpoint, cosigners, aspPubkey, receivers, feeAmount, b.roundLifetime, + initialOutpoint, cosigners, aspPubkey, receivers, feeAmount, dustAmount, b.roundLifetime, ) if err != nil { return @@ -401,7 +401,7 @@ func (b *txBuilder) FindLeaves(congestionTree tree.CongestionTree, fromtxid stri return foundLeaves, nil } -func (b *txBuilder) BuildAsyncPaymentTransactions( +func (b *txBuilder) BuildTxOOR( vtxos []domain.Vtxo, descriptors map[domain.VtxoKey]string, forfeitsLeaves map[domain.VtxoKey]chainhash.Hash, @@ -411,12 +411,23 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( return "", fmt.Errorf("missing vtxos") } + if len(receivers) <= 0 { + return "", fmt.Errorf("missing receivers") + } + + if len(descriptors) != len(vtxos) { + return "", fmt.Errorf("missing descriptors") + } + + if len(forfeitsLeaves) != len(vtxos) { + return "", fmt.Errorf("missing forfeits leaves") + } + ins := make([]*wire.OutPoint, 0, len(vtxos)) outs := make([]*wire.TxOut, 0, len(receivers)) witnessUtxos := make(map[int]*wire.TxOut) tapscripts := make(map[int]*psbt.TaprootTapLeafScript) - redeemTxWeightEstimator := &input.TxWeightEstimator{} for index, vtxo := range vtxos { desc, ok := descriptors[vtxo.VtxoKey] if !ok { @@ -473,32 +484,14 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( LeafVersion: txscript.BaseLeafVersion, } - ctrlBlock, err := txscript.ParseControlBlock(leafProof.ControlBlock) - if err != nil { - return "", err - } - - redeemTxWeightEstimator.AddTapscriptInput(64*2+40, &waddrmgr.Tapscript{ - RevealedScript: leafProof.Script, - ControlBlock: ctrlBlock, - }) - ins = append(ins, vtxoOutpoint) } - for range receivers { - redeemTxWeightEstimator.AddP2TROutput() - } - - redeemTxMinRelayFee, err := b.wallet.MinRelayFee(context.Background(), uint64(redeemTxWeightEstimator.VSize())) + dustAmount, err := b.wallet.GetDustAmount(context.Background()) if err != nil { return "", err } - if redeemTxMinRelayFee >= receivers[len(receivers)-1].Amount { - return "", fmt.Errorf("redeem tx fee is higher than the amount of the change receiver") - } - for i, receiver := range receivers { if receiver.IsOnchain() { return "", fmt.Errorf("receiver %d is onchain", i) @@ -514,30 +507,42 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( return "", err } - newVtxoScript, err := common.P2TRScript(pubkey) + receiverPkScript, err := common.P2TRScript(pubkey) if err != nil { return "", err } - // Deduct the min relay fee from the very last receiver which is supposed - // to be the change in case it's not a send-all. value := receiver.Amount + if value <= dustAmount { + return "", fmt.Errorf("receiver amount smaller than dust") + } + + // to be the change in case it's not a send-all. if i == len(receivers)-1 { - value -= redeemTxMinRelayFee + value -= uint64(bitcointree.ANCHOR_AMOUNT) + if value <= dustAmount { + return "", fmt.Errorf("change amount is dust amount") + } } + outs = append(outs, &wire.TxOut{ Value: int64(value), - PkScript: newVtxoScript, + PkScript: receiverPkScript, }) } + outs = append(outs, &wire.TxOut{ + Value: bitcointree.ANCHOR_AMOUNT, + PkScript: bitcointree.ANCHOR_PKSCRIPT, + }) + sequences := make([]uint32, len(ins)) for i := range sequences { sequences[i] = wire.MaxTxInSequenceNum } redeemPtx, err := psbt.New( - ins, outs, 2, 0, sequences, + ins, outs, 3, 0, sequences, ) if err != nil { return "", err @@ -582,11 +587,6 @@ func (b *txBuilder) createRoundTx( return nil, err } - connectorMinRelayFee, err := b.minRelayFeeConnectorTx() - if err != nil { - return nil, err - } - dustLimit, err := b.wallet.GetDustAmount(context.Background()) if err != nil { return nil, err @@ -595,10 +595,7 @@ func (b *txBuilder) createRoundTx( connectorAmount := dustLimit nbOfInputs := countSpentVtxos(payments) - connectorsAmount := (connectorAmount + connectorMinRelayFee) * nbOfInputs - if nbOfInputs > 1 { - connectorsAmount -= connectorMinRelayFee - } + connectorsAmount := connectorAmount * nbOfInputs targetAmount := connectorsAmount outputs := make([]*wire.TxOut, 0) @@ -879,10 +876,6 @@ func (b *txBuilder) createRoundTx( return ptx, nil } -func (b *txBuilder) minRelayFeeConnectorTx() (uint64, error) { - return b.wallet.MinRelayFee(context.Background(), uint64(common.ConnectorTxSize)) -} - func (b *txBuilder) VerifyAndCombinePartialTx(dest string, src string) (string, error) { roundTx, err := psbt.NewFromRawBytes(strings.NewReader(dest), true) if err != nil { @@ -941,7 +934,7 @@ func (b *txBuilder) VerifyAndCombinePartialTx(dest string, src string) (string, } func (b *txBuilder) createConnectors( - poolTx string, payments []domain.Payment, connectorScript []byte, feeAmount uint64, + poolTx string, payments []domain.Payment, connectorScript []byte, ) ([]*psbt.Packet, error) { partialTx, err := psbt.NewFromRawBytes(strings.NewReader(poolTx), true) if err != nil { @@ -967,7 +960,7 @@ func (b *txBuilder) createConnectors( if numberOfConnectors == 1 { outputs := []*wire.TxOut{connectorOutput} - connectorTx, err := craftConnectorTx(previousInput, connectorScript, outputs, feeAmount) + connectorTx, err := craftConnectorTx(previousInput, connectorScript, outputs) if err != nil { return nil, err } @@ -975,23 +968,19 @@ func (b *txBuilder) createConnectors( return []*psbt.Packet{connectorTx}, nil } - totalConnectorAmount := (connectorAmount + feeAmount) * numberOfConnectors - if numberOfConnectors > 1 { - totalConnectorAmount -= feeAmount - } + totalConnectorAmount := connectorAmount * numberOfConnectors connectors := make([]*psbt.Packet, 0, numberOfConnectors-1) for i := uint64(0); i < numberOfConnectors-1; i++ { outputs := []*wire.TxOut{connectorOutput} totalConnectorAmount -= connectorAmount - totalConnectorAmount -= feeAmount if totalConnectorAmount > 0 { outputs = append(outputs, &wire.TxOut{ PkScript: connectorScript, Value: int64(totalConnectorAmount), }) } - connectorTx, err := craftConnectorTx(previousInput, connectorScript, outputs, feeAmount) + connectorTx, err := craftConnectorTx(previousInput, connectorScript, outputs) if err != nil { return nil, err } diff --git a/server/internal/infrastructure/tx-builder/covenantless/builder_test.go b/server/internal/infrastructure/tx-builder/covenantless/builder_test.go index da312d608..239af5150 100644 --- a/server/internal/infrastructure/tx-builder/covenantless/builder_test.go +++ b/server/internal/infrastructure/tx-builder/covenantless/builder_test.go @@ -47,7 +47,7 @@ func TestMain(m *testing.M) { wallet.On("MinRelayFee", mock.Anything, mock.Anything). Return(uint64(30), nil) wallet.On("GetDustAmount", mock.Anything). - Return(uint64(1000), nil) + Return(uint64(100), nil) wallet.On("GetForfeitAddress", mock.Anything). Return(forfeitAddress, nil) diff --git a/server/internal/infrastructure/tx-builder/covenantless/connectors.go b/server/internal/infrastructure/tx-builder/covenantless/connectors.go index 4075691d4..96e463601 100644 --- a/server/internal/infrastructure/tx-builder/covenantless/connectors.go +++ b/server/internal/infrastructure/tx-builder/covenantless/connectors.go @@ -6,12 +6,12 @@ import ( ) func craftConnectorTx( - input *wire.OutPoint, inputScript []byte, outputs []*wire.TxOut, feeAmount uint64, + input *wire.OutPoint, inputScript []byte, outputs []*wire.TxOut, ) (*psbt.Packet, error) { ptx, err := psbt.New( []*wire.OutPoint{input}, outputs, - 2, + 3, 0, []uint32{wire.MaxTxInSequenceNum}, ) @@ -24,7 +24,7 @@ func craftConnectorTx( return nil, err } - inputAmount := int64(feeAmount) + inputAmount := int64(0) for _, output := range outputs { inputAmount += output.Value } diff --git a/server/internal/infrastructure/wallet/btc-embedded/wallet.go b/server/internal/infrastructure/wallet/btc-embedded/wallet.go index 111b58322..672e59eed 100644 --- a/server/internal/infrastructure/wallet/btc-embedded/wallet.go +++ b/server/internal/infrastructure/wallet/btc-embedded/wallet.go @@ -392,12 +392,12 @@ func (s *service) Lock(_ context.Context, _ string) error { } func (s *service) BroadcastTransaction(ctx context.Context, txHex string) (string, error) { - if err := s.extraAPI.broadcast(txHex); err != nil { + var tx wire.MsgTx + if err := tx.Deserialize(hex.NewDecoder(strings.NewReader(txHex))); err != nil { return "", err } - var tx wire.MsgTx - if err := tx.Deserialize(hex.NewDecoder(strings.NewReader(txHex))); err != nil { + if err := s.extraAPI.broadcast(txHex); err != nil { return "", err } @@ -821,6 +821,14 @@ func (s *service) WaitForSync(ctx context.Context, txid string) error { } } +func (s *service) FeeRate(ctx context.Context) chainfee.SatPerKVByte { + fee, err := s.feeEstimator.EstimateFeePerKW(1) + if err != nil { + return s.MinRelayFeeRate(ctx) + } + return fee.FeePerKVByte() +} + func (s *service) MinRelayFeeRate(ctx context.Context) chainfee.SatPerKVByte { return s.feeEstimator.RelayFeePerKW().FeePerKVByte() } diff --git a/server/internal/infrastructure/wallet/liquid-standalone/service.go b/server/internal/infrastructure/wallet/liquid-standalone/service.go index 0c4837c4a..365b58eb1 100644 --- a/server/internal/infrastructure/wallet/liquid-standalone/service.go +++ b/server/internal/infrastructure/wallet/liquid-standalone/service.go @@ -8,6 +8,7 @@ import ( pb "github.com/ark-network/ark/api-spec/protobuf/gen/ocean/v1" "github.com/ark-network/ark/server/internal/core/domain" "github.com/ark-network/ark/server/internal/core/ports" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" log "github.com/sirupsen/logrus" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -159,6 +160,10 @@ func (s *service) GetDustAmount(ctx context.Context) (uint64, error) { return 450, nil // constant on liquid cause fees are not subject to huge changes } +func (s *service) FeeRate(ctx context.Context) chainfee.SatPerKVByte { + return chainfee.SatPerKVByte(1000) +} + func (s *service) listenToNotifications() { s.isListening = true defer func() { diff --git a/server/test/e2e/covenantless/e2e_test.go b/server/test/e2e/covenantless/e2e_test.go index 685c3761e..3b918cc58 100644 --- a/server/test/e2e/covenantless/e2e_test.go +++ b/server/test/e2e/covenantless/e2e_test.go @@ -3,10 +3,12 @@ package e2e_test import ( "bytes" "context" + "encoding/hex" "encoding/json" "fmt" "net/http" "os" + "strings" "testing" "time" @@ -19,6 +21,14 @@ import ( "github.com/ark-network/ark/pkg/client-sdk/store" "github.com/ark-network/ark/pkg/client-sdk/types" utils "github.com/ark-network/ark/server/test/e2e" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/stretchr/testify/require" ) @@ -142,6 +152,140 @@ func TestUnilateralExit(t *testing.T) { require.NotZero(t, lockedBalance) } +func TestUnilateralExitWithAnchorSpend(t *testing.T) { + ctx := context.Background() + sdkClient, grpcClient := setupArkSDK(t) + defer grpcClient.Close() + + _, boardingAddress, err := sdkClient.Receive(ctx) + require.NoError(t, err) + + _, err = utils.RunCommand("nigiri", "faucet", boardingAddress) + require.NoError(t, err) + + time.Sleep(5 * time.Second) + + roundId, err := sdkClient.Settle(ctx) + require.NoError(t, err) + + time.Sleep(5 * time.Second) + + vtxos, _, err := sdkClient.ListVtxos(ctx) + require.NoError(t, err) + require.NotEmpty(t, vtxos) + + var vtxo client.Vtxo + + for _, v := range vtxos { + if v.RoundTxid == roundId { + vtxo = v + break + } + } + require.NotEmpty(t, vtxo) + + err = sdkClient.UnilateralRedeem(ctx) + require.NoError(t, err) + vtxoHash, err := chainhash.NewHashFromStr(vtxo.Txid) + require.NoError(t, err) + + seckey, err := secp256k1.GeneratePrivateKey() + require.NoError(t, err) + + addr, err := btcutil.NewAddressTaproot(schnorr.SerializePubKey(seckey.PubKey()), &chaincfg.RegressionNetParams) + require.NoError(t, err) + + txid, err := utils.RunCommand("nigiri", "faucet", addr.String()) + require.NoError(t, err) + + txid = strings.TrimSpace(txid) + txid = txid[6:] + + time.Sleep(5 * time.Second) + + expl := explorer.NewExplorer("http://localhost:3000", common.BitcoinRegTest) + + faucetTxHex, err := expl.GetTxHex(txid) + require.NoError(t, err) + + var faucetTx wire.MsgTx + err = faucetTx.Deserialize(hex.NewDecoder(strings.NewReader(faucetTxHex))) + require.NoError(t, err) + + pkscript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + var output *wire.TxOut + var outputIndex int + for i, out := range faucetTx.TxOut { + if bytes.Equal(out.PkScript, pkscript) { + output = out + outputIndex = i + break + } + } + require.NotNil(t, output) + + // the anchor must be spendable right now so the user can bump the fees if needed + + ptx, err := psbt.New( + []*wire.OutPoint{{ + Hash: *vtxoHash, + Index: vtxo.VOut + 1, + }, { + Hash: faucetTx.TxHash(), + Index: uint32(outputIndex), + }}, + []*wire.TxOut{{ + Value: 1_0000_0000 - 10_0000, + PkScript: pkscript, + }}, + 2, + 0, + []uint32{wire.MaxTxInSequenceNum, wire.MaxTxInSequenceNum}, + ) + require.NoError(t, err) + + prevouts := make(map[wire.OutPoint]*wire.TxOut) + + txhex, err := expl.GetTxHex(vtxo.Txid) + require.NoError(t, err) + + var vtxoTx wire.MsgTx + err = vtxoTx.Deserialize(hex.NewDecoder(strings.NewReader(txhex))) + require.NoError(t, err) + + prevouts[ptx.UnsignedTx.TxIn[0].PreviousOutPoint] = vtxoTx.TxOut[vtxo.VOut+1] + prevouts[ptx.UnsignedTx.TxIn[1].PreviousOutPoint] = output + + prevoutFetcher := txscript.NewMultiPrevOutFetcher(prevouts) + + preimage, err := txscript.CalcTaprootSignatureHash( + txscript.NewTxSigHashes(ptx.UnsignedTx, prevoutFetcher), + txscript.SigHashDefault, + ptx.UnsignedTx, + 1, + prevoutFetcher, + ) + require.NoError(t, err) + + sig, err := schnorr.Sign(seckey, preimage[:]) + require.NoError(t, err) + + unsignedTx := ptx.UnsignedTx + unsignedTx.TxIn[0].Witness = [][]byte{{txscript.OP_TRUE}} + unsignedTx.TxIn[1].Witness = [][]byte{sig.Serialize()} + + var signedTx bytes.Buffer + + err = unsignedTx.Serialize(&signedTx) + require.NoError(t, err) + + txhex = hex.EncodeToString(signedTx.Bytes()) + _, err = expl.Broadcast(txhex) + require.NoError(t, err) +} + func TestCollaborativeExit(t *testing.T) { var receive utils.ArkReceive receiveStr, err := runClarkCommand("receive") @@ -219,7 +363,7 @@ func TestReactToAsyncSpentVtxosRedemption(t *testing.T) { offchainAddress, boardingAddress, err := sdkClient.Receive(ctx) require.NoError(t, err) - _, err = utils.RunCommand("nigiri", "faucet", boardingAddress) + _, err = utils.RunCommand("nigiri", "faucet", boardingAddress, "0.00003000") require.NoError(t, err) time.Sleep(5 * time.Second) @@ -227,10 +371,12 @@ func TestReactToAsyncSpentVtxosRedemption(t *testing.T) { roundId, err := sdkClient.Settle(ctx) require.NoError(t, err) + time.Sleep(3 * time.Second) + err = utils.GenerateBlock() require.NoError(t, err) - _, err = sdkClient.SendOffChain(ctx, false, []arksdk.Receiver{arksdk.NewBitcoinReceiver(offchainAddress, 1000)}) + _, err = sdkClient.SendAsync(ctx, false, []arksdk.Receiver{arksdk.NewBitcoinReceiver(offchainAddress, 1000)}) require.NoError(t, err) _, err = sdkClient.Settle(ctx) @@ -245,7 +391,7 @@ func TestReactToAsyncSpentVtxosRedemption(t *testing.T) { var vtxo client.Vtxo for _, v := range spentVtxos { - if v.RoundTxid == roundId { + if v.RoundTxid == roundId && !v.IsOOR { vtxo = v break }