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>
This commit is contained in:
Louis Singer
2024-09-16 17:03:43 +02:00
committed by GitHub
parent 4c8c5c06ed
commit 3782793431
31 changed files with 709 additions and 275 deletions

View File

@@ -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()
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]
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
if !vtxo.Redeemed {
go func() {
if err := s.markAsRedeemed(ctx, vtxo); err != nil {
log.WithError(err).Warnf("failed to mark vtxo %s:%d as redeemed", vtxo.Txid, vtxo.VOut)
}
}()
}
if 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)
}
}()
}
}
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
}
forfeitTxid, err := s.wallet.BroadcastTransaction(ctx, forfeitTxHex)
if err != nil {
log.WithError(err).Warn("failed to broadcast forfeit tx")
continue
}
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
}
}