From 5d5ad1f88a1b5ce44cbe68a1d4f76d758b3892e1 Mon Sep 17 00:00:00 2001 From: RafilxTenfen Date: Mon, 13 Jan 2025 12:54:58 -0300 Subject: [PATCH] chore: release v0.11.3 with backport of #91 (#93) * feat: add check if the covenant is the covenant committee (#91) * fix: add check if the covenant is the covenant committee of that btc delegation based on the params version * chore: refactory sanitize delegations * chore: add #91 to changelog * chore: add godoc to funcs * chore: add client controller and logger directly to param cache * chore: refactory to remove delegations in a single loop over dels * chore: add check for del copy * chore: removed delCopy * fix: test check endheight modified * chore: add test that verifies if all the delegations pointers wasn't pointing to the last one * chore: return error in func IsKeyInCommittee and log if there is a error * chore: stop the loop if there is an error in sanitize delegations * chore: address pr comments, transform err from %s to %w and add check if sanitized dels returns some delegation * chore: changelog release v0.11.3 with #91 PR --- CHANGELOG.md | 7 ++ covenant/cache_params.go | 76 ++++++++++++++++++++ covenant/covenant.go | 143 ++++++++++++++++++++++++-------------- covenant/covenant_test.go | 139 +++++++++++++++++++++++++++++++++++- 4 files changed, 311 insertions(+), 54 deletions(-) create mode 100644 covenant/cache_params.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 88f3b60..07a5a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## Unreleased +## v0.11.3 + +### Bug fixes + +* [#91](https://github.com/babylonlabs-io/covenant-emulator/pull/91) Add verification +if the covenant public key was present in the params of the versioned BTC delegation + ## v0.11.2 ### Bug fixes diff --git a/covenant/cache_params.go b/covenant/cache_params.go new file mode 100644 index 0000000..926ed69 --- /dev/null +++ b/covenant/cache_params.go @@ -0,0 +1,76 @@ +package covenant + +import ( + "sync" + + "github.com/avast/retry-go/v4" + "github.com/babylonlabs-io/covenant-emulator/clientcontroller" + "github.com/babylonlabs-io/covenant-emulator/types" + "go.uber.org/zap" +) + +type ( + ParamsGetter interface { + Get(version uint32) (*types.StakingParams, error) + } + CacheVersionedParams struct { + sync.Mutex + paramsByVersion map[uint32]*types.StakingParams + + cc clientcontroller.ClientController + logger *zap.Logger + } +) + +func NewCacheVersionedParams(cc clientcontroller.ClientController, logger *zap.Logger) ParamsGetter { + return &CacheVersionedParams{ + paramsByVersion: make(map[uint32]*types.StakingParams), + cc: cc, + logger: logger, + } +} + +// Get returns the staking parameter from the +func (v *CacheVersionedParams) Get(version uint32) (*types.StakingParams, error) { + v.Lock() + defer v.Unlock() + + params, ok := v.paramsByVersion[version] + if ok { + return params, nil + } + + params, err := v.getParamsByVersion(version) + if err != nil { + return nil, err + } + + v.paramsByVersion[version] = params + return params, nil +} + +func (v *CacheVersionedParams) getParamsByVersion(version uint32) (*types.StakingParams, error) { + var ( + err error + params *types.StakingParams + ) + + if err := retry.Do(func() error { + params, err = v.cc.QueryStakingParamsByVersion(version) + if err != nil { + return err + } + return nil + }, RtyAtt, RtyDel, RtyErr, retry.OnRetry(func(n uint, err error) { + v.logger.Debug( + "failed to query the consumer chain for the staking params", + zap.Uint("attempt", n+1), + zap.Uint("max_attempts", RtyAttNum), + zap.Error(err), + ) + })); err != nil { + return nil, err + } + + return params, nil +} diff --git a/covenant/covenant.go b/covenant/covenant.go index 918e724..5d0409d 100644 --- a/covenant/covenant.go +++ b/covenant/covenant.go @@ -46,6 +46,8 @@ type CovenantEmulator struct { config *covcfg.Config logger *zap.Logger + + paramCache ParamsGetter } func NewCovenantEmulator( @@ -60,12 +62,13 @@ func NewCovenantEmulator( } return &CovenantEmulator{ - cc: cc, - signer: signer, - config: config, - logger: logger, - pk: pk, - quit: make(chan struct{}), + cc: cc, + signer: signer, + config: config, + logger: logger, + pk: pk, + quit: make(chan struct{}), + paramCache: NewCacheVersionedParams(cc, logger), }, nil } @@ -100,7 +103,7 @@ func (ce *CovenantEmulator) AddCovenantSignatures(btcDels []*types.Delegation) ( } // 1. get the params matched to the delegation version - params, err := ce.getParamsByVersionWithRetry(btcDel.ParamsVersion) + params, err := ce.paramCache.Get(btcDel.ParamsVersion) if err != nil { return nil, fmt.Errorf("failed to get staking params with version %d: %w", btcDel.ParamsVersion, err) } @@ -436,30 +439,72 @@ func (ce *CovenantEmulator) delegationsToBatches(dels []*types.Delegation) [][]* return batches } -func RemoveAlreadySigned(localKey *btcec.PublicKey, dels []*types.Delegation) []*types.Delegation { - sanitized := make([]*types.Delegation, 0, len(dels)) - localKeyBytes := schnorr.SerializePubKey(localKey) +// IsKeyInCommittee returns true if the covenant serialized public key is in the covenant committee of the +// parameter in which the BTC delegation was included. +func IsKeyInCommittee(paramCache ParamsGetter, covenantSerializedPk []byte, del *types.Delegation) (bool, error) { + stkParams, err := paramCache.Get(del.ParamsVersion) + if err != nil { + return false, fmt.Errorf("unable to get the param version: %d, reason: %w", del.ParamsVersion, err) + } - for _, del := range dels { - delCopy := del - alreadySigned := false - for _, covSig := range delCopy.CovenantSigs { - remoteKey := schnorr.SerializePubKey(covSig.Pk) - if bytes.Equal(remoteKey, localKeyBytes) { - alreadySigned = true - break - } + for _, pk := range stkParams.CovenantPks { + remoteKey := schnorr.SerializePubKey(pk) + if !bytes.Equal(remoteKey, covenantSerializedPk) { + continue } - if !alreadySigned { - sanitized = append(sanitized, delCopy) + return true, nil + } + + return false, nil +} + +// CovenantAlreadySigned returns true if the covenant already signed the BTC Delegation +func CovenantAlreadySigned(covenantSerializedPk []byte, del *types.Delegation) bool { + for _, covSig := range del.CovenantSigs { + remoteKey := schnorr.SerializePubKey(covSig.Pk) + if !bytes.Equal(remoteKey, covenantSerializedPk) { + continue } + return true } - return sanitized + + return false } -// removeAlreadySigned removes any delegations that have already been signed by the covenant -func (ce *CovenantEmulator) removeAlreadySigned(dels []*types.Delegation) []*types.Delegation { - return RemoveAlreadySigned(ce.pk, dels) +// sanitizeDelegations removes any delegations that have already been signed by the covenant and +// remove delegations that were not constructed with this covenant public key +func (ce *CovenantEmulator) sanitizeDelegations(dels []*types.Delegation) ([]*types.Delegation, error) { + return SanitizeDelegations(ce.pk, ce.paramCache, dels) +} + +// SanitizeDelegations remove the delegations in which the covenant public key already signed +// or the delegation was not constructed with that covenant public key +func SanitizeDelegations( + pk *btcec.PublicKey, + paramCache ParamsGetter, + dels []*types.Delegation, +) ([]*types.Delegation, error) { + covenantSerializedPk := schnorr.SerializePubKey(pk) + + sanitized := make([]*types.Delegation, 0, len(dels)) + for _, del := range dels { + // 1. Remove delegations that do not need the covenant's signature because + // this covenant already signed + if CovenantAlreadySigned(covenantSerializedPk, del) { + continue + } + // 2. Remove delegations that were not constructed with this covenant public key + isInCommittee, err := IsKeyInCommittee(paramCache, covenantSerializedPk, del) + if err != nil { + return nil, fmt.Errorf("unable to verify if covenant key is in committee: %w", err) + } + if !isInCommittee { + continue + } + sanitized = append(sanitized, del) + } + + return sanitized, nil } // covenantSigSubmissionLoop is the reactor to submit Covenant signature for BTC delegations @@ -483,14 +528,32 @@ func (ce *CovenantEmulator) covenantSigSubmissionLoop() { continue } + pendingDels := len(dels) // record delegation metrics - ce.recordMetricsCurrentPendingDelegations(len(dels)) + ce.recordMetricsCurrentPendingDelegations(pendingDels) if len(dels) == 0 { ce.logger.Debug("no pending delegations are found") + continue } + // 2. Remove delegations that do not need the covenant's signature - sanitizedDels := ce.removeAlreadySigned(dels) + sanitizedDels, err := ce.sanitizeDelegations(dels) + if err != nil { + ce.logger.Error( + "error sanitizing delegations", + zap.Error(err), + ) + continue + } + + if len(sanitizedDels) == 0 { + ce.logger.Debug( + "no new delegations to sign", + zap.Int("pending_dels_len", pendingDels), + ) + continue + } // 3. Split delegations into batches for submission batches := ce.delegationsToBatches(sanitizedDels) @@ -532,32 +595,6 @@ func (ce *CovenantEmulator) metricsUpdateLoop() { } } -func (ce *CovenantEmulator) getParamsByVersionWithRetry(version uint32) (*types.StakingParams, error) { - var ( - params *types.StakingParams - err error - ) - - if err := retry.Do(func() error { - params, err = ce.cc.QueryStakingParamsByVersion(version) - if err != nil { - return err - } - return nil - }, RtyAtt, RtyDel, RtyErr, retry.OnRetry(func(n uint, err error) { - ce.logger.Debug( - "failed to query the consumer chain for the staking params", - zap.Uint("attempt", n+1), - zap.Uint("max_attempts", RtyAttNum), - zap.Error(err), - ) - })); err != nil { - return nil, err - } - - return params, nil -} - func (ce *CovenantEmulator) recordMetricsFailedSignDelegations(n int) { failedSignDelegations.WithLabelValues(ce.PublicKeyStr()).Add(float64(n)) } diff --git a/covenant/covenant_test.go b/covenant/covenant_test.go index eaa0f17..b7be357 100644 --- a/covenant/covenant_test.go +++ b/covenant/covenant_test.go @@ -2,8 +2,10 @@ package covenant_test import ( "encoding/hex" + "fmt" "math/rand" "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" @@ -15,6 +17,7 @@ import ( bbntypes "github.com/babylonlabs-io/babylon/types" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/wire" + "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/stretchr/testify/require" "go.uber.org/zap" @@ -226,6 +229,7 @@ func TestDeduplicationWithOddKey(t *testing.T) { require.NoError(t, err) pubKey := randomKey.PubKey() + paramVersion := uint32(2) delegations := []*types.Delegation{ &types.Delegation{ CovenantSigs: []*types.CovenantAdaptorSigInfo{ @@ -234,6 +238,7 @@ func TestDeduplicationWithOddKey(t *testing.T) { Pk: pubKeyFromSchnorr, }, }, + ParamsVersion: paramVersion, }, &types.Delegation{ CovenantSigs: []*types.CovenantAdaptorSigInfo{ @@ -241,10 +246,142 @@ func TestDeduplicationWithOddKey(t *testing.T) { Pk: pubKey, }, }, + ParamsVersion: paramVersion, }, } + paramsGet := NewMockParam(map[uint32]*types.StakingParams{ + paramVersion: &types.StakingParams{ + CovenantPks: []*secp256k1.PublicKey{oddKeyPub, pubKeyFromSchnorr}, + }, + }) + // 4. After removing the already signed delegation, the list should have only one element - sanitized := covenant.RemoveAlreadySigned(oddKeyPub, delegations) + sanitized, err := covenant.SanitizeDelegations(oddKeyPub, paramsGet, delegations) require.Equal(t, 1, len(sanitized)) + require.NoError(t, err) +} + +func TestIsKeyInCommittee(t *testing.T) { + r := rand.New(rand.NewSource(time.Now().Unix())) + + // create a Covenant key pair in the keyring + covenantConfig := covcfg.DefaultConfig() + covenantConfig.BabylonConfig.KeyDirectory = t.TempDir() + + covKeyPair, err := keyring.CreateCovenantKey( + covenantConfig.BabylonConfig.KeyDirectory, + covenantConfig.BabylonConfig.ChainID, + covenantConfig.BabylonConfig.Key, + covenantConfig.BabylonConfig.KeyringBackend, + passphrase, + hdPath, + ) + require.NoError(t, err) + covenantSerializedPk := schnorr.SerializePubKey(covKeyPair.PublicKey) + + // create params and version + pVersionWithoutCovenant := uint32(datagen.RandomInRange(r, 1, 10)) + pVersionWithCovenant := pVersionWithoutCovenant + 1 + + paramsWithoutCovenant := testutil.GenRandomParams(r, t) + paramsWithCovenant := testutil.GenRandomParams(r, t) + paramsWithCovenant.CovenantPks = append(paramsWithCovenant.CovenantPks, covKeyPair.PublicKey) + + // creates delegations to check + delNoCovenant := &types.Delegation{ + ParamsVersion: pVersionWithoutCovenant, + } + delWithCovenant := &types.Delegation{ + ParamsVersion: pVersionWithCovenant, + } + + // simple mock with the parameter versions + paramsGet := NewMockParam(map[uint32]*types.StakingParams{ + pVersionWithoutCovenant: paramsWithoutCovenant, + pVersionWithCovenant: paramsWithCovenant, + }) + + // checks the case where the covenant is NOT in the committee + actual, err := covenant.IsKeyInCommittee(paramsGet, covenantSerializedPk, delNoCovenant) + require.False(t, actual) + require.NoError(t, err) + emptyDels, err := covenant.SanitizeDelegations(covKeyPair.PublicKey, paramsGet, []*types.Delegation{delNoCovenant, delNoCovenant}) + require.NoError(t, err) + require.Len(t, emptyDels, 0) + + // checks the case where the covenant is in the committee + actual, err = covenant.IsKeyInCommittee(paramsGet, covenantSerializedPk, delWithCovenant) + require.True(t, actual) + require.NoError(t, err) + dels, err := covenant.SanitizeDelegations(covKeyPair.PublicKey, paramsGet, []*types.Delegation{delWithCovenant, delNoCovenant}) + require.NoError(t, err) + require.Len(t, dels, 1) + dels, err = covenant.SanitizeDelegations(covKeyPair.PublicKey, paramsGet, []*types.Delegation{delWithCovenant}) + require.NoError(t, err) + require.Len(t, dels, 1) + + amtSatFirst := btcutil.Amount(100) + amtSatSecond := btcutil.Amount(150) + amtSatThird := btcutil.Amount(200) + lastUnsanitizedDels := []*types.Delegation{ + &types.Delegation{ + ParamsVersion: pVersionWithCovenant, + TotalSat: amtSatFirst, + }, + delNoCovenant, + &types.Delegation{ + ParamsVersion: pVersionWithCovenant, + TotalSat: amtSatSecond, + }, + delNoCovenant, + &types.Delegation{ + ParamsVersion: pVersionWithCovenant, + TotalSat: amtSatThird, + }, + } + + sanitizedDels, err := covenant.SanitizeDelegations(covKeyPair.PublicKey, paramsGet, lastUnsanitizedDels) + require.NoError(t, err) + require.Len(t, sanitizedDels, 3) + require.Equal(t, amtSatFirst, sanitizedDels[0].TotalSat) + require.Equal(t, amtSatSecond, sanitizedDels[1].TotalSat) + require.Equal(t, amtSatThird, sanitizedDels[2].TotalSat) + + errParamGet := fmt.Errorf("dumbErr") + sanitizedDels, err = covenant.SanitizeDelegations(covKeyPair.PublicKey, NewMockParamError(errParamGet), lastUnsanitizedDels) + require.Nil(t, sanitizedDels) + + errKeyIsInCommittee := fmt.Errorf("unable to get the param version: %d, reason: %s", pVersionWithCovenant, errParamGet.Error()) + expErr := fmt.Errorf("unable to verify if covenant key is in committee: %s", errKeyIsInCommittee.Error()) + require.EqualError(t, err, expErr.Error()) +} + +type MockParamGetter struct { + paramsByVersion map[uint32]*types.StakingParams +} + +func NewMockParam(p map[uint32]*types.StakingParams) *MockParamGetter { + return &MockParamGetter{ + paramsByVersion: p, + } +} + +func (m *MockParamGetter) Get(version uint32) (*types.StakingParams, error) { + p := m.paramsByVersion[version] + return p, nil +} + +type MockParamError struct { + err error +} + +func NewMockParamError(err error) *MockParamError { + return &MockParamError{ + err: err, + } +} + +func (m *MockParamError) Get(version uint32) (*types.StakingParams, error) { + return nil, m.err }