From 37827934318c9ff6ee2a040f0767a7faea18d134 Mon Sep 17 00:00:00 2001 From: Louis Singer <41042567+louisinger@users.noreply.github.com> Date: Mon, 16 Sep 2024 17:03:43 +0200 Subject: [PATCH] Add tests for adversarial scenarios (#300) * fix and test cheating scenario (malicious double spend) * test and fix async vtxo cheating cases * add replace statement in go.mod * Update server/internal/core/application/covenantless.go Co-authored-by: Pietralberto Mazza <18440657+altafan@users.noreply.github.com> Signed-off-by: Louis Singer <41042567+louisinger@users.noreply.github.com> * Update server/internal/infrastructure/wallet/btc-embedded/psbt.go Co-authored-by: Pietralberto Mazza <18440657+altafan@users.noreply.github.com> Signed-off-by: Louis Singer <41042567+louisinger@users.noreply.github.com> * Update server/test/e2e/covenant/e2e_test.go Co-authored-by: Pietralberto Mazza <18440657+altafan@users.noreply.github.com> Signed-off-by: Louis Singer <41042567+louisinger@users.noreply.github.com> * Update server/test/e2e/covenantless/e2e_test.go Co-authored-by: Pietralberto Mazza <18440657+altafan@users.noreply.github.com> Signed-off-by: Louis Singer <41042567+louisinger@users.noreply.github.com> * Update server/test/e2e/covenantless/e2e_test.go Co-authored-by: Pietralberto Mazza <18440657+altafan@users.noreply.github.com> Signed-off-by: Louis Singer <41042567+louisinger@users.noreply.github.com> * remove unused * [btc-embedded] fix GetNotificationChannel * [tx-builder] fix redeem transaction fee estimator * close grpc client in tests * [application] rework listentoscannerNotification * [application][covenant] fix getConnectorAmount * [tx-builder][covenant] get connector amount from wallet * e2e test sleep time * [liquid-standalone] ListConnectorUtxos: filter by script client side * fix Makefile integrationtest * do not use cache in integration tests * use VtxoKey as argument of findForfeitTxBitcoin * wrap adversarial test in t.Run * increaste test timeout * CI: setup go 1.23.1 * CI: revert go version * add replace in server/go.mod * Update server/internal/core/application/covenant.go Co-authored-by: Pietralberto Mazza <18440657+altafan@users.noreply.github.com> Signed-off-by: Louis Singer <41042567+louisinger@users.noreply.github.com> * remove replace * readd replace statement * fixes * go work sync * fix CI --------- Signed-off-by: Louis Singer <41042567+louisinger@users.noreply.github.com> Co-authored-by: Pietralberto Mazza <18440657+altafan@users.noreply.github.com> --- .github/workflows/ark.artifacts.yaml | 4 +- .github/workflows/ark.integration.yaml | 12 +- .github/workflows/ark.release.yaml | 4 +- .github/workflows/ark.unit.yaml | 8 +- docker-compose.clark.regtest.yml | 1 - pkg/client-sdk/ark_sdk.go | 4 +- pkg/client-sdk/covenant_client.go | 2 +- pkg/client-sdk/covenantless_client.go | 24 +- pkg/client-sdk/internal/utils/utils.go | 6 + .../utils => }/redemption/covenant_redeem.go | 0 .../redemption/covenantless_redeem.go | 0 .../wallet/singlekey/bitcoin_wallet.go | 16 +- server/Makefile | 3 +- server/internal/core/application/covenant.go | 189 +++++++------- .../internal/core/application/covenantless.go | 230 +++++++++++------- server/internal/core/ports/scanner.go | 2 +- server/internal/core/ports/tx_builder.go | 2 +- .../tx-builder/covenant/builder.go | 35 ++- .../tx-builder/covenant/connectors.go | 2 +- .../tx-builder/covenant/forfeit.go | 3 +- .../tx-builder/covenant/mocks_test.go | 6 +- .../tx-builder/covenantless/builder.go | 60 ++++- .../tx-builder/covenantless/mocks_test.go | 6 +- .../wallet/btc-embedded/psbt.go | 43 ++-- .../wallet/btc-embedded/wallet.go | 42 +++- .../wallet/liquid-standalone/account.go | 15 +- .../wallet/liquid-standalone/notification.go | 2 +- .../wallet/liquid-standalone/service.go | 18 +- .../wallet/liquid-standalone/transaction.go | 2 +- server/test/e2e/covenant/e2e_test.go | 83 +++++++ server/test/e2e/covenantless/e2e_test.go | 162 ++++++++++++ 31 files changed, 710 insertions(+), 276 deletions(-) rename pkg/client-sdk/{internal/utils => }/redemption/covenant_redeem.go (100%) rename pkg/client-sdk/{internal/utils => }/redemption/covenantless_redeem.go (100%) diff --git a/.github/workflows/ark.artifacts.yaml b/.github/workflows/ark.artifacts.yaml index 79762f0c3..6ff2413c4 100644 --- a/.github/workflows/ark.artifacts.yaml +++ b/.github/workflows/ark.artifacts.yaml @@ -12,9 +12,9 @@ jobs: uses: actions/checkout@v2 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: - go-version: 1.21.0 + go-version: 1.23.1 - name: Build binaries run: make build-all diff --git a/.github/workflows/ark.integration.yaml b/.github/workflows/ark.integration.yaml index 6278df994..28ac256af 100755 --- a/.github/workflows/ark.integration.yaml +++ b/.github/workflows/ark.integration.yaml @@ -2,7 +2,8 @@ name: ci_integration on: push: - branches: [master] + branches: + - master pull_request: branches: - master @@ -15,11 +16,12 @@ jobs: run: working-directory: ./server steps: - - uses: actions/setup-go@v3 - with: - go-version: ">1.17.2" - uses: actions/checkout@v3 - - run: go get -v -t -d ./... + - uses: actions/setup-go@v4 + with: + go-version: '>=1.23.1' + - name: Run go work sync + run: go work sync - name: Run Nigiri uses: vulpemventures/nigiri-github-action@v1 diff --git a/.github/workflows/ark.release.yaml b/.github/workflows/ark.release.yaml index 1cd56ad25..18355860d 100755 --- a/.github/workflows/ark.release.yaml +++ b/.github/workflows/ark.release.yaml @@ -16,9 +16,9 @@ jobs: uses: actions/checkout@v2 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: - go-version: 1.21.0 + go-version: 1.23.1 # Build binaries for all architectures - name: Build binaries diff --git a/.github/workflows/ark.unit.yaml b/.github/workflows/ark.unit.yaml index b942a8e2c..9df1852cc 100755 --- a/.github/workflows/ark.unit.yaml +++ b/.github/workflows/ark.unit.yaml @@ -2,10 +2,11 @@ name: ci_unit on: push: + branches: + - master paths: - 'server/**' - 'pkg/client-sdk/**' - branches: [master] pull_request: branches: - master @@ -56,7 +57,8 @@ jobs: uses: securego/gosec@master with: args: '-severity high -quiet -exclude=G115 ./...' - - run: go get -v -t -d ./... + - name: Run go work sync + run: go work sync - name: unit testing run: make test @@ -83,4 +85,4 @@ jobs: args: '-severity high -quiet -exclude=G115 ./...' - run: go get -v -t -d ./... - name: unit testing - run: make test + run: make test \ No newline at end of file diff --git a/docker-compose.clark.regtest.yml b/docker-compose.clark.regtest.yml index f7a153f6d..6c24f6a82 100644 --- a/docker-compose.clark.regtest.yml +++ b/docker-compose.clark.regtest.yml @@ -11,7 +11,6 @@ services: - ARK_LOG_LEVEL=5 - ARK_ROUND_LIFETIME=512 - ARK_TX_BUILDER_TYPE=covenantless - - ARK_MIN_RELAY_FEE=200 - ARK_ESPLORA_URL=http://chopsticks:3000 - ARK_BITCOIND_RPC_USER=admin1 - ARK_BITCOIND_RPC_PASS=123 diff --git a/pkg/client-sdk/ark_sdk.go b/pkg/client-sdk/ark_sdk.go index 040a58d6a..f44f62642 100644 --- a/pkg/client-sdk/ark_sdk.go +++ b/pkg/client-sdk/ark_sdk.go @@ -15,7 +15,7 @@ type ArkClient interface { Unlock(ctx context.Context, password string) error Lock(ctx context.Context, password string) error Balance(ctx context.Context, computeExpiryDetails bool) (*Balance, error) - Receive(ctx context.Context) (string, string, error) + Receive(ctx context.Context) (offchainAddr, boardingAddr string, err error) SendOnChain(ctx context.Context, receivers []Receiver) (string, error) SendOffChain( ctx context.Context, withExpiryCoinselect bool, receivers []Receiver, @@ -26,7 +26,7 @@ type ArkClient interface { ) (string, error) SendAsync(ctx context.Context, withExpiryCoinselect bool, receivers []Receiver) (string, error) Claim(ctx context.Context) (string, error) - ListVtxos(ctx context.Context) ([]client.Vtxo, []client.Vtxo, error) + ListVtxos(ctx context.Context) (spendable, spent []client.Vtxo, err error) GetTransactionHistory(ctx context.Context) ([]Transaction, error) Dump(ctx context.Context) (seed string, err error) } diff --git a/pkg/client-sdk/covenant_client.go b/pkg/client-sdk/covenant_client.go index 54013d322..f792a1301 100644 --- a/pkg/client-sdk/covenant_client.go +++ b/pkg/client-sdk/covenant_client.go @@ -17,7 +17,7 @@ import ( "github.com/ark-network/ark/pkg/client-sdk/client" "github.com/ark-network/ark/pkg/client-sdk/explorer" "github.com/ark-network/ark/pkg/client-sdk/internal/utils" - "github.com/ark-network/ark/pkg/client-sdk/internal/utils/redemption" + "github.com/ark-network/ark/pkg/client-sdk/redemption" "github.com/ark-network/ark/pkg/client-sdk/store" "github.com/ark-network/ark/pkg/client-sdk/wallet" "github.com/btcsuite/btcd/btcec/v2/schnorr" diff --git a/pkg/client-sdk/covenantless_client.go b/pkg/client-sdk/covenantless_client.go index f976e9fb6..f9faa6ab7 100644 --- a/pkg/client-sdk/covenantless_client.go +++ b/pkg/client-sdk/covenantless_client.go @@ -18,7 +18,7 @@ import ( "github.com/ark-network/ark/pkg/client-sdk/client" "github.com/ark-network/ark/pkg/client-sdk/explorer" "github.com/ark-network/ark/pkg/client-sdk/internal/utils" - "github.com/ark-network/ark/pkg/client-sdk/internal/utils/redemption" + "github.com/ark-network/ark/pkg/client-sdk/redemption" "github.com/ark-network/ark/pkg/client-sdk/store" "github.com/ark-network/ark/pkg/client-sdk/wallet" "github.com/btcsuite/btcd/btcec/v2/schnorr" @@ -975,6 +975,15 @@ func (a *covenantlessArkClient) handleRoundStream( var signerSession bitcointree.SignerSession + const ( + start = iota + roundSigningStarted + roundSigningNoncesGenerated + roundFinalization + ) + + step := start + for { select { case <-ctx.Done(): @@ -991,6 +1000,9 @@ func (a *covenantlessArkClient) handleRoundStream( return "", fmt.Errorf("round failed: %s", event.(client.RoundFailedEvent).Reason) case client.RoundSigningStartedEvent: pingStop() + if step != start { + continue + } log.Info("a round signing started") signerSession, err = a.handleRoundSigningStarted( ctx, roundEphemeralKey, event.(client.RoundSigningStartedEvent), @@ -998,8 +1010,12 @@ func (a *covenantlessArkClient) handleRoundStream( if err != nil { return "", err } + step++ continue case client.RoundSigningNoncesGeneratedEvent: + if step != roundSigningStarted { + continue + } pingStop() log.Info("round combined nonces generated") if err := a.handleRoundSigningNoncesGenerated( @@ -1007,8 +1023,12 @@ func (a *covenantlessArkClient) handleRoundStream( ); err != nil { return "", err } + step++ continue case client.RoundFinalizationEvent: + if step != roundSigningNoncesGenerated { + continue + } pingStop() log.Info("a round finalization started") @@ -1031,6 +1051,8 @@ func (a *covenantlessArkClient) handleRoundStream( log.Info("done.") log.Info("waiting for round finalization...") + step++ + continue } } } diff --git a/pkg/client-sdk/internal/utils/utils.go b/pkg/client-sdk/internal/utils/utils.go index dffb5fd5b..7b6c6aebd 100644 --- a/pkg/client-sdk/internal/utils/utils.go +++ b/pkg/client-sdk/internal/utils/utils.go @@ -8,6 +8,7 @@ import ( "fmt" "runtime/debug" "sort" + "sync" "github.com/ark-network/ark/common" "github.com/ark-network/ark/pkg/client-sdk/client" @@ -265,8 +266,13 @@ func DecryptAES128(encrypted, password []byte) ([]byte, error) { return plaintext, nil } +var lock = &sync.Mutex{} + // deriveKey derives a 32 byte array key from a custom passhprase func deriveKey(password, salt []byte) ([]byte, []byte, error) { + lock.Lock() + defer lock.Unlock() + if salt == nil { salt = make([]byte, 32) if _, err := rand.Read(salt); err != nil { diff --git a/pkg/client-sdk/internal/utils/redemption/covenant_redeem.go b/pkg/client-sdk/redemption/covenant_redeem.go similarity index 100% rename from pkg/client-sdk/internal/utils/redemption/covenant_redeem.go rename to pkg/client-sdk/redemption/covenant_redeem.go diff --git a/pkg/client-sdk/internal/utils/redemption/covenantless_redeem.go b/pkg/client-sdk/redemption/covenantless_redeem.go similarity index 100% rename from pkg/client-sdk/internal/utils/redemption/covenantless_redeem.go rename to pkg/client-sdk/redemption/covenantless_redeem.go diff --git a/pkg/client-sdk/wallet/singlekey/bitcoin_wallet.go b/pkg/client-sdk/wallet/singlekey/bitcoin_wallet.go index 0e09490c9..a79a99ebb 100644 --- a/pkg/client-sdk/wallet/singlekey/bitcoin_wallet.go +++ b/pkg/client-sdk/wallet/singlekey/bitcoin_wallet.go @@ -181,14 +181,16 @@ func (s *bitcoinWallet) SignTransaction( return "", fmt.Errorf("signature verification failed") } - updater.Upsbt.Inputs[i].TaprootScriptSpendSig = []*psbt.TaprootScriptSpendSig{ - { - XOnlyPubKey: schnorr.SerializePubKey(pubkey), - LeafHash: hash.CloneBytes(), - Signature: sig.Serialize(), - SigHash: txscript.SigHashDefault, - }, + if len(updater.Upsbt.Inputs[i].TaprootScriptSpendSig) == 0 { + updater.Upsbt.Inputs[i].TaprootScriptSpendSig = make([]*psbt.TaprootScriptSpendSig, 0) } + + updater.Upsbt.Inputs[i].TaprootScriptSpendSig = append(updater.Upsbt.Inputs[i].TaprootScriptSpendSig, &psbt.TaprootScriptSpendSig{ + XOnlyPubKey: schnorr.SerializePubKey(pubkey), + LeafHash: hash.CloneBytes(), + Signature: sig.Serialize(), + SigHash: txscript.SigHashDefault, + }) } } } diff --git a/server/Makefile b/server/Makefile index 3f50220da..86d6d4d6c 100755 --- a/server/Makefile +++ b/server/Makefile @@ -23,7 +23,8 @@ help: ## intergrationtest: runs integration tests integrationtest: @echo "Running integration tests..." - @go test -v -count=1 -race -timeout 200s github.com/ark-network/ark/server/test/e2e/... + @go test -v -count 1 -timeout 300s github.com/ark-network/ark/server/test/e2e/covenant + @go test -v -count 1 -timeout 300s github.com/ark-network/ark/server/test/e2e/covenantless ## lint: lint codebase lint: diff --git a/server/internal/core/application/covenant.go b/server/internal/core/application/covenant.go index 9def5f912..924972a4c 100644 --- a/server/internal/core/application/covenant.go +++ b/server/internal/core/application/covenant.go @@ -5,7 +5,6 @@ import ( "context" "encoding/hex" "fmt" - "strings" "sync" "time" @@ -15,7 +14,6 @@ import ( "github.com/ark-network/ark/server/internal/core/domain" "github.com/ark-network/ark/server/internal/core/ports" "github.com/btcsuite/btcd/btcec/v2/schnorr" - "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/decred/dcrd/dcrec/secp256k1/v4" log "github.com/sirupsen/logrus" @@ -613,87 +611,35 @@ func (s *covenantService) listenToScannerNotifications() { mutx := &sync.Mutex{} for vtxoKeys := range chVtxos { - go func(vtxoKeys map[string]ports.VtxoWithValue) { + go func(vtxoKeys map[string][]ports.VtxoWithValue) { vtxosRepo := s.repoManager.Vtxos() - roundRepo := s.repoManager.Rounds() - for _, v := range vtxoKeys { - // redeem - vtxos, err := vtxosRepo.GetVtxos(ctx, []domain.VtxoKey{v.VtxoKey}) - if err != nil { - log.WithError(err).Warn("failed to retrieve vtxos, skipping...") - continue - } - - vtxo := vtxos[0] - - if vtxo.Redeemed { - continue - } - - if err := s.repoManager.Vtxos().RedeemVtxos(ctx, []domain.VtxoKey{vtxo.VtxoKey}); err != nil { - log.WithError(err).Warn("failed to redeem vtxos, retrying...") - continue - } - log.Debugf("vtxo %s redeemed", vtxo.Txid) - - if !vtxo.Spent { - continue - } - - log.Debugf("fraud detected on vtxo %s", vtxo.Txid) - - round, err := roundRepo.GetRoundWithTxid(ctx, vtxo.SpentBy) - if err != nil { - log.WithError(err).Warn("failed to retrieve round") - continue - } - - mutx.Lock() - defer mutx.Unlock() - - connectorTxid, connectorVout, err := s.getNextConnector(ctx, *round) - if err != nil { - log.WithError(err).Warn("failed to retrieve next connector") - continue - } - - forfeitTx, err := findForfeitTxLiquid(round.ForfeitTxs, connectorTxid, connectorVout, vtxo.Txid) - if err != nil { - log.WithError(err).Warn("failed to retrieve forfeit tx") - continue - } - - if err := s.wallet.LockConnectorUtxos(ctx, []ports.TxOutpoint{txOutpoint{connectorTxid, connectorVout}}); err != nil { - log.WithError(err).Warn("failed to lock connector utxos") - continue - } - - signedForfeitTx, err := s.wallet.SignTransaction(ctx, forfeitTx, false) - if err != nil { - log.WithError(err).Warn("failed to sign connector input in forfeit tx") - continue - } - - signedForfeitTx, err = s.wallet.SignTransactionTapscript(ctx, signedForfeitTx, []int{1}) - if err != nil { - log.WithError(err).Warn("failed to sign vtxo input in forfeit tx") - continue - } - - forfeitTxHex, err := s.builder.FinalizeAndExtractForfeit(signedForfeitTx) - if err != nil { - log.WithError(err).Warn("failed to finalize forfeit tx") - continue - } + for _, keys := range vtxoKeys { + for _, v := range keys { + vtxos, err := vtxosRepo.GetVtxos(ctx, []domain.VtxoKey{v.VtxoKey}) + if err != nil { + log.WithError(err).Warn("failed to retrieve vtxos, skipping...") + continue + } + 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) + } + }() + } - forfeitTxid, err := s.wallet.BroadcastTransaction(ctx, forfeitTxHex) - if err != nil { - log.WithError(err).Warn("failed to broadcast forfeit tx") - continue + if vtxo.Spent { + log.Infof("fraud detected on vtxo %s:%d", vtxo.Txid, vtxo.VOut) + go func() { + if err := s.reactToFraud(ctx, vtxo, mutx); err != nil { + log.WithError(err).Warnf("failed to prevent fraud for vtxo %s:%d", vtxo.Txid, vtxo.VOut) + } + }() + } } - - log.Debugf("broadcasted forfeit tx %s", forfeitTxid) } }(vtxoKeys) } @@ -703,20 +649,27 @@ func (s *covenantService) getNextConnector( ctx context.Context, round domain.Round, ) (string, uint32, error) { - connectorTx, err := psetv2.NewPsetFromBase64(round.Connectors[0]) + lastConnectorPtx, err := psetv2.NewPsetFromBase64(round.Connectors[len(round.Connectors)-1]) if err != nil { return "", 0, err } - prevout := connectorTx.Inputs[0].WitnessUtxo - if prevout == nil { - return "", 0, fmt.Errorf("connector prevout not found") + var connectorAmount uint64 + for i := len(lastConnectorPtx.Outputs) - 1; i >= 0; i-- { + o := lastConnectorPtx.Outputs[i] + if len(o.Script) <= 0 { + continue // skip the fee output + } + + connectorAmount = o.Value + break } utxos, err := s.wallet.ListConnectorUtxos(ctx, round.ConnectorAddress) if err != nil { return "", 0, 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 { @@ -732,21 +685,26 @@ func (s *covenantService) getNextConnector( // search for an already existing connector for _, u := range utxos { - if u.GetValue() == 450 { + if u.GetValue() == connectorAmount { return u.GetTxid(), u.GetIndex(), nil } } for _, u := range utxos { - if u.GetValue() > 450 { + if u.GetValue() > connectorAmount { for _, b64 := range round.Connectors { - partial, err := psbt.NewFromRawBytes(strings.NewReader(b64), true) + partial, err := psetv2.NewPsetFromBase64(b64) if err != nil { return "", 0, err } - for _, i := range partial.UnsignedTx.TxIn { - if i.PreviousOutPoint.Hash.String() == u.GetTxid() && i.PreviousOutPoint.Index == u.GetIndex() { + for _, i := range partial.Inputs { + txhash, err := chainhash.NewHash(i.PreviousTxid) + if err != nil { + return "", 0, err + } + + if txhash.String() == u.GetTxid() && i.PreviousTxIndex == u.GetIndex() { connectorOutpoint := txOutpoint{u.GetTxid(), u.GetIndex()} if err := s.wallet.LockConnectorUtxos(ctx, []ports.TxOutpoint{connectorOutpoint}); err != nil { @@ -780,6 +738,61 @@ func (s *covenantService) getNextConnector( return "", 0, fmt.Errorf("no connector utxos found") } +func (s *covenantService) reactToFraud(ctx context.Context, vtxo domain.Vtxo, mutx *sync.Mutex) error { + round, err := s.repoManager.Rounds().GetRoundWithTxid(ctx, vtxo.SpentBy) + if err != nil { + return fmt.Errorf("failed to retrieve round: %s", err) + } + + mutx.Lock() + defer mutx.Unlock() + + connectorTxid, connectorVout, err := s.getNextConnector(ctx, *round) + if err != nil { + return fmt.Errorf("failed to retrieve next connector: %s", err) + } + + forfeitTx, err := findForfeitTxLiquid(round.ForfeitTxs, connectorTxid, connectorVout, vtxo.Txid) + 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.SignTransaction(ctx, forfeitTx, false) + if err != nil { + return fmt.Errorf("failed to sign connector input in forfeit tx: %s", err) + } + + signedForfeitTx, err = s.wallet.SignTransactionTapscript(ctx, signedForfeitTx, []int{1}) + if err != nil { + return fmt.Errorf("failed to sign vtxo input in forfeit tx: %s", err) + } + + forfeitTxHex, err := s.builder.FinalizeAndExtract(signedForfeitTx) + if err != nil { + return fmt.Errorf("failed to finalize forfeit tx: %s", err) + } + + forfeitTxid, err := s.wallet.BroadcastTransaction(ctx, forfeitTxHex) + if err != nil { + return fmt.Errorf("failed to broadcast forfeit tx: %s", err) + } + + log.Debugf("broadcasted forfeit tx %s", forfeitTxid) + return nil +} + +func (s *covenantService) markAsRedeemed(ctx context.Context, vtxo domain.Vtxo) error { + if err := s.repoManager.Vtxos().RedeemVtxos(ctx, []domain.VtxoKey{vtxo.VtxoKey}); err != nil { + return err + } + log.Debugf("vtxo %s redeemed", vtxo.Txid) + return nil +} + func (s *covenantService) updateVtxoSet(round *domain.Round) { // Update the vtxo set only after a round is finalized. if !round.IsEnded() { diff --git a/server/internal/core/application/covenantless.go b/server/internal/core/application/covenantless.go index 666dfb788..a23fb953b 100644 --- a/server/internal/core/application/covenantless.go +++ b/server/internal/core/application/covenantless.go @@ -259,13 +259,17 @@ func (s *covenantlessService) CompleteAsyncPayment( } vtxos := make([]domain.Vtxo, 0, len(asyncPayData.receivers)) - for i, receiver := range asyncPayData.receivers { + + for outIndex, out := range redeemPtx.UnsignedTx.TxOut { vtxos = append(vtxos, domain.Vtxo{ VtxoKey: domain.VtxoKey{ Txid: redeemTxid, - VOut: uint32(i), + VOut: uint32(outIndex), + }, + Receiver: domain.Receiver{ + Pubkey: asyncPayData.receivers[outIndex].Pubkey, + Amount: uint64(out.Value), }, - Receiver: receiver, ExpireAt: asyncPayData.expireAt, AsyncPayment: &domain.AsyncPaymentTxs{ RedeemTx: redeemTx, @@ -278,6 +282,12 @@ func (s *covenantlessService) CompleteAsyncPayment( return fmt.Errorf("failed to add vtxos: %s", err) } log.Infof("added %d vtxos", len(vtxos)) + if err := s.startWatchingVtxos(vtxos); err != nil { + log.WithError(err).Warn( + "failed to start watching vtxos", + ) + } + log.Debugf("started watching %d vtxos", len(vtxos)) if err := s.repoManager.Vtxos().SpendVtxos(ctx, spentVtxos, redeemTxid); err != nil { return fmt.Errorf("failed to spend vtxo: %s", err) @@ -546,8 +556,8 @@ func (s *covenantlessService) GetEventsChannel(ctx context.Context) <-chan domai return s.eventsCh } -func (s *covenantlessService) GetRoundByTxid(ctx context.Context, poolTxid string) (*domain.Round, error) { - return s.repoManager.Rounds().GetRoundWithTxid(ctx, poolTxid) +func (s *covenantlessService) GetRoundByTxid(ctx context.Context, roundTxid string) (*domain.Round, error) { + return s.repoManager.Rounds().GetRoundWithTxid(ctx, roundTxid) } func (s *covenantlessService) GetRoundById(ctx context.Context, id string) (*domain.Round, error) { @@ -1070,89 +1080,33 @@ func (s *covenantlessService) listenToScannerNotifications() { mutx := &sync.Mutex{} for vtxoKeys := range chVtxos { - go func(vtxoKeys map[string]ports.VtxoWithValue) { - vtxosRepo := s.repoManager.Vtxos() - roundRepo := s.repoManager.Rounds() - - for _, v := range vtxoKeys { - // redeem - vtxos, err := vtxosRepo.GetVtxos(ctx, []domain.VtxoKey{v.VtxoKey}) - if err != nil { - log.WithError(err).Warn("failed to retrieve vtxos, skipping...") - continue - } - - vtxo := vtxos[0] - - if vtxo.Redeemed { - continue - } - - if err := s.repoManager.Vtxos().RedeemVtxos( - ctx, []domain.VtxoKey{vtxo.VtxoKey}, - ); err != nil { - log.WithError(err).Warn("failed to redeem vtxos, retrying...") - continue - } - log.Debugf("vtxo %s redeemed", vtxo.Txid) - - if !vtxo.Spent { - continue - } - - log.Debugf("fraud detected on vtxo %s", vtxo.Txid) - - round, err := roundRepo.GetRoundWithTxid(ctx, vtxo.SpentBy) - if err != nil { - log.WithError(err).Warn("failed to retrieve round") - continue - } - - mutx.Lock() - defer mutx.Unlock() - - connectorTxid, connectorVout, err := s.getNextConnector(ctx, *round) - if err != nil { - log.WithError(err).Warn("failed to retrieve next connector") - continue - } - - forfeitTx, err := findForfeitTxBitcoin(round.ForfeitTxs, connectorTxid, connectorVout, vtxo.Txid) - if err != nil { - log.WithError(err).Warn("failed to retrieve forfeit tx") - continue - } - - if err := s.wallet.LockConnectorUtxos(ctx, []ports.TxOutpoint{txOutpoint{connectorTxid, connectorVout}}); err != nil { - log.WithError(err).Warn("failed to lock connector utxos") - continue - } - - signedForfeitTx, err := s.wallet.SignTransaction(ctx, forfeitTx, false) - if err != nil { - log.WithError(err).Warn("failed to sign connector input in forfeit tx") - continue - } - - signedForfeitTx, err = s.wallet.SignTransactionTapscript(ctx, signedForfeitTx, []int{1}) - if err != nil { - log.WithError(err).Warn("failed to sign vtxo input in forfeit tx") - continue - } - - forfeitTxHex, err := s.builder.FinalizeAndExtractForfeit(signedForfeitTx) - if err != nil { - log.WithError(err).Warn("failed to finalize forfeit tx") - continue - } + go func(vtxoKeys map[string][]ports.VtxoWithValue) { + for _, keys := range vtxoKeys { + for _, v := range keys { + vtxos, err := s.repoManager.Vtxos().GetVtxos(ctx, []domain.VtxoKey{v.VtxoKey}) + if err != nil { + log.WithError(err).Warn("failed to retrieve vtxos, skipping...") + return + } + 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) + } + }() + } - forfeitTxid, err := s.wallet.BroadcastTransaction(ctx, forfeitTxHex) - if err != nil { - log.WithError(err).Warn("failed to broadcast forfeit tx") - continue + if vtxo.Spent { + log.Infof("fraud detected on vtxo %s:%d", vtxo.Txid, vtxo.VOut) + go func() { + if err := s.reactToFraud(ctx, vtxo, mutx); err != nil { + log.WithError(err).Warnf("failed to prevent fraud for vtxo %s:%d", vtxo.Txid, vtxo.VOut) + } + }() + } } - - log.Debugf("broadcasted forfeit tx %s", forfeitTxid) } }(vtxoKeys) } @@ -1162,10 +1116,19 @@ func (s *covenantlessService) getNextConnector( ctx context.Context, round domain.Round, ) (string, uint32, error) { + lastConnectorPtx, err := psbt.NewFromRawBytes(strings.NewReader(round.Connectors[len(round.Connectors)-1]), true) + if err != nil { + return "", 0, err + } + + lastOutput := lastConnectorPtx.UnsignedTx.TxOut[len(lastConnectorPtx.UnsignedTx.TxOut)-1] + connectorAmount := lastOutput.Value + utxos, err := s.wallet.ListConnectorUtxos(ctx, round.ConnectorAddress) if err != nil { return "", 0, 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 { @@ -1181,13 +1144,13 @@ func (s *covenantlessService) getNextConnector( // search for an already existing connector for _, u := range utxos { - if u.GetValue() == 450 { + if u.GetValue() == uint64(connectorAmount) { return u.GetTxid(), u.GetIndex(), nil } } for _, u := range utxos { - if u.GetValue() > 450 { + if u.GetValue() > uint64(connectorAmount) { for _, b64 := range round.Connectors { ptx, err := psbt.NewFromRawBytes(strings.NewReader(b64), true) if err != nil { @@ -1210,7 +1173,7 @@ func (s *covenantlessService) getNextConnector( connectorTxid, err := s.wallet.BroadcastTransaction(ctx, signedConnectorTx) if err != nil { - return "", 0, err + return "", 0, fmt.Errorf("failed to broadcast connector tx: %s", err) } log.Debugf("broadcasted connector tx %s", connectorTxid) @@ -1464,8 +1427,90 @@ func (s *covenantlessService) saveEvents( return s.repoManager.Rounds().AddOrUpdateRound(ctx, *round) } +func (s *covenantlessService) reactToFraud(ctx context.Context, vtxo domain.Vtxo, mutx *sync.Mutex) error { + mutx.Lock() + defer mutx.Unlock() + roundRepo := s.repoManager.Rounds() + + round, err := roundRepo.GetRoundWithTxid(ctx, vtxo.SpentBy) + if err != nil { + 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 err != nil || len(vtxos) <= 0 { + return fmt.Errorf("failed to retrieve round: %s", err) + } + + asyncPayVtxo := vtxos[0] + if asyncPayVtxo.Redeemed { // redeem tx is already onchain + return nil + } + + log.Debugf("vtxo %s:%d has been spent by async payment", vtxo.Txid, vtxo.VOut) + + redeemTxHex, err := s.builder.FinalizeAndExtract(asyncPayVtxo.AsyncPayment.RedeemTx) + if err != nil { + return fmt.Errorf("failed to finalize redeem tx: %s", err) + } + + redeemTxid, err := s.wallet.BroadcastTransaction(ctx, redeemTxHex) + if err != nil { + return fmt.Errorf("failed to broadcast redeem tx: %s", err) + } + + log.Debugf("broadcasted redeem tx %s", redeemTxid) + return nil + } + + connectorTxid, connectorVout, err := s.getNextConnector(ctx, *round) + if err != nil { + return fmt.Errorf("failed to get next connector: %s", err) + } + + log.Debugf("found next connector %s:%d", connectorTxid, connectorVout) + + forfeitTx, err := findForfeitTxBitcoin(round.ForfeitTxs, connectorTxid, connectorVout, vtxo.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) + } + + forfeitTxid, err := s.wallet.BroadcastTransaction(ctx, forfeitTxHex) + if err != nil { + return fmt.Errorf("failed to broadcast forfeit tx: %s", err) + } + + log.Debugf("broadcasted forfeit tx %s", forfeitTxid) + return nil +} + +func (s *covenantlessService) markAsRedeemed(ctx context.Context, vtxo domain.Vtxo) error { + if err := s.repoManager.Vtxos().RedeemVtxos(ctx, []domain.VtxoKey{vtxo.VtxoKey}); err != nil { + return err + } + + log.Debugf("vtxo %s:%d redeemed", vtxo.Txid, vtxo.VOut) + return nil +} + func findForfeitTxBitcoin( - forfeits []string, connectorTxid string, connectorVout uint32, vtxoTxid string, + forfeits []string, connectorTxid string, connectorVout uint32, vtxo domain.VtxoKey, ) (string, error) { for _, forfeit := range forfeits { forfeitTx, err := psbt.NewFromRawBytes(strings.NewReader(forfeit), true) @@ -1476,9 +1521,10 @@ func findForfeitTxBitcoin( connector := forfeitTx.UnsignedTx.TxIn[0] vtxoInput := forfeitTx.UnsignedTx.TxIn[1] - if connector.PreviousOutPoint.String() == connectorTxid && + if connector.PreviousOutPoint.Hash.String() == connectorTxid && connector.PreviousOutPoint.Index == connectorVout && - vtxoInput.PreviousOutPoint.String() == vtxoTxid { + vtxoInput.PreviousOutPoint.Hash.String() == vtxo.Txid && + vtxoInput.PreviousOutPoint.Index == vtxo.VOut { return forfeit, nil } } diff --git a/server/internal/core/ports/scanner.go b/server/internal/core/ports/scanner.go index 2ca502d0b..3a8325674 100644 --- a/server/internal/core/ports/scanner.go +++ b/server/internal/core/ports/scanner.go @@ -13,6 +13,6 @@ type VtxoWithValue struct { type BlockchainScanner interface { WatchScripts(ctx context.Context, scripts []string) error UnwatchScripts(ctx context.Context, scripts []string) error - GetNotificationChannel(ctx context.Context) <-chan map[string]VtxoWithValue + GetNotificationChannel(ctx context.Context) <-chan map[string][]VtxoWithValue IsTransactionConfirmed(ctx context.Context, txid string) (isConfirmed bool, blocktime int64, err error) } diff --git a/server/internal/core/ports/tx_builder.go b/server/internal/core/ports/tx_builder.go index 865c80d55..7c0953fb1 100644 --- a/server/internal/core/ports/tx_builder.go +++ b/server/internal/core/ports/tx_builder.go @@ -32,8 +32,8 @@ 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) + FinalizeAndExtract(tx string) (txhex 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) BuildAsyncPaymentTransactions( diff --git a/server/internal/infrastructure/tx-builder/covenant/builder.go b/server/internal/infrastructure/tx-builder/covenant/builder.go index 9ad1d308a..80d14d774 100644 --- a/server/internal/infrastructure/tx-builder/covenant/builder.go +++ b/server/internal/infrastructure/tx-builder/covenant/builder.go @@ -23,10 +23,6 @@ import ( "github.com/vulpemventures/go-elements/transaction" ) -const ( - connectorAmount = uint64(450) -) - type txBuilder struct { wallet ports.WalletService net common.Network @@ -112,12 +108,17 @@ func (b *txBuilder) BuildForfeitTxs( return nil, nil, err } - connectorTxs, err := b.createConnectors(poolTx, payments, connectorAddress, connectorFeeAmount) + connectorAmount, err := b.wallet.GetDustAmount(context.Background()) if err != nil { return nil, nil, err } - forfeitTxs, err = b.createForfeitTxs(aspPubkey, payments, connectorTxs) + connectorTxs, err := b.createConnectors(poolTx, payments, connectorAddress, connectorAmount, connectorFeeAmount) + if err != nil { + return nil, nil, err + } + + forfeitTxs, err = b.createForfeitTxs(aspPubkey, payments, connectorTxs, connectorAmount) if err != nil { return nil, nil, err } @@ -309,7 +310,7 @@ func (b *txBuilder) VerifyTapscriptPartialSigs(tx string) (bool, string, error) return true, txid, nil } -func (b *txBuilder) FinalizeAndExtractForfeit(tx string) (string, error) { +func (b *txBuilder) FinalizeAndExtract(tx string) (string, error) { p, err := psetv2.NewPsetFromBase64(tx) if err != nil { return "", err @@ -437,9 +438,14 @@ func (b *txBuilder) createPoolTx( return nil, err } + dustAmount, err := b.wallet.GetDustAmount(context.Background()) + if err != nil { + return nil, err + } + receivers := getOnchainReceivers(payments) nbOfInputs := countSpentVtxos(payments) - connectorsAmount := (connectorAmount + connectorMinRelayFee) * nbOfInputs + connectorsAmount := (dustAmount + connectorMinRelayFee) * nbOfInputs if nbOfInputs > 1 { connectorsAmount -= connectorMinRelayFee } @@ -744,7 +750,9 @@ func (b *txBuilder) VerifyAndCombinePartialTx(dest string, src string) (string, } func (b *txBuilder) createConnectors( - poolTx string, payments []domain.Payment, connectorAddress string, feeAmount uint64, + poolTx string, payments []domain.Payment, + connectorAddress string, + connectorAmount, feeAmount uint64, ) ([]*psetv2.Pset, error) { txid, _ := getTxid(poolTx) @@ -798,7 +806,10 @@ func (b *txBuilder) createConnectors( return nil, err } - txid, _ := getPsetId(connectorTx) + txid, err := getPsetId(connectorTx) + if err != nil { + return nil, err + } previousInput = psetv2.InputArgs{ Txid: txid, @@ -812,7 +823,7 @@ func (b *txBuilder) createConnectors( } func (b *txBuilder) createForfeitTxs( - aspPubkey *secp256k1.PublicKey, payments []domain.Payment, connectors []*psetv2.Pset, + aspPubkey *secp256k1.PublicKey, payments []domain.Payment, connectors []*psetv2.Pset, connectorAmount uint64, ) ([]string, error) { aspScript, err := p2wpkhScript(aspPubkey, b.onchainNetwork()) if err != nil { @@ -855,7 +866,7 @@ func (b *txBuilder) createForfeitTxs( for _, connector := range connectors { txs, err := b.craftForfeitTxs( - connector, vtxo, *forfeitProof, vtxoScript, aspScript, + connector, connectorAmount, vtxo, *forfeitProof, vtxoScript, aspScript, ) if err != nil { return nil, err diff --git a/server/internal/infrastructure/tx-builder/covenant/connectors.go b/server/internal/infrastructure/tx-builder/covenant/connectors.go index c4e54d4e6..78cb2998c 100644 --- a/server/internal/infrastructure/tx-builder/covenant/connectors.go +++ b/server/internal/infrastructure/tx-builder/covenant/connectors.go @@ -52,7 +52,7 @@ func craftConnectorTx( return ptx, nil } -func getConnectorInputs(pset *psetv2.Pset) ([]psetv2.InputArgs, []*transaction.TxOutput) { +func getConnectorInputs(pset *psetv2.Pset, connectorAmount uint64) ([]psetv2.InputArgs, []*transaction.TxOutput) { txID, _ := getPsetId(pset) inputs := make([]psetv2.InputArgs, 0, len(pset.Outputs)) diff --git a/server/internal/infrastructure/tx-builder/covenant/forfeit.go b/server/internal/infrastructure/tx-builder/covenant/forfeit.go index e3ffe3206..ca9fc569e 100644 --- a/server/internal/infrastructure/tx-builder/covenant/forfeit.go +++ b/server/internal/infrastructure/tx-builder/covenant/forfeit.go @@ -16,11 +16,12 @@ import ( func (b *txBuilder) craftForfeitTxs( connectorTx *psetv2.Pset, + connectorAmount uint64, vtxo domain.Vtxo, vtxoForfeitTapleaf taproot.TapscriptElementsProof, vtxoScript, aspScript []byte, ) (forfeitTxs []string, err error) { - connectors, prevouts := getConnectorInputs(connectorTx) + connectors, prevouts := getConnectorInputs(connectorTx, connectorAmount) for i, connectorInput := range connectors { weightEstimator := &input.TxWeightEstimator{} diff --git a/server/internal/infrastructure/tx-builder/covenant/mocks_test.go b/server/internal/infrastructure/tx-builder/covenant/mocks_test.go index 06e8dd8b5..a4f8b2e24 100644 --- a/server/internal/infrastructure/tx-builder/covenant/mocks_test.go +++ b/server/internal/infrastructure/tx-builder/covenant/mocks_test.go @@ -190,12 +190,12 @@ func (m *mockedWallet) UnwatchScripts( return args.Error(0) } -func (m *mockedWallet) GetNotificationChannel(ctx context.Context) <-chan map[string]ports.VtxoWithValue { +func (m *mockedWallet) GetNotificationChannel(ctx context.Context) <-chan map[string][]ports.VtxoWithValue { args := m.Called(ctx) - var res <-chan map[string]ports.VtxoWithValue + var res <-chan map[string][]ports.VtxoWithValue if a := args.Get(0); a != nil { - res = a.(<-chan map[string]ports.VtxoWithValue) + res = a.(<-chan map[string][]ports.VtxoWithValue) } return res } diff --git a/server/internal/infrastructure/tx-builder/covenantless/builder.go b/server/internal/infrastructure/tx-builder/covenantless/builder.go index c474b9935..1e7dd1206 100644 --- a/server/internal/infrastructure/tx-builder/covenantless/builder.go +++ b/server/internal/infrastructure/tx-builder/covenantless/builder.go @@ -102,12 +102,60 @@ func (b *txBuilder) VerifyTapscriptPartialSigs(tx string) (bool, string, error) return true, txid, nil } -func (b *txBuilder) FinalizeAndExtractForfeit(tx string) (string, error) { - ptx, _ := psbt.NewFromRawBytes(strings.NewReader(tx), true) +func (b *txBuilder) FinalizeAndExtract(tx string) (string, error) { + ptx, err := psbt.NewFromRawBytes(strings.NewReader(tx), true) + if err != nil { + return "", err + } + + for i, in := range ptx.Inputs { + isTaproot := txscript.IsPayToTaproot(in.WitnessUtxo.PkScript) + if isTaproot && len(in.TaprootLeafScript) > 0 { + closure, err := bitcointree.DecodeClosure(in.TaprootLeafScript[0].Script) + if err != nil { + return "", err + } + + witness := make(wire.TxWitness, 4) + + castClosure, isTaprootMultisig := closure.(*bitcointree.MultisigClosure) + if isTaprootMultisig { + ownerPubkey := schnorr.SerializePubKey(castClosure.Pubkey) + aspKey := schnorr.SerializePubKey(castClosure.AspPubkey) + + for _, sig := range in.TaprootScriptSpendSig { + if bytes.Equal(sig.XOnlyPubKey, ownerPubkey) { + witness[0] = sig.Signature + } + + if bytes.Equal(sig.XOnlyPubKey, aspKey) { + witness[1] = sig.Signature + } + } + + witness[2] = in.TaprootLeafScript[0].Script + witness[3] = in.TaprootLeafScript[0].ControlBlock + + for idw, w := range witness { + if w == nil { + return "", fmt.Errorf("missing witness element %d, cannot finalize taproot mutlisig input %d", idw, i) + } + } + + var witnessBuf bytes.Buffer + + if err := psbt.WriteTxWitness(&witnessBuf, witness); err != nil { + return "", err + } + + ptx.Inputs[i].FinalScriptWitness = witnessBuf.Bytes() + continue + } + + } - for i := range ptx.Inputs { if err := psbt.Finalize(ptx, i); err != nil { - return "", err + return "", fmt.Errorf("failed to finalize input %d: %w", i, err) } } @@ -477,7 +525,7 @@ func (b *txBuilder) BuildAsyncPaymentTransactions( unconditionalForfeitTxs = append(unconditionalForfeitTxs, forfeitTx) ins = append(ins, vtxoOutpoint) - redeemTxWeightEstimator.AddTapscriptInput(64, tapscript) + redeemTxWeightEstimator.AddTapscriptInput(64*2, tapscript) } for range receivers { @@ -1199,7 +1247,7 @@ func (b *txBuilder) getConnectorPkScript(poolTx string) ([]byte, error) { return nil, fmt.Errorf("connector output not found in pool tx") } - return partialTx.UnsignedTx.TxOut[0].PkScript, nil + return partialTx.UnsignedTx.TxOut[1].PkScript, nil } func (b *txBuilder) selectUtxos(ctx context.Context, sweptRounds []domain.Round, amount uint64) ([]ports.TxInput, uint64, error) { diff --git a/server/internal/infrastructure/tx-builder/covenantless/mocks_test.go b/server/internal/infrastructure/tx-builder/covenantless/mocks_test.go index 581734bd0..69b0bcf42 100644 --- a/server/internal/infrastructure/tx-builder/covenantless/mocks_test.go +++ b/server/internal/infrastructure/tx-builder/covenantless/mocks_test.go @@ -190,12 +190,12 @@ func (m *mockedWallet) UnwatchScripts( return args.Error(0) } -func (m *mockedWallet) GetNotificationChannel(ctx context.Context) <-chan map[string]ports.VtxoWithValue { +func (m *mockedWallet) GetNotificationChannel(ctx context.Context) <-chan map[string][]ports.VtxoWithValue { args := m.Called(ctx) - var res <-chan map[string]ports.VtxoWithValue + var res <-chan map[string][]ports.VtxoWithValue if a := args.Get(0); a != nil { - res = a.(<-chan map[string]ports.VtxoWithValue) + res = a.(<-chan map[string][]ports.VtxoWithValue) } return res } diff --git a/server/internal/infrastructure/wallet/btc-embedded/psbt.go b/server/internal/infrastructure/wallet/btc-embedded/psbt.go index 3b2cc7559..4ae685d53 100644 --- a/server/internal/infrastructure/wallet/btc-embedded/psbt.go +++ b/server/internal/infrastructure/wallet/btc-embedded/psbt.go @@ -1,6 +1,7 @@ package btcwallet import ( + "encoding/hex" "fmt" "github.com/btcsuite/btcd/btcec/v2/schnorr" @@ -41,6 +42,7 @@ func (s *service) signPsbt(packet *psbt.Packet, inputsToSign []int) ([]uint32, e } tx := packet.UnsignedTx + signedInputs := make([]uint32, 0) for idx := range tx.TxIn { in := &packet.Inputs[idx] @@ -76,13 +78,19 @@ func (s *service) signPsbt(packet *psbt.Packet, inputsToSign []int) ([]uint32, e managedAddress = s.aspTaprootAddr } else { // segwit v0 + var err error managedAddress, _, _, err = s.wallet.ScriptForOutput(in.WitnessUtxo) if err != nil { - log.Debugf("SignPsbt: Skipping input %d, error "+ - "fetching script for output: %v", idx, err) + log.WithError(err).Debugf( + "failed to fetch address for input %d with script %s", + idx, hex.EncodeToString(in.WitnessUtxo.PkScript), + ) continue } } + + signedInputs = append(signedInputs, uint32(idx)) + bip32Infos := derivationPathForAddress(managedAddress) packet.Inputs[idx].Bip32Derivation = []*psbt.Bip32Derivation{bip32Infos} @@ -106,25 +114,18 @@ func (s *service) signPsbt(packet *psbt.Packet, inputsToSign []int) ([]uint32, e } } - // TODO (@louisinger): shall we delete this code? - // prevOutputFetcher := wallet.PsbtPrevOutputFetcher(packet) - // sigHashes := txscript.NewTxSigHashes(tx, prevOutputFetcher) - - // in := packet.Inputs[0] - - // preimage, err := txscript.CalcTapscriptSignaturehash( - // sigHashes, - // txscript.SigHashType(in.SighashType), - // tx, - // 0, - // txscript.NewCannedPrevOutputFetcher(in.WitnessUtxo.PkScript, in.WitnessUtxo.Value), - // txscript.NewBaseTapLeaf(in.TaprootLeafScript[0].Script), - // ) - // if err != nil { - // return nil, err - // } - - return s.wallet.SignPsbt(packet) + ins, err := s.wallet.SignPsbt(packet) + if err != nil { + return nil, err + } + + // delete derivation paths to avoid duplicate keys error + for idx := range signedInputs { + packet.Inputs[idx].Bip32Derivation = nil + packet.Inputs[idx].TaprootBip32Derivation = nil + } + + return ins, nil } func derivationPathForAddress(addr waddrmgr.ManagedPubKeyAddress) *psbt.Bip32Derivation { diff --git a/server/internal/infrastructure/wallet/btc-embedded/wallet.go b/server/internal/infrastructure/wallet/btc-embedded/wallet.go index 070e1d804..846b2707f 100644 --- a/server/internal/infrastructure/wallet/btc-embedded/wallet.go +++ b/server/internal/infrastructure/wallet/btc-embedded/wallet.go @@ -70,6 +70,10 @@ const ( mainAccount accountName = "main" connectorAccount accountName = "connector" aspKeyAccount accountName = "aspkey" + + // https://github.com/bitcoin/bitcoin/blob/439e58c4d8194ca37f70346727d31f52e69592ec/src/policy/policy.cpp#L23C8-L23C11 + // biggest input size to compute the maximum dust amount + biggestInputSize = 148 + 182 // = 330 vbytes ) var ( @@ -852,18 +856,37 @@ func (s *service) UnwatchScripts(ctx context.Context, scripts []string) error { func (s *service) GetNotificationChannel( ctx context.Context, -) <-chan map[string]ports.VtxoWithValue { - ch := make(chan map[string]ports.VtxoWithValue) +) <-chan map[string][]ports.VtxoWithValue { + ch := make(chan map[string][]ports.VtxoWithValue) go func() { + const maxCacheSize = 100 + sentTxs := make(map[chainhash.Hash]struct{}) + + cache := func(hash chainhash.Hash) { + if len(sentTxs) > maxCacheSize { + sentTxs = make(map[chainhash.Hash]struct{}) + } + + sentTxs[hash] = struct{}{} + } + for n := range s.scanner.Notifications() { switch m := n.(type) { case chain.RelevantTx: + if _, sent := sentTxs[m.TxRecord.Hash]; sent { + continue + } notification := s.castNotification(m.TxRecord) + cache(m.TxRecord.Hash) ch <- notification case chain.FilteredBlockConnected: for _, tx := range m.RelevantTxs { + if _, sent := sentTxs[tx.Hash]; sent { + continue + } notification := s.castNotification(tx) + cache(tx.Hash) ch <- notification } } @@ -879,11 +902,10 @@ func (s *service) IsTransactionConfirmed( return s.extraAPI.getTxStatus(txid) } -// https://github.com/bitcoin/bitcoin/blob/439e58c4d8194ca37f70346727d31f52e69592ec/src/policy/policy.cpp#L23C8-L23C11 func (s *service) GetDustAmount( ctx context.Context, ) (uint64, error) { - return s.MinRelayFee(ctx, 182) // non-segwit 1-in-1-out tx + return s.MinRelayFee(ctx, biggestInputSize) } func (s *service) GetTransaction(ctx context.Context, txid string) (string, error) { @@ -904,8 +926,8 @@ func (s *service) GetTransaction(ctx context.Context, txid string) (string, erro return hex.EncodeToString(buf.Bytes()), nil } -func (s *service) castNotification(tx *wtxmgr.TxRecord) map[string]ports.VtxoWithValue { - vtxos := make(map[string]ports.VtxoWithValue) +func (s *service) castNotification(tx *wtxmgr.TxRecord) map[string][]ports.VtxoWithValue { + vtxos := make(map[string][]ports.VtxoWithValue) s.watchedScriptsLock.RLock() defer s.watchedScriptsLock.RUnlock() @@ -916,13 +938,17 @@ func (s *service) castNotification(tx *wtxmgr.TxRecord) map[string]ports.VtxoWit continue } - vtxos[script] = ports.VtxoWithValue{ + if len(vtxos[script]) <= 0 { + vtxos[script] = make([]ports.VtxoWithValue, 0) + } + + vtxos[script] = append(vtxos[script], ports.VtxoWithValue{ VtxoKey: domain.VtxoKey{ Txid: tx.Hash.String(), VOut: uint32(outputIndex), }, Value: uint64(txout.Value), - } + }) } return vtxos diff --git a/server/internal/infrastructure/wallet/liquid-standalone/account.go b/server/internal/infrastructure/wallet/liquid-standalone/account.go index 555c9331e..d888b8543 100644 --- a/server/internal/infrastructure/wallet/liquid-standalone/account.go +++ b/server/internal/infrastructure/wallet/liquid-standalone/account.go @@ -2,6 +2,7 @@ package oceanwallet import ( "context" + "encoding/hex" pb "github.com/ark-network/ark/api-spec/protobuf/gen/ocean/v1" "github.com/ark-network/ark/server/internal/core/ports" @@ -27,20 +28,26 @@ func (s *service) DeriveConnectorAddress(ctx context.Context) (string, error) { func (s *service) ListConnectorUtxos( ctx context.Context, connectorAddress string, ) ([]ports.TxInput, error) { - addresses := make([]string, 0) - if len(connectorAddress) > 0 { - addresses = append(addresses, connectorAddress) + connectorScript, err := address.ToOutputScript(connectorAddress) + if err != nil { + return nil, err } + res, err := s.accountClient.ListUtxos(ctx, &pb.ListUtxosRequest{ AccountName: connectorAccount, - Addresses: addresses, }) if err != nil { return nil, err } utxos := make([]ports.TxInput, 0) + connectorScriptHex := hex.EncodeToString(connectorScript) + for _, utxo := range res.GetSpendableUtxos().GetUtxos() { + if utxo.Script != connectorScriptHex { + continue + } + utxos = append(utxos, utxo) } diff --git a/server/internal/infrastructure/wallet/liquid-standalone/notification.go b/server/internal/infrastructure/wallet/liquid-standalone/notification.go index 18087d7bb..4f0aa9be3 100644 --- a/server/internal/infrastructure/wallet/liquid-standalone/notification.go +++ b/server/internal/infrastructure/wallet/liquid-standalone/notification.go @@ -35,7 +35,7 @@ func (s *service) UnwatchScripts(ctx context.Context, scripts []string) error { return nil } -func (s *service) GetNotificationChannel(ctx context.Context) <-chan map[string]ports.VtxoWithValue { +func (s *service) GetNotificationChannel(ctx context.Context) <-chan map[string][]ports.VtxoWithValue { return s.chVtxos } diff --git a/server/internal/infrastructure/wallet/liquid-standalone/service.go b/server/internal/infrastructure/wallet/liquid-standalone/service.go index 9b84af257..3ea133f7c 100644 --- a/server/internal/infrastructure/wallet/liquid-standalone/service.go +++ b/server/internal/infrastructure/wallet/liquid-standalone/service.go @@ -22,7 +22,7 @@ type service struct { accountClient pb.AccountServiceClient txClient pb.TransactionServiceClient notifyClient pb.NotificationServiceClient - chVtxos chan map[string]ports.VtxoWithValue + chVtxos chan map[string][]ports.VtxoWithValue isListening bool } @@ -35,7 +35,7 @@ func NewService(addr string) (ports.WalletService, error) { accountClient := pb.NewAccountServiceClient(conn) txClient := pb.NewTransactionServiceClient(conn) notifyClient := pb.NewNotificationServiceClient(conn) - chVtxos := make(chan map[string]ports.VtxoWithValue) + chVtxos := make(chan map[string][]ports.VtxoWithValue) svc := &service{ addr: addr, conn: conn, @@ -190,8 +190,8 @@ func (s *service) listenToNotifications() { } } -func toVtxos(utxos []*pb.Utxo) map[string]ports.VtxoWithValue { - vtxos := make(map[string]ports.VtxoWithValue, len(utxos)) +func toVtxos(utxos []*pb.Utxo) map[string][]ports.VtxoWithValue { + vtxos := make(map[string][]ports.VtxoWithValue, len(utxos)) for _, utxo := range utxos { // We want to notify for activity related to vtxos owner, therefore we skip // returning anything related to the internal accounts of the wallet, like @@ -200,11 +200,13 @@ func toVtxos(utxos []*pb.Utxo) map[string]ports.VtxoWithValue { continue } - vtxos[utxo.Script] = ports.VtxoWithValue{ - VtxoKey: domain.VtxoKey{Txid: utxo.GetTxid(), - VOut: utxo.GetIndex(), + vtxos[utxo.Script] = []ports.VtxoWithValue{ + { + VtxoKey: domain.VtxoKey{Txid: utxo.GetTxid(), + VOut: utxo.GetIndex(), + }, + Value: utxo.GetValue(), }, - Value: utxo.GetValue(), } } return vtxos diff --git a/server/internal/infrastructure/wallet/liquid-standalone/transaction.go b/server/internal/infrastructure/wallet/liquid-standalone/transaction.go index 3c9fc314d..187968f0b 100644 --- a/server/internal/infrastructure/wallet/liquid-standalone/transaction.go +++ b/server/internal/infrastructure/wallet/liquid-standalone/transaction.go @@ -275,7 +275,7 @@ func (s *service) LockConnectorUtxos(ctx context.Context, utxos []ports.TxOutpoi } func (s *service) MinRelayFee(ctx context.Context, vbytes uint64) (uint64, error) { - feeRate := 0.11 + feeRate := 0.2 fee := uint64(float64(vbytes) * feeRate) return fee, nil } diff --git a/server/test/e2e/covenant/e2e_test.go b/server/test/e2e/covenant/e2e_test.go index 651cb4c36..d509fe276 100644 --- a/server/test/e2e/covenant/e2e_test.go +++ b/server/test/e2e/covenant/e2e_test.go @@ -2,6 +2,7 @@ package e2e_test import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -10,6 +11,12 @@ import ( "time" "github.com/ark-network/ark/common" + arksdk "github.com/ark-network/ark/pkg/client-sdk" + "github.com/ark-network/ark/pkg/client-sdk/client" + grpcclient "github.com/ark-network/ark/pkg/client-sdk/client/grpc" + "github.com/ark-network/ark/pkg/client-sdk/explorer" + "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/stretchr/testify/require" ) @@ -143,6 +150,58 @@ func TestCollaborativeExit(t *testing.T) { require.NoError(t, err) } +func TestReactToSpentVtxosRedemption(t *testing.T) { + ctx := context.Background() + client, grpcClient := setupArkSDK(t) + defer grpcClient.Close() + + offchainAddress, boardingAddress, err := client.Receive(ctx) + require.NoError(t, err) + + _, err = utils.RunCommand("nigiri", "faucet", "--liquid", boardingAddress) + require.NoError(t, err) + + time.Sleep(5 * time.Second) + + _, err = client.Claim(ctx) + require.NoError(t, err) + + time.Sleep(3 * time.Second) + + spendable, _, err := client.ListVtxos(ctx) + require.NoError(t, err) + require.NotEmpty(t, spendable) + + vtxo := spendable[0] + + _, err = client.SendOffChain(ctx, false, []arksdk.Receiver{arksdk.NewLiquidReceiver(offchainAddress, 1000)}) + require.NoError(t, err) + + round, err := grpcClient.GetRound(ctx, vtxo.RoundTxid) + require.NoError(t, err) + + expl := explorer.NewExplorer("http://localhost:3001", common.LiquidRegTest) + + branch, err := redemption.NewCovenantRedeemBranch(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) + } + + // give time for the ASP to detect and process the fraud + time.Sleep(18 * time.Second) + + balance, err := client.Balance(ctx, true) + require.NoError(t, err) + + require.Empty(t, balance.OnchainBalance.LockedAmount) +} + func runArkCommand(arg ...string) (string, error) { args := append([]string{"exec", "-t", "arkd", "ark"}, arg...) return utils.RunCommand("docker", args...) @@ -237,3 +296,27 @@ func setupAspWallet() error { return nil } + +func setupArkSDK(t *testing.T) (arksdk.ArkClient, client.ASPClient) { + storeSvc, err := inmemorystore.NewConfigStore() + require.NoError(t, err) + + client, err := arksdk.NewCovenantClient(storeSvc) + require.NoError(t, err) + + err = client.Init(context.Background(), arksdk.InitArgs{ + WalletType: arksdk.SingleKeyWallet, + ClientType: arksdk.GrpcClient, + AspUrl: "localhost:6060", + Password: utils.Password, + }) + require.NoError(t, err) + + err = client.Unlock(context.Background(), utils.Password) + require.NoError(t, err) + + grpcClient, err := grpcclient.NewClient("localhost:6060") + require.NoError(t, err) + + return client, grpcClient +} diff --git a/server/test/e2e/covenantless/e2e_test.go b/server/test/e2e/covenantless/e2e_test.go index eb2c3d277..32a75fbf0 100644 --- a/server/test/e2e/covenantless/e2e_test.go +++ b/server/test/e2e/covenantless/e2e_test.go @@ -2,6 +2,7 @@ package e2e_test import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -9,6 +10,13 @@ import ( "testing" "time" + "github.com/ark-network/ark/common" + arksdk "github.com/ark-network/ark/pkg/client-sdk" + "github.com/ark-network/ark/pkg/client-sdk/client" + grpcclient "github.com/ark-network/ark/pkg/client-sdk/client/grpc" + "github.com/ark-network/ark/pkg/client-sdk/explorer" + "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/stretchr/testify/require" ) @@ -155,6 +163,126 @@ func TestCollaborativeExit(t *testing.T) { require.NoError(t, err) } +func TestReactToSpentVtxosRedemption(t *testing.T) { + ctx := context.Background() + client, grpcClient := setupArkSDK(t) + defer grpcClient.Close() + + offchainAddress, boardingAddress, err := client.Receive(ctx) + require.NoError(t, err) + + _, err = utils.RunCommand("nigiri", "faucet", boardingAddress) + require.NoError(t, err) + + time.Sleep(5 * time.Second) + + _, err = client.Claim(ctx) + require.NoError(t, err) + + _, err = client.SendOffChain(ctx, false, []arksdk.Receiver{arksdk.NewBitcoinReceiver(offchainAddress, 1000)}) + require.NoError(t, err) + + time.Sleep(2 * time.Second) + + _, spentVtxos, err := client.ListVtxos(ctx) + require.NoError(t, err) + require.NotEmpty(t, spentVtxos) + + vtxo := spentVtxos[0] + + 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) + } + + // give time for the ASP to detect and process the fraud + time.Sleep(20 * time.Second) + + balance, err := client.Balance(ctx, true) + require.NoError(t, err) + + require.Empty(t, balance.OnchainBalance.LockedAmount) +} + +func TestReactToAsyncSpentVtxosRedemption(t *testing.T) { + t.Run("receveir claimed funds", func(t *testing.T) { + ctx := context.Background() + sdkClient, grpcClient := setupArkSDK(t) + defer grpcClient.Close() + + offchainAddress, 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.SendAsync(ctx, false, []arksdk.Receiver{arksdk.NewBitcoinReceiver(offchainAddress, 1000)}) + require.NoError(t, err) + + _, err = sdkClient.Claim(ctx) + require.NoError(t, err) + + time.Sleep(5 * time.Second) + + _, spentVtxos, err := sdkClient.ListVtxos(ctx) + require.NoError(t, err) + require.NotEmpty(t, spentVtxos) + + var vtxo client.Vtxo + + for _, v := range spentVtxos { + 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) + } + + // give time for the ASP to detect and process the fraud + time.Sleep(50 * time.Second) + + balance, err := sdkClient.Balance(ctx, true) + require.NoError(t, err) + + require.Empty(t, balance.OnchainBalance.LockedAmount) + }) +} + func runClarkCommand(arg ...string) (string, error) { args := append([]string{"exec", "-t", "clarkd", "ark"}, arg...) return utils.RunCommand("docker", args...) @@ -254,7 +382,41 @@ func setupAspWallet() error { return fmt.Errorf("failed to fund wallet: %s", err) } + _, err = utils.RunCommand("nigiri", "faucet", addr.Address) + if err != nil { + return fmt.Errorf("failed to fund wallet: %s", err) + } + + _, err = utils.RunCommand("nigiri", "faucet", addr.Address) + if err != nil { + return fmt.Errorf("failed to fund wallet: %s", err) + } + time.Sleep(5 * time.Second) return nil } + +func setupArkSDK(t *testing.T) (arksdk.ArkClient, client.ASPClient) { + storeSvc, err := inmemorystore.NewConfigStore() + require.NoError(t, err) + + client, err := arksdk.NewCovenantlessClient(storeSvc) + require.NoError(t, err) + + err = client.Init(context.Background(), arksdk.InitArgs{ + WalletType: arksdk.SingleKeyWallet, + ClientType: arksdk.GrpcClient, + AspUrl: "localhost:7070", + Password: utils.Password, + }) + require.NoError(t, err) + + err = client.Unlock(context.Background(), utils.Password) + require.NoError(t, err) + + grpcClient, err := grpcclient.NewClient("localhost:7070") + require.NoError(t, err) + + return client, grpcClient +}