diff --git a/CHANGELOG.md b/CHANGELOG.md index 716da1ac..0814c807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * [#580](https://github.com/babylonlabs-io/finality-provider/pull/580) chore: bump bbn for v2 compatibility * [#572](https://github.com/babylonlabs-io/finality-provider/pull/572) chore(rollup): ensure pub randomness is timestamped +* [#583](https://github.com/babylonlabs-io/finality-provider/pull/583) + feat(rollup): sparse pub rand generation * [#584](https://github.com/babylonlabs-io/finality-provider/pull/584) chore: bump go 1.24 * [#586](https://github.com/babylonlabs-io/finality-provider/pull/586) chore: init fp metrics diff --git a/bsn/rollup/clientcontroller/consumer.go b/bsn/rollup/clientcontroller/consumer.go index ce6c9da2..1578d1df 100644 --- a/bsn/rollup/clientcontroller/consumer.go +++ b/bsn/rollup/clientcontroller/consumer.go @@ -103,8 +103,8 @@ func (cc *RollupBSNController) ReliablySendMsg(ctx context.Context, msg sdk.Msg, return cc.reliablySendMsgs(ctx, []sdk.Msg{msg}, expectedErrs, unrecoverableErrs) } -// queryContractConfig queries the finality contract for its config -func (cc *RollupBSNController) queryContractConfig(ctx context.Context) (*ContractConfig, error) { +// QueryContractConfig queries the finality contract for its config +func (cc *RollupBSNController) QueryContractConfig(ctx context.Context) (*ContractConfig, error) { query := QueryMsg{ Config: &ContractConfig{}, } @@ -589,6 +589,12 @@ func (cc *RollupBSNController) QueryPubRandCommitForHeight(ctx context.Context, if err != nil { return nil, fmt.Errorf("failed to unmarshal response: %w", err) } + if resp == nil { + return nil, nil + } + if err := resp.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate response: %w", err) + } return resp, nil } @@ -596,7 +602,7 @@ func (cc *RollupBSNController) QueryPubRandCommitForHeight(ctx context.Context, // isEligibleForFinalitySignature checks if finality signatures are allowed for the given height // based on the contract's BSN activation and interval requirements func (cc *RollupBSNController) isEligibleForFinalitySignature(ctx context.Context, height uint64) (bool, error) { - config, err := cc.queryContractConfig(ctx) + config, err := cc.QueryContractConfig(ctx) if err != nil { return false, fmt.Errorf("failed to query contract config: %w", err) } @@ -628,7 +634,7 @@ func (cc *RollupBSNController) isEligibleForFinalitySignature(ctx context.Contex } func (cc *RollupBSNController) QueryFinalityActivationBlockHeight(ctx context.Context) (uint64, error) { - config, err := cc.queryContractConfig(ctx) + config, err := cc.QueryContractConfig(ctx) if err != nil { return 0, fmt.Errorf("failed to query contract config: %w", err) } diff --git a/bsn/rollup/clientcontroller/msg.go b/bsn/rollup/clientcontroller/msg.go index 4200bab6..48f71345 100644 --- a/bsn/rollup/clientcontroller/msg.go +++ b/bsn/rollup/clientcontroller/msg.go @@ -1,6 +1,10 @@ package clientcontroller -import "fmt" +import ( + "fmt" + + "github.com/babylonlabs-io/finality-provider/types" +) type CommitPublicRandomnessMsg struct { CommitPublicRandomness CommitPublicRandomnessMsgParams `json:"commit_public_randomness"` @@ -73,20 +77,35 @@ type HighestVotedHeightQuery struct { BtcPkHex string `json:"btc_pk_hex"` } +var _ types.PubRandCommit = &RollupPubRandCommit{} + type RollupPubRandCommit struct { StartHeight uint64 `json:"start_height"` NumPubRand uint64 `json:"num_pub_rand"` - Commitment []byte `json:"commitment"` + Interval uint64 `json:"interval"` BabylonEpoch uint64 `json:"babylon_epoch"` + Commitment []byte `json:"commitment"` +} + +// EndHeight returns the last height for which randomness actually exists in this commitment. +// For sparse commitments, randomness is generated only at specific intervals, not consecutively. +// +// Example with StartHeight=60, NumPubRand=5, Interval=5: +// - Randomness exists for heights: 60, 65, 70, 75, 80 +// - EndHeight() returns 80 (the last height with actual randomness) +// - Heights 61-64, 66-69, 71-74, 76-79, 81+ have NO randomness +// +// The ShouldCommit function is responsible for computing the next eligible start height +// and ensuring no gaps or overlaps by using proper alignment logic. +func (r *RollupPubRandCommit) EndHeight() uint64 { + return r.StartHeight + (r.NumPubRand-1)*r.Interval } -// Interface implementation -func (r *RollupPubRandCommit) EndHeight() uint64 { return r.StartHeight + r.NumPubRand - 1 } func (r *RollupPubRandCommit) Validate() error { if r.NumPubRand < 1 { return fmt.Errorf("NumPubRand must be >= 1, got %d", r.NumPubRand) } - + return nil } diff --git a/bsn/rollup/e2e/bytecode/finality.wasm b/bsn/rollup/e2e/bytecode/finality.wasm index 28a6ebcd..7bdb1fa0 100644 Binary files a/bsn/rollup/e2e/bytecode/finality.wasm and b/bsn/rollup/e2e/bytecode/finality.wasm differ diff --git a/bsn/rollup/e2e/rollup_test_manager.go b/bsn/rollup/e2e/rollup_test_manager.go index 405519b4..dbd25ce9 100644 --- a/bsn/rollup/e2e/rollup_test_manager.go +++ b/bsn/rollup/e2e/rollup_test_manager.go @@ -502,16 +502,23 @@ func (ctm *OpL2ConsumerTestManager) getConsumerFpInstance( fpMetrics := metrics.NewFpMetrics() poller := service.NewChainPoller(ctm.logger, fpCfg.PollerConfig, ctm.RollupBSNController, fpMetrics) - rndCommitter := service.NewDefaultRandomnessCommitter( + // For E2E tests, use RollupRandomnessCommitter to test our sparse generation + contractConfig, err := ctm.RollupBSNController.QueryContractConfig(context.Background()) + require.NoError(t, err) + + rndCommitter := service.NewRollupRandomnessCommitter( service.NewRandomnessCommitterConfig(fpCfg.NumPubRand, int64(fpCfg.TimestampingDelayBlocks), fpCfg.ContextSigningHeight), - service.NewPubRandState(pubRandStore), ctm.RollupBSNController, ctm.ConsumerEOTSClient, ctm.logger, fpMetrics) + service.NewPubRandState(pubRandStore), ctm.RollupBSNController, ctm.ConsumerEOTSClient, ctm.logger, fpMetrics, + contractConfig.FinalitySignatureInterval) heightDeterminer := service.NewStartHeightDeterminer(ctm.RollupBSNController, fpCfg.PollerConfig, ctm.logger) - finalitySubmitter := service.NewDefaultFinalitySubmitter(ctm.RollupBSNController, ctm.ConsumerEOTSClient, rndCommitter.GetPubRandProofList, + + // For E2E tests, use RollupFinalitySubmitter to test sparse randomness generation + finalitySubmitter := service.NewRollupFinalitySubmitter(ctm.RollupBSNController, ctm.ConsumerEOTSClient, rndCommitter.GetPubRandProofList, service.NewDefaultFinalitySubmitterConfig(fpCfg.MaxSubmissionRetries, fpCfg.ContextSigningHeight, fpCfg.SubmissionRetryInterval), - ctm.logger, fpMetrics) + ctm.logger, fpMetrics, contractConfig.FinalitySignatureInterval) fpInstance, err := service.NewFinalityProviderInstance( consumerFpPk, diff --git a/bsn/rollup/service/app.go b/bsn/rollup/service/app.go index 540fa305..7d65ae2c 100644 --- a/bsn/rollup/service/app.go +++ b/bsn/rollup/service/app.go @@ -1,6 +1,7 @@ package service import ( + "context" "fmt" rollupfpcc "github.com/babylonlabs-io/finality-provider/bsn/rollup/clientcontroller" @@ -50,17 +51,32 @@ func NewRollupBSNFinalityProviderAppFromConfig( return nil, fmt.Errorf("failed to initiate public randomness store: %w", err) } - rndCommitter := service.NewDefaultRandomnessCommitter( + // For rollup environments, always use RollupRandomnessCommitter + contractConfig, err := consumerCon.QueryContractConfig(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to query contract config: %w", err) + } + + logger.Info("using RollupRandomnessCommitter for rollup environment", + zap.Uint64("finality_signature_interval", contractConfig.FinalitySignatureInterval)) + + rndCommitter := service.NewRollupRandomnessCommitter( service.NewRandomnessCommitterConfig(cfg.Common.NumPubRand, int64(cfg.Common.TimestampingDelayBlocks), cfg.Common.ContextSigningHeight), service.NewPubRandState(pubRandStore), consumerCon, em, logger, fpMetrics, + contractConfig.FinalitySignatureInterval, ) heightDeterminer := service.NewStartHeightDeterminer(consumerCon, cfg.Common.PollerConfig, logger) - finalitySubmitter := service.NewDefaultFinalitySubmitter(consumerCon, + + logger.Info("using RollupFinalitySubmitter for rollup environment", + zap.Uint64("finality_signature_interval", contractConfig.FinalitySignatureInterval)) + + // For rollup environments, use RollupFinalitySubmitter for sparse randomness generation + finalitySubmitter := service.NewRollupFinalitySubmitter(consumerCon, em, rndCommitter.GetPubRandProofList, service.NewDefaultFinalitySubmitterConfig(cfg.Common.MaxSubmissionRetries, @@ -68,6 +84,7 @@ func NewRollupBSNFinalityProviderAppFromConfig( cfg.Common.SubmissionRetryInterval), logger, fpMetrics, + contractConfig.FinalitySignatureInterval, ) fpApp, err := service.NewFinalityProviderApp(cfg.Common, cc, consumerCon, em, poller, rndCommitter, heightDeterminer, finalitySubmitter, fpMetrics, db, logger) diff --git a/eotsmanager/client/rpcclient.go b/eotsmanager/client/rpcclient.go index 4520d87e..e2a9612a 100644 --- a/eotsmanager/client/rpcclient.go +++ b/eotsmanager/client/rpcclient.go @@ -90,6 +90,29 @@ func (c *EOTSManagerGRpcClient) CreateRandomnessPairList(uid, chainID []byte, st return pubRandFieldValList, nil } +func (c *EOTSManagerGRpcClient) CreateRandomnessPairListWithInterval(uid, chainID []byte, startHeight uint64, num uint32, interval uint64) ([]*btcec.FieldVal, error) { + // For now, implement using existing RPC by calling individual heights + // TODO: Later can add dedicated GRPC method for efficiency + // https://github.com/babylonlabs-io/finality-provider/issues/590 + pubRandList := make([]*btcec.FieldVal, 0, num) + + for i := uint32(0); i < num; i++ { + height := startHeight + uint64(i)*interval + // We request exactly 1 randomness value per height since we're generating sparse randomness + // for specific heights (startHeight, startHeight+interval, startHeight+2*interval, etc.) + singleList, err := c.CreateRandomnessPairList(uid, chainID, height, 1) + if err != nil { + return nil, fmt.Errorf("failed to create randomness for height %d: %w", height, err) + } + if len(singleList) != 1 { + return nil, fmt.Errorf("expected 1 randomness value, got %d", len(singleList)) + } + pubRandList = append(pubRandList, singleList[0]) + } + + return pubRandList, nil +} + func (c *EOTSManagerGRpcClient) SaveEOTSKeyName(pk *btcec.PublicKey, keyName string) error { req := &proto.SaveEOTSKeyNameRequest{ KeyName: keyName, diff --git a/eotsmanager/eotsmanager.go b/eotsmanager/eotsmanager.go index ca887d73..2c6b5b6f 100644 --- a/eotsmanager/eotsmanager.go +++ b/eotsmanager/eotsmanager.go @@ -14,6 +14,14 @@ type EOTSManager interface { // block height CreateRandomnessPairList(uid []byte, chainID []byte, startHeight uint64, num uint32) ([]*btcec.FieldVal, error) + // CreateRandomnessPairListWithInterval generates a list of Schnorr randomness pairs with intervals + // from startHeight, startHeight+interval, startHeight+2*interval, etc. where num means the number of public randomness + // It fails if the finality provider does not exist or a randomness pair has been created before + // or passPhrase is incorrect + // NOTE: the randomness is deterministically generated based on the EOTS key, chainID and + // block height. This method is used for rollup FPs that only vote on specific intervals. + CreateRandomnessPairListWithInterval(uid []byte, chainID []byte, startHeight uint64, num uint32, interval uint64) ([]*btcec.FieldVal, error) + // SignEOTS signs an EOTS using the private key of the finality provider and the corresponding // secret randomness of the given chain at the given height // It fails if the finality provider does not exist or there's no randomness committed to the given height. diff --git a/eotsmanager/localmanager.go b/eotsmanager/localmanager.go index b0eaab64..8459beaf 100644 --- a/eotsmanager/localmanager.go +++ b/eotsmanager/localmanager.go @@ -215,6 +215,28 @@ func (lm *LocalEOTSManager) CreateRandomnessPairList(fpPk []byte, chainID []byte return prList, nil } +// CreateRandomnessPairListWithInterval generates a list of public randomness pairs with a given interval. +// It creates keys for heights starting from startHeight and incrementing by interval for num entries. +// For example, with startHeight=100, num=3, interval=5, it generates keys for heights [100, 105, 110]. +func (lm *LocalEOTSManager) CreateRandomnessPairListWithInterval(fpPk []byte, chainID []byte, startHeight uint64, num uint32, interval uint64) ([]*btcec.FieldVal, error) { + prList := make([]*btcec.FieldVal, 0, num) + + for i := uint32(0); i < num; i++ { + // KEY DIFFERENCE: height increments by interval, not 1 + height := startHeight + uint64(i)*interval // 100, 105, 110, 115... + _, pubRand, err := lm.getRandomnessPair(fpPk, chainID, height) + if err != nil { + return nil, err + } + + prList = append(prList, pubRand) + } + lm.metrics.IncrementEotsFpTotalGeneratedRandomnessCounter(hex.EncodeToString(fpPk)) + lm.metrics.SetEotsFpLastGeneratedRandomnessHeight(hex.EncodeToString(fpPk), float64(startHeight)) + + return prList, nil +} + func (lm *LocalEOTSManager) SignEOTS(eotsPk []byte, chainID []byte, msg []byte, height uint64) (*btcec.ModNScalar, error) { record, found, err := lm.es.GetSignRecord(eotsPk, chainID, height) if err != nil { diff --git a/finality-provider/service/fp_instance.go b/finality-provider/service/fp_instance.go index f688918e..879cc8d2 100644 --- a/finality-provider/service/fp_instance.go +++ b/finality-provider/service/fp_instance.go @@ -4,15 +4,11 @@ import ( "context" "errors" "fmt" - "github.com/btcsuite/btcd/btcec/v2" "sync" "time" "github.com/avast/retry-go/v4" bbntypes "github.com/babylonlabs-io/babylon/v3/types" - "go.uber.org/atomic" - "go.uber.org/zap" - ccapi "github.com/babylonlabs-io/finality-provider/clientcontroller/api" "github.com/babylonlabs-io/finality-provider/eotsmanager" fpcfg "github.com/babylonlabs-io/finality-provider/finality-provider/config" @@ -20,6 +16,9 @@ import ( "github.com/babylonlabs-io/finality-provider/finality-provider/store" "github.com/babylonlabs-io/finality-provider/metrics" "github.com/babylonlabs-io/finality-provider/types" + "github.com/btcsuite/btcd/btcec/v2" + "go.uber.org/atomic" + "go.uber.org/zap" ) type FinalityProviderInstance struct { diff --git a/finality-provider/service/pub_rand_store_adapter.go b/finality-provider/service/pub_rand_store_adapter.go index c4d9dfc8..1c071a77 100644 --- a/finality-provider/service/pub_rand_store_adapter.go +++ b/finality-provider/service/pub_rand_store_adapter.go @@ -26,6 +26,17 @@ func (st *PubRandState) addPubRandProofList( return nil } +func (st *PubRandState) addPubRandProofListWithInterval( + pk, chainID []byte, startHeight uint64, numPubRand uint64, + proofList []*merkle.Proof, interval uint64, +) error { + if err := st.s.AddPubRandProofListWithInterval(chainID, pk, startHeight, numPubRand, proofList, interval); err != nil { + return fmt.Errorf("failed to add pub rand proof list with interval: %w", err) + } + + return nil +} + func (st *PubRandState) getPubRandProof(pk, chainID []byte, height uint64) ([]byte, error) { proof, err := st.s.GetPubRandProof(chainID, pk, height) if err != nil { diff --git a/finality-provider/service/rollup_finality_submitter.go b/finality-provider/service/rollup_finality_submitter.go new file mode 100644 index 00000000..7860c23c --- /dev/null +++ b/finality-provider/service/rollup_finality_submitter.go @@ -0,0 +1,240 @@ +package service + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + fpcc "github.com/babylonlabs-io/finality-provider/clientcontroller" + "github.com/babylonlabs-io/finality-provider/clientcontroller/api" + "github.com/babylonlabs-io/finality-provider/eotsmanager" + "github.com/babylonlabs-io/finality-provider/metrics" + "github.com/babylonlabs-io/finality-provider/types" + "github.com/btcsuite/btcd/btcec/v2" + "go.uber.org/zap" +) + +// RollupFinalitySubmitter is a finality submitter for rollup FPs that uses sparse randomness generation +// It generates randomness only for heights where the FP will vote (based on finality signature interval) +type RollupFinalitySubmitter struct { + *DefaultFinalitySubmitter + interval uint64 +} + +func NewRollupFinalitySubmitter( + consumerCtrl api.ConsumerController, + em eotsmanager.EOTSManager, + proofListGetterFunc PubRandProofListGetterFunc, + cfg *FinalitySubmitterConfig, + logger *zap.Logger, + metrics *metrics.FpMetrics, + interval uint64, +) *RollupFinalitySubmitter { + return &RollupFinalitySubmitter{ + DefaultFinalitySubmitter: NewDefaultFinalitySubmitter(consumerCtrl, em, proofListGetterFunc, cfg, logger, metrics), + interval: interval, + } +} + +// GetPubRandList overrides the default implementation to use sparse generation +// This ensures the randomness retrieval matches the sparse commitment pattern +func (rfs *RollupFinalitySubmitter) GetPubRandList(startHeight uint64, numPubRand uint32) ([]*btcec.FieldVal, error) { + pubRandList, err := rfs.em.CreateRandomnessPairListWithInterval( + rfs.getBtcPkBIP340().MustMarshal(), + rfs.state.GetChainID(), + startHeight, + numPubRand, + rfs.interval, + ) + if err != nil { + return nil, fmt.Errorf("failed to create sparse public randomness list: %w", err) + } + + return pubRandList, nil +} + +// SubmitBatchFinalitySignatures overrides the default implementation to ensure +// our sparse GetPubRandList method is called throughout the submission process +func (rfs *RollupFinalitySubmitter) SubmitBatchFinalitySignatures(ctx context.Context, blocks []types.BlockDescription) (*types.TxResponse, error) { + if len(blocks) == 0 { + return nil, fmt.Errorf("cannot send signatures for empty blocks") + } + + blocks, err := rfs.filterBlocksForVoting(ctx, blocks) + if err != nil { + return nil, fmt.Errorf("failed to filter blocks for voting: %w", err) + } + + if len(blocks) == 0 { + rfs.logger.Debug( + "no blocks to vote for after filtering", + zap.String("pk", rfs.getBtcPkHex()), + zap.Uint64("last_voted_height", rfs.state.GetLastVotedHeight()), + ) + + return nil, nil // No blocks to vote for + } + + var failedCycles uint32 + targetHeight := blocks[len(blocks)-1].GetHeight() + + // Retry loop with internal retry logic (copied from DefaultFinalitySubmitter) + for { + res, err := rfs.submitBatchFinalitySignaturesOnce(ctx, blocks) + if err != nil { + rfs.logger.Debug( + "failed to submit finality signature to the consumer chain", + zap.String("pk", rfs.getBtcPkHex()), + zap.Uint32("current_failures", failedCycles), + zap.Uint64("target_start_height", blocks[0].GetHeight()), + zap.Uint64("target_end_height", targetHeight), + zap.Error(err), + ) + + // Handle different error types + if fpcc.IsUnrecoverable(err) { + return nil, err + } + + if fpcc.IsExpected(err) { + return nil, nil + } + + failedCycles++ + if failedCycles > rfs.cfg.MaxSubmissionRetries { + return nil, fmt.Errorf("reached max failed cycles with err: %w", err) + } + } else { + // The signature has been successfully submitted + return res, nil + } + + // Check if the block is already finalized + finalized, err := rfs.checkBlockFinalization(ctx, targetHeight) + if err != nil { + return nil, fmt.Errorf("failed to query block finalization at height %v: %w", targetHeight, err) + } + if finalized { + rfs.logger.Debug( + "the block is already finalized, skip submission", + zap.String("pk", rfs.getBtcPkHex()), + zap.Uint64("target_height", targetHeight), + ) + + rfs.metrics.IncrementFpTotalFailedVotes(rfs.getBtcPkHex()) + + return nil, nil + } + + // Wait for the retry interval + select { + case <-time.After(rfs.cfg.SubmissionRetryInterval): + // Continue to next retry iteration + continue + case <-ctx.Done(): + rfs.logger.Debug("the finality-provider instance is closing", zap.String("pk", rfs.getBtcPkHex())) + + return nil, ErrFinalityProviderShutDown + } + } +} + +// submitBatchFinalitySignaturesOnce overrides to ensure our GetPubRandList method is called +func (rfs *RollupFinalitySubmitter) submitBatchFinalitySignaturesOnce(ctx context.Context, blocks []types.BlockDescription) (*types.TxResponse, error) { + if len(blocks) == 0 { + return nil, fmt.Errorf("should not submit batch finality signature with zero block") + } + + // Get proofs and public randomness for each block + var proofBytesList [][]byte + var prList []*btcec.FieldVal + + for _, block := range blocks { + // Get public randomness for this specific height using sparse generation method + // We request exactly 1 randomness value since we need randomness for this single block height + pr, err := rfs.GetPubRandList(block.GetHeight(), 1) + if err != nil { + return nil, fmt.Errorf("failed to get public randomness for height %d: %w", block.GetHeight(), err) + } + prList = append(prList, pr[0]) + + // Get proof for this specific height + proofs, err := rfs.proofListGetterFunc(block.GetHeight(), 1) + if err != nil { + return nil, fmt.Errorf("failed to get public randomness inclusion proof for height %d: %w\nplease recover the randomness proof from db", block.GetHeight(), err) + } + if len(proofs) != 1 { + return nil, fmt.Errorf("expected exactly one proof for height %d, got %d", block.GetHeight(), len(proofs)) + } + proofBytesList = append(proofBytesList, proofs[0]) + } + + // Process each block and collect only valid items + var validBlocks []types.BlockDescription + var validPrList []*btcec.FieldVal + var validProofList [][]byte + var validSigList []*btcec.ModNScalar + + for i, b := range blocks { + eotsSig, err := rfs.signFinalitySig(b) + if err != nil { + if !errors.Is(err, ErrFailedPrecondition) { + return nil, err + } + // Skip this block if we encounter FailedPrecondition + rfs.logger.Warn("encountered FailedPrecondition error, skipping block", + zap.Uint64("height", b.GetHeight()), + zap.String("hash", hex.EncodeToString(b.GetHash())), + zap.Error(err)) + + continue + } + + // If signature is valid, append all corresponding items + validBlocks = append(validBlocks, b) + validPrList = append(validPrList, prList[i]) + validProofList = append(validProofList, proofBytesList[i]) + validSigList = append(validSigList, eotsSig.ToModNScalar()) + } + + // If all blocks were skipped, return early + if len(validBlocks) == 0 { + rfs.logger.Info("all blocks were skipped due to double sign errors") + + return nil, nil + } + + // Send finality signature to the consumer chain + res, err := rfs.consumerCtrl.SubmitBatchFinalitySigs(ctx, api.NewSubmitBatchFinalitySigsRequest( + rfs.getBtcPk(), + validBlocks, + validPrList, + validProofList, + validSigList, + )) + + if err != nil { + if strings.Contains(err.Error(), "jailed") { + return nil, ErrFinalityProviderJailed + } + if strings.Contains(err.Error(), "slashed") { + return nil, ErrFinalityProviderSlashed + } + + return nil, fmt.Errorf("failed to submit finality signature to the consumer chain: %w", err) + } + + // Update the metrics with voted blocks + for _, b := range validBlocks { + rfs.metrics.RecordFpVotedHeight(rfs.getBtcPkHex(), b.GetHeight()) + } + + // Update state with the highest height of this batch + highBlock := blocks[len(blocks)-1] + rfs.mustSetLastVotedHeight(highBlock.GetHeight()) + + return res, nil +} diff --git a/finality-provider/service/rollup_rand_committer.go b/finality-provider/service/rollup_rand_committer.go new file mode 100644 index 00000000..1dabb4bf --- /dev/null +++ b/finality-provider/service/rollup_rand_committer.go @@ -0,0 +1,238 @@ +package service + +import ( + "context" + "fmt" + + ccapi "github.com/babylonlabs-io/finality-provider/clientcontroller/api" + "github.com/babylonlabs-io/finality-provider/eotsmanager" + "github.com/babylonlabs-io/finality-provider/metrics" + "github.com/babylonlabs-io/finality-provider/types" + "github.com/btcsuite/btcd/btcec/v2" + "go.uber.org/zap" +) + +// RollupRandomnessCommitter is a randomness committer for rollup FPs that supports sparse generation +// It generates randomness only for heights where the FP will vote (based on finality signature interval) +type RollupRandomnessCommitter struct { + *DefaultRandomnessCommitter + interval uint64 +} + +func NewRollupRandomnessCommitter( + cfg *RandomnessCommitterConfig, + pubRandState *PubRandState, + consumerCon ccapi.ConsumerController, + em eotsmanager.EOTSManager, + logger *zap.Logger, + metrics *metrics.FpMetrics, + interval uint64, +) *RollupRandomnessCommitter { + return &RollupRandomnessCommitter{ + DefaultRandomnessCommitter: NewDefaultRandomnessCommitter(cfg, pubRandState, consumerCon, em, logger, metrics), + interval: interval, + } +} + +// ShouldCommit overrides the default implementation with rollup-specific logic +// that directly calculates aligned startHeight without redundant parent calls +func (rrc *RollupRandomnessCommitter) ShouldCommit(ctx context.Context) (bool, uint64, error) { + // Get last committed height (same as parent) + lastCommittedHeight, err := rrc.GetLastCommittedHeight(ctx) + if err != nil { + return false, 0, fmt.Errorf("failed to get last committed height: %w", err) + } + + // Get current tip height (same as parent) + tipBlock, err := rrc.consumerCon.QueryLatestBlock(ctx) + if tipBlock == nil || err != nil { + return false, 0, fmt.Errorf("failed to get the last block: %w", err) + } + + if rrc.cfg.TimestampingDelayBlocks < 0 { + return false, 0, fmt.Errorf("TimestampingDelayBlocks cannot be negative: %d", rrc.cfg.TimestampingDelayBlocks) + } + + // Get activation height first for interval-aware calculations + activationBlkHeight, err := rrc.consumerCon.QueryFinalityActivationBlockHeight(ctx) + if err != nil { + return false, 0, fmt.Errorf("failed to query finality activation block height: %w", err) + } + + // Calculate tip height with delay + tipHeightWithDelay := tipBlock.GetHeight() + uint64(rrc.cfg.TimestampingDelayBlocks) // #nosec G115 + + // ROLLUP-SPECIFIC: Determine startHeight with interval awareness from the beginning + var alignedStartHeight uint64 + switch { + case lastCommittedHeight < tipHeightWithDelay: + // Need to start from tipHeightWithDelay, but align it to voting schedule + baseHeight := max(tipHeightWithDelay, activationBlkHeight) + alignedStartHeight = rrc.calculateFirstEligibleHeightWithActivation(baseHeight, activationBlkHeight) + + case rrc.needsMoreVotingRandomness(lastCommittedHeight, tipHeightWithDelay, activationBlkHeight): + // Need to continue from where we left off, but with interval spacing + // For sparse generation, we need to check if we have enough *voting* heights covered + baseHeight := max(lastCommittedHeight+1, activationBlkHeight) + alignedStartHeight = rrc.calculateFirstEligibleHeightWithActivation(baseHeight, activationBlkHeight) + + default: + // Check if we have sufficient voting randomness, not just any randomness + // Calculate the last voting height we have randomness for + lastVotingHeight := rrc.getLastVotingHeightWithRandomness(lastCommittedHeight, activationBlkHeight) + requiredVotingHeight := tipHeightWithDelay + uint64(rrc.cfg.NumPubRand)*rrc.interval + + if lastVotingHeight >= requiredVotingHeight { + // Sufficient voting randomness, no need to commit + rrc.logger.Debug( + "the rollup finality-provider has sufficient voting randomness, skip committing more", + zap.String("pk", rrc.btcPk.MarshalHex()), + zap.Uint64("tip_height", tipBlock.GetHeight()), + zap.Uint64("last_committed_height", lastCommittedHeight), + zap.Uint64("last_voting_height", lastVotingHeight), + zap.Uint64("required_voting_height", requiredVotingHeight), + ) + + return false, 0, nil + } + + // Need more voting randomness + baseHeight := max(lastCommittedHeight+1, activationBlkHeight) + alignedStartHeight = rrc.calculateFirstEligibleHeightWithActivation(baseHeight, activationBlkHeight) + } + + rrc.logger.Debug( + "the rollup finality-provider should commit randomness", + zap.String("pk", rrc.btcPk.MarshalHex()), + zap.Uint64("tip_height", tipBlock.GetHeight()), + zap.Uint64("last_committed_height", lastCommittedHeight), + zap.Uint64("aligned_start_height", alignedStartHeight), + zap.Uint64("interval", rrc.interval), + ) + + return true, alignedStartHeight, nil +} + +// needsMoreVotingRandomness determines if we need more voting randomness +// by checking if our last committed voting height can cover the required buffer +func (rrc *RollupRandomnessCommitter) needsMoreVotingRandomness(lastCommittedHeight, tipHeightWithDelay, activationHeight uint64) bool { + // Find the last voting height we have randomness for + lastVotingHeight := rrc.getLastVotingHeightWithRandomness(lastCommittedHeight, activationHeight) + + // Find the next voting height we need to cover after tipHeightWithDelay + nextRequiredVotingHeight := rrc.calculateFirstEligibleHeightWithActivation(tipHeightWithDelay, activationHeight) + + // We need randomness for NumPubRand voting heights starting from nextRequiredVotingHeight + requiredVotingHeight := nextRequiredVotingHeight + (uint64(rrc.cfg.NumPubRand)-1)*rrc.interval + + return lastVotingHeight < requiredVotingHeight +} + +// getLastVotingHeightWithRandomness calculates the last voting height for which we have randomness +// based on the lastCommittedHeight and interval spacing +func (rrc *RollupRandomnessCommitter) getLastVotingHeightWithRandomness(lastCommittedHeight, activationHeight uint64) uint64 { + if lastCommittedHeight < activationHeight { + return 0 // No voting randomness yet + } + + // For sparse generation, we need to find the last voting height <= lastCommittedHeight + // that aligns with the voting schedule + offset := lastCommittedHeight - activationHeight + votingHeightIndex := offset / rrc.interval + + return activationHeight + votingHeightIndex*rrc.interval +} + +// getPubRandList overrides the default implementation to use sparse generation +// startHeight is already aligned by ShouldCommit, so we can use it directly +func (rrc *RollupRandomnessCommitter) getPubRandList(startHeight uint64, numPubRand uint32) ([]*btcec.FieldVal, error) { + pubRandList, err := rrc.em.CreateRandomnessPairListWithInterval( + rrc.btcPk.MustMarshal(), + rrc.cfg.ChainID, + startHeight, // Already aligned by ShouldCommit + numPubRand, + rrc.interval, + ) + if err != nil { + return nil, fmt.Errorf("failed to create randomness pair list with interval: %w", err) + } + + return pubRandList, nil +} + +// calculateFirstEligibleHeightWithActivation finds the first height >= startHeight that is eligible for voting +// using the provided activation height (avoids redundant contract queries) +func (rrc *RollupRandomnessCommitter) calculateFirstEligibleHeightWithActivation(startHeight, activationHeight uint64) uint64 { + // If startHeight is before activation, first eligible is activation height + if startHeight <= activationHeight { + return activationHeight + } + + // Calculate the first eligible height at or after startHeight + // Formula: activationHeight + n*interval where n is chosen so result >= startHeight + offset := startHeight - activationHeight + remainder := offset % rrc.interval + + if remainder == 0 { + // startHeight is already aligned + return startHeight + } + + // Round up to next aligned height + return startHeight + (rrc.interval - remainder) +} + +// Commit overrides the default implementation to use interval-aware storage +// startHeight is already aligned by ShouldCommit, so we can use it directly +func (rrc *RollupRandomnessCommitter) Commit(ctx context.Context, startHeight uint64) (*types.TxResponse, error) { + // Generate sparse randomness aligned with voting schedule + // startHeight is already aligned by ShouldCommit + pubRandList, err := rrc.getPubRandList(startHeight, rrc.cfg.NumPubRand) + if err != nil { + return nil, fmt.Errorf("failed to generate randomness: %w", err) + } + numPubRand := uint64(len(pubRandList)) + + // Generate commitment and proof for each public randomness (same as default) + commitment, proofList := types.GetPubRandCommitAndProofs(pubRandList) + + // Store them to database with interval-aware keys + // startHeight is already aligned, so proofs are stored at the correct voting heights + if err := rrc.pubRandState.addPubRandProofListWithInterval( + rrc.btcPk.MustMarshal(), + rrc.cfg.ChainID, + startHeight, // Already aligned by ShouldCommit + uint64(rrc.cfg.NumPubRand), + proofList, + rrc.interval, + ); err != nil { + return nil, fmt.Errorf("failed to save public randomness to DB: %w", err) + } + + // Sign the commitment using the aligned startHeight + schnorrSig, err := rrc.signPubRandCommit(startHeight, numPubRand, commitment) + if err != nil { + return nil, fmt.Errorf("failed to sign the Schnorr signature: %w", err) + } + + // Submit to consumer chain using the aligned startHeight + res, err := rrc.consumerCon.CommitPubRandList(ctx, &ccapi.CommitPubRandListRequest{ + FpPk: rrc.btcPk.MustToBTCPK(), + StartHeight: startHeight, + NumPubRand: numPubRand, + Commitment: commitment, + Sig: schnorrSig, + }) + if err != nil { + return nil, fmt.Errorf("failed to commit public randomness to the consumer chain: %w", err) + } + + // Update metrics using aligned heights + rrc.metrics.RecordFpRandomnessTime(rrc.btcPk.MarshalHex()) + // For sparse generation, the last height is startHeight + (numPubRand-1)*interval + lastHeight := startHeight + (numPubRand-1)*rrc.interval + rrc.metrics.RecordFpLastCommittedRandomnessHeight(rrc.btcPk.MarshalHex(), lastHeight) + rrc.metrics.AddToFpTotalCommittedRandomness(rrc.btcPk.MarshalHex(), float64(len(pubRandList))) + + return res, nil +} diff --git a/finality-provider/store/pub_rand.go b/finality-provider/store/pub_rand.go index 79dfb059..dd9952b6 100644 --- a/finality-provider/store/pub_rand.go +++ b/finality-provider/store/pub_rand.go @@ -79,6 +79,18 @@ func buildKeys(chainID, pk []byte, height uint64, num uint64) [][]byte { return keys } +func buildKeysWithInterval(chainID, pk []byte, startHeight uint64, num uint64, interval uint64) [][]byte { + keys := make([][]byte, 0, num) + + for i := uint64(0); i < num; i++ { + height := startHeight + i*interval // 100, 105, 110, 115... + key := getKey(chainID, pk, height) + keys = append(keys, key) + } + + return keys +} + func (s *PubRandProofStore) AddPubRandProofList( chainID []byte, pk []byte, @@ -122,6 +134,53 @@ func (s *PubRandProofStore) AddPubRandProofList( return nil } +// AddPubRandProofListWithInterval adds a list of public randomness proofs to the store with a given interval. +// It creates keys for heights starting from startHeight and incrementing by interval for numPubRand entries. +// For example, with startHeight=100, numPubRand=3, interval=5, it generates keys for heights [100, 105, 110]. +func (s *PubRandProofStore) AddPubRandProofListWithInterval( + chainID []byte, + pk []byte, + startHeight uint64, + numPubRand uint64, + proofList []*merkle.Proof, + interval uint64, +) error { + keys := buildKeysWithInterval(chainID, pk, startHeight, numPubRand, interval) + + if len(keys) != len(proofList) { + return fmt.Errorf("the number of public randomness is not same as the number of proofs") + } + + var proofBytesList [][]byte + for _, proof := range proofList { + proofBytes, err := proof.ToProto().Marshal() + if err != nil { + return fmt.Errorf("invalid proof: %w", err) + } + proofBytesList = append(proofBytesList, proofBytes) + } + + if err := kvdb.Batch(s.db, func(tx kvdb.RwTx) error { + bucket := tx.ReadWriteBucket(pubRandProofBucketName) + if bucket == nil { + return ErrCorruptedPubRandProofDB + } + + for i, key := range keys { + // set to DB + if err := bucket.Put(key, proofBytesList[i]); err != nil { + return fmt.Errorf("failed to store pub rand proof: %w", err) + } + } + + return nil + }); err != nil { + return fmt.Errorf("failed to add pub rand proof list with interval: %w", err) + } + + return nil +} + func (s *PubRandProofStore) GetPubRandProof(chainID []byte, pk []byte, height uint64) ([]byte, error) { key := getKey(chainID, pk, height) var proofBytes []byte