From 55bd772560fae2832fc40aed5ff028226d44ba20 Mon Sep 17 00:00:00 2001 From: louisinger Date: Tue, 24 Sep 2024 17:39:53 +0200 Subject: [PATCH 1/4] add anchor outputs in async redeem tx and vtxo tree leaves --- common/bitcointree/builder.go | 42 +++-- common/bitcointree/musig2_test.go | 3 + common/bitcointree/psbt.go | 3 + pkg/client-sdk/covenantless_client.go | 22 ++- .../internal/core/application/covenantless.go | 4 + .../tx-builder/covenantless/builder.go | 27 ++- .../tx-builder/covenantless/builder_test.go | 2 +- server/test/e2e/covenantless/e2e_test.go | 162 ++++++++++++++++++ 8 files changed, 244 insertions(+), 21 deletions(-) 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 b2904a103..f4bba6120 100644 --- a/pkg/client-sdk/covenantless_client.go +++ b/pkg/client-sdk/covenantless_client.go @@ -1479,24 +1479,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 3f78d4164..f163e7f4c 100644 --- a/server/internal/core/application/covenantless.go +++ b/server/internal/core/application/covenantless.go @@ -263,6 +263,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) isChange := err != nil diff --git a/server/internal/infrastructure/tx-builder/covenantless/builder.go b/server/internal/infrastructure/tx-builder/covenantless/builder.go index e08ce1e78..bd16a2523 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) BuildPoolTx( 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) BuildPoolTx( } congestionTree, err = bitcointree.CraftCongestionTree( - initialOutpoint, cosigners, aspPubkey, receivers, feeAmount, b.roundLifetime, + initialOutpoint, cosigners, aspPubkey, receivers, feeAmount, dustAmount, b.roundLifetime, ) if err != nil { return @@ -537,6 +544,8 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( redeemTxWeightEstimator.AddP2TROutput() } + redeemTxWeightEstimator.AddP2WSHOutput() // anchor output + redeemTxMinRelayFee, err := b.wallet.MinRelayFee(context.Background(), uint64(redeemTxWeightEstimator.VSize())) if err != nil { return nil, err @@ -546,6 +555,11 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( return nil, 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 nil, err + } + for i, receiver := range receivers { offchainScript, err := bitcointree.ParseVtxoScript(receiver.Descriptor) if err != nil { @@ -567,6 +581,10 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( value := receiver.Amount if i == len(receivers)-1 { value -= redeemTxMinRelayFee + value -= dustAmount // remove anchor output amount + if value <= dustAmount { + return nil, fmt.Errorf("change amount is dust amount") + } } outs = append(outs, &wire.TxOut{ Value: int64(value), @@ -574,6 +592,11 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( }) } + outs = append(outs, &wire.TxOut{ + Value: int64(dustAmount), + PkScript: bitcointree.ANCHOR_PKSCRIPT, + }) + sequences := make([]uint32, len(ins)) for i := range sequences { sequences[i] = wire.MaxTxInSequenceNum diff --git a/server/internal/infrastructure/tx-builder/covenantless/builder_test.go b/server/internal/infrastructure/tx-builder/covenantless/builder_test.go index 58b7c4f52..b09b45a1f 100644 --- a/server/internal/infrastructure/tx-builder/covenantless/builder_test.go +++ b/server/internal/infrastructure/tx-builder/covenantless/builder_test.go @@ -46,7 +46,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) pubkeyBytes, _ := hex.DecodeString(testingKey) pubkey, _ = secp256k1.ParsePubKey(pubkeyBytes) diff --git a/server/test/e2e/covenantless/e2e_test.go b/server/test/e2e/covenantless/e2e_test.go index 18bb1161c..335c3487d 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" @@ -18,6 +20,14 @@ import ( "github.com/ark-network/ark/pkg/client-sdk/redemption" inmemorystore "github.com/ark-network/ark/pkg/client-sdk/store/inmemory" 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" ) @@ -141,6 +151,158 @@ 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) + + err = utils.GenerateBlock() + require.NoError(t, err) + + _, 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) + + round, err := grpcClient.GetRound(ctx, vtxo.RoundTxid) + require.NoError(t, err) + + expl := explorer.NewExplorer("http://localhost:3000", common.BitcoinRegTest) + + branch, err := redemption.NewCovenantlessRedeemBranch(expl, round.Tree, vtxo) + require.NoError(t, err) + + txs, err := branch.RedeemPath() + require.NoError(t, err) + + for _, tx := range txs { + _, err := expl.Broadcast(tx) + 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) + + 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") From 3971a1a78d3f5355aa70e5e715e1378422cfa96d Mon Sep 17 00:00:00 2001 From: louisinger Date: Fri, 4 Oct 2024 17:22:40 +0200 Subject: [PATCH 2/4] remove anchor output in redeem tx --- .../infrastructure/tx-builder/covenantless/builder.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/server/internal/infrastructure/tx-builder/covenantless/builder.go b/server/internal/infrastructure/tx-builder/covenantless/builder.go index cfdd257d6..b1fbe1203 100644 --- a/server/internal/infrastructure/tx-builder/covenantless/builder.go +++ b/server/internal/infrastructure/tx-builder/covenantless/builder.go @@ -544,8 +544,6 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( redeemTxWeightEstimator.AddP2TROutput() } - redeemTxWeightEstimator.AddP2WSHOutput() // anchor output - redeemTxMinRelayFee, err := b.wallet.MinRelayFee(context.Background(), uint64(redeemTxWeightEstimator.VSize())) if err != nil { return nil, err @@ -581,7 +579,6 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( value := receiver.Amount if i == len(receivers)-1 { value -= redeemTxMinRelayFee - value -= dustAmount // remove anchor output amount if value <= dustAmount { return nil, fmt.Errorf("change amount is dust amount") } @@ -592,11 +589,6 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( }) } - outs = append(outs, &wire.TxOut{ - Value: int64(dustAmount), - PkScript: bitcointree.ANCHOR_PKSCRIPT, - }) - sequences := make([]uint32, len(ins)) for i := range sequences { sequences[i] = wire.MaxTxInSequenceNum From 7a8ccbfadc4f163160a0d149981a2f0936c527dd Mon Sep 17 00:00:00 2001 From: louisinger Date: Fri, 18 Oct 2024 12:02:44 +0200 Subject: [PATCH 3/4] use UnilateralRedeem in test --- server/test/e2e/covenantless/e2e_test.go | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/server/test/e2e/covenantless/e2e_test.go b/server/test/e2e/covenantless/e2e_test.go index 335c3487d..33815aaae 100644 --- a/server/test/e2e/covenantless/e2e_test.go +++ b/server/test/e2e/covenantless/e2e_test.go @@ -167,12 +167,6 @@ func TestUnilateralExitWithAnchorSpend(t *testing.T) { roundId, err := sdkClient.Claim(ctx) require.NoError(t, err) - err = utils.GenerateBlock() - require.NoError(t, err) - - _, err = sdkClient.Claim(ctx) - require.NoError(t, err) - time.Sleep(5 * time.Second) vtxos, _, err := sdkClient.ListVtxos(ctx) @@ -189,22 +183,8 @@ func TestUnilateralExitWithAnchorSpend(t *testing.T) { } require.NotEmpty(t, vtxo) - round, err := grpcClient.GetRound(ctx, vtxo.RoundTxid) - require.NoError(t, err) - - expl := explorer.NewExplorer("http://localhost:3000", common.BitcoinRegTest) - - branch, err := redemption.NewCovenantlessRedeemBranch(expl, round.Tree, vtxo) - require.NoError(t, err) - - txs, err := branch.RedeemPath() + err = sdkClient.UnilateralRedeem(ctx) require.NoError(t, err) - - for _, tx := range txs { - _, err := expl.Broadcast(tx) - require.NoError(t, err) - } - vtxoHash, err := chainhash.NewHashFromStr(vtxo.Txid) require.NoError(t, err) @@ -222,6 +202,8 @@ func TestUnilateralExitWithAnchorSpend(t *testing.T) { time.Sleep(5 * time.Second) + expl := explorer.NewExplorer("http://localhost:3000", common.BitcoinRegTest) + faucetTxHex, err := expl.GetTxHex(txid) require.NoError(t, err) From 0786457809fc536bb6467e0d996a85973aa7fb80 Mon Sep 17 00:00:00 2001 From: louisinger Date: Fri, 18 Oct 2024 12:14:09 +0200 Subject: [PATCH 4/4] fix covenantless/builder.go --- .../infrastructure/tx-builder/covenantless/builder.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/internal/infrastructure/tx-builder/covenantless/builder.go b/server/internal/infrastructure/tx-builder/covenantless/builder.go index b070e72c6..bb22549d5 100644 --- a/server/internal/infrastructure/tx-builder/covenantless/builder.go +++ b/server/internal/infrastructure/tx-builder/covenantless/builder.go @@ -506,7 +506,7 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( dustAmount, err := b.wallet.GetDustAmount(context.Background()) if err != nil { - return nil, err + return "", err } for i, receiver := range receivers { @@ -531,7 +531,7 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( if i == len(receivers)-1 { value -= redeemTxMinRelayFee if value <= dustAmount { - return nil, fmt.Errorf("change amount is dust amount") + return "", fmt.Errorf("change amount is dust amount") } } outs = append(outs, &wire.TxOut{