diff --git a/common/bitcointree/builder.go b/common/bitcointree/builder.go index 44327c220..f01f48fcc 100644 --- a/common/bitcointree/builder.go +++ b/common/bitcointree/builder.go @@ -15,8 +15,12 @@ import ( // 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 int64, + cosigners []*secp256k1.PublicKey, + aspPubkey *secp256k1.PublicKey, + receivers []Receiver, + feeSatsPerNode, + dustAmount uint64, + roundLifetime int64, ) ([]byte, int64, error) { aggregatedKey, _, err := createAggregatedKeyWithSweep( cosigners, aspPubkey, roundLifetime, @@ -25,7 +29,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 } @@ -42,8 +46,13 @@ func CraftSharedOutput( // CraftCongestionTree creates all the tree's transactions func CraftCongestionTree( - initialInput *wire.OutPoint, cosigners []*secp256k1.PublicKey, aspPubkey *secp256k1.PublicKey, receivers []Receiver, - feeSatsPerNode uint64, roundLifetime int64, + initialInput *wire.OutPoint, + cosigners []*secp256k1.PublicKey, + aspPubkey *secp256k1.PublicKey, + receivers []Receiver, + feeSatsPerNode, + dustAmount uint64, + roundLifetime int64, ) (tree.CongestionTree, error) { aggregatedKey, sweepTapLeaf, err := createAggregatedKeyWithSweep( cosigners, aspPubkey, roundLifetime, @@ -52,7 +61,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 } @@ -110,6 +119,7 @@ type node interface { type leaf struct { vtxoScript VtxoScript amount int64 + dustAmount int64 } type branch struct { @@ -152,12 +162,22 @@ func (l *leaf) getOutputs() ([]*wire.TxOut, error) { return nil, err } - output := &wire.TxOut{ - Value: l.amount, + vtxoOutputAmount := l.amount - l.dustAmount + if vtxoOutputAmount <= l.dustAmount { + return nil, fmt.Errorf("vtxo output amount must be greater than dust amount") + } + + vtxoOutput := &wire.TxOut{ + Value: l.amount - l.dustAmount, PkScript: script, } - return []*wire.TxOut{output}, nil + anchorOutput := &wire.TxOut{ + Value: l.dustAmount, + PkScript: ANCHOR_PKSCRIPT, + } + + return []*wire.TxOut{vtxoOutput, anchorOutput}, nil } func (b *branch) getOutputs() ([]*wire.TxOut, error) { @@ -247,7 +267,8 @@ func createRootNode( aggregatedKey *musig2.AggregateKey, cosigners []*secp256k1.PublicKey, receivers []Receiver, - feeSatsPerNode uint64, + feeSatsPerNode, + dustAmount uint64, ) (root node, err error) { if len(receivers) == 0 { return nil, fmt.Errorf("no receivers provided") @@ -258,6 +279,7 @@ func createRootNode( leafNode := &leaf{ vtxoScript: r.Script, amount: int64(r.Amount), + dustAmount: int64(dustAmount), } nodes = append(nodes, leafNode) } diff --git a/common/bitcointree/musig2_test.go b/common/bitcointree/musig2_test.go index 2ba185960..351044e04 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, asp.PubKey()), minRelayFee, + dustAmount, lifetime, ) require.NoError(t, err) @@ -60,6 +62,7 @@ func TestRoundTripSignTree(t *testing.T) { asp.PubKey(), castReceivers(f.Receivers, asp.PubKey()), minRelayFee, + dustAmount, lifetime, ) require.NoError(t, err) diff --git a/common/bitcointree/psbt.go b/common/bitcointree/psbt.go index 379f422f6..a485bf4d0 100644 --- a/common/bitcointree/psbt.go +++ b/common/bitcointree/psbt.go @@ -11,6 +11,9 @@ var ( COSIGNER_PSBT_KEY_PREFIX = []byte("cosigner") ) +// 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} + 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 6f9ebdaf3..ab09f7cc5 100644 --- a/pkg/client-sdk/covenantless_client.go +++ b/pkg/client-sdk/covenantless_client.go @@ -1886,24 +1886,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:], schnorr.SerializePubKey(outputTapKey), - ) { - 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:], schnorr.SerializePubKey(outputTapKey)) { + 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 a0a736b2a..90415d133 100644 --- a/server/internal/core/application/covenantless.go +++ b/server/internal/core/application/covenantless.go @@ -260,6 +260,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 + } + desc := asyncPayData.receivers[outIndex].Descriptor _, _, _, _, err := descriptor.ParseReversibleVtxoDescriptor(desc) isPending := err == nil diff --git a/server/internal/infrastructure/tx-builder/covenantless/builder.go b/server/internal/infrastructure/tx-builder/covenantless/builder.go index 3fc7a3d6a..bb22549d5 100644 --- a/server/internal/infrastructure/tx-builder/covenantless/builder.go +++ b/server/internal/infrastructure/tx-builder/covenantless/builder.go @@ -281,9 +281,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 @@ -314,7 +321,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 @@ -497,6 +504,11 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( return "", fmt.Errorf("redeem tx fee is higher than the amount of the change receiver") } + dustAmount, err := b.wallet.GetDustAmount(context.Background()) + if err != nil { + return "", err + } + for i, receiver := range receivers { offchainScript, err := bitcointree.ParseVtxoScript(receiver.Descriptor) if err != nil { @@ -518,6 +530,9 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( value := receiver.Amount if i == len(receivers)-1 { value -= redeemTxMinRelayFee + if value <= dustAmount { + return "", fmt.Errorf("change amount is dust amount") + } } outs = append(outs, &wire.TxOut{ Value: int64(value), diff --git a/server/internal/infrastructure/tx-builder/covenantless/builder_test.go b/server/internal/infrastructure/tx-builder/covenantless/builder_test.go index 7411507d4..af5aca5c2 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/test/e2e/covenantless/e2e_test.go b/server/test/e2e/covenantless/e2e_test.go index d696319a1..41a378c75 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.Claim(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")