diff --git a/pkg/capabilities/consensus/ocr3/datafeeds/feeds_aggregator.go b/pkg/capabilities/consensus/ocr3/datafeeds/feeds_aggregator.go index d5cd51beab..e76adbb95e 100644 --- a/pkg/capabilities/consensus/ocr3/datafeeds/feeds_aggregator.go +++ b/pkg/capabilities/consensus/ocr3/datafeeds/feeds_aggregator.go @@ -38,6 +38,8 @@ const ( TimestampOutputFieldName = EVMEncoderKey("Timestamp") RemappedIDOutputFieldName = EVMEncoderKey("RemappedID") StreamIDOutputFieldName = EVMEncoderKey("StreamID") + DataIDOutputFieldName = EVMEncoderKey("DataID") + AnswerOutputFieldName = EVMEncoderKey("Answer") addrLen = 20 ) diff --git a/pkg/capabilities/consensus/ocr3/datafeeds/securemint_aggregator.go b/pkg/capabilities/consensus/ocr3/datafeeds/securemint_aggregator.go new file mode 100644 index 0000000000..d552582cbc --- /dev/null +++ b/pkg/capabilities/consensus/ocr3/datafeeds/securemint_aggregator.go @@ -0,0 +1,486 @@ +package datafeeds + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "strconv" + "strings" + + chainselectors "github.com/smartcontractkit/chain-selectors" + ocrcommon "github.com/smartcontractkit/libocr/commontypes" + ocr3types "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/capabilities/consensus/ocr3/types" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/types/chains/solana" + "github.com/smartcontractkit/chainlink-common/pkg/types/core/securemint" + "github.com/smartcontractkit/chainlink-protos/cre/go/values" +) + +type SolanaEncoderKey = string + +const ( + /* + OutputFormat for solana: + "account_context_hash": <"hash">, + "payload": []reports{timestamp uint32, answer *big.Int, dataId [16]byte } + Solana encoder compatible idl config: + encoderConfig := map[string]any{ + report_schema": `{ + "kind": "struct", + "fields": [ + { "name": "payload", "type": { "vec": { "defined": "DecimalReport" } } } + ] + }`, + "defined_types": `[ + { + "name":"DecimalReport", + "type":{ + "kind":"struct", + "fields":[ + { "name":"timestamp", "type":"u32" }, + { "name":"answer", "type":"u128" }, + { "name": "dataId", "type": {"array": ["u8",16]}} + ] + } + } + ]`, + } + + */ + TopLevelPayloadListFieldName = SolanaEncoderKey("payload") + TopLevelAccountCtxHashFieldName = SolanaEncoderKey("account_context_hash") + SolTimestampOutputFieldName = SolanaEncoderKey("timestamp") + SolAnswerOutputFieldName = SolanaEncoderKey("answer") + SolDataIDOutputFieldName = SolanaEncoderKey("dataId") +) + +type wrappedMintReport struct { + Report securemint.Report `json:"report"` + SolanaAccountContext solana.AccountMetaSlice `json:"solanaAccountContext,omitempty"` +} + +// SecureMintAggregatorConfig is the config for the SecureMint aggregator. +// This aggregator is designed to pick out reports for a specific chain selector. +type SecureMintAggregatorConfig struct { + // TargetChainSelector is the chain selector to look for + TargetChainSelector securemint.ChainSelector `mapstructure:"targetChainSelector"` + DataID [16]byte `mapstructure:"dataID"` +} + +// ToMap converts the SecureMintAggregatorConfig to a values.Map, which is suitable for the +// [NewAggregator] function in the OCR3 Aggregator interface. +func (c SecureMintAggregatorConfig) ToMap() (*values.Map, error) { + v, err := values.WrapMap(c) + if err != nil { + // this should never happen since we are wrapping a struct + return &values.Map{}, fmt.Errorf("failed to wrap SecureMintAggregatorConfig: %w", err) + } + return v, nil +} + +var _ types.Aggregator = (*SecureMintAggregator)(nil) + +type SecureMintAggregator struct { + config SecureMintAggregatorConfig + formatters *formatterFactory +} + +type chainReportFormatter interface { + packReport(lggr logger.Logger, report *wrappedMintReport) (*values.Map, error) +} + +type evmReportFormatter struct { + targetChainSelector securemint.ChainSelector + dataID [16]byte +} + +func (f *evmReportFormatter) packReport(lggr logger.Logger, wreport *wrappedMintReport) (*values.Map, error) { + report := wreport.Report + smReportAsAnswer, err := packSecureMintReportIntoUint224ForEVM(report.Mintable, uint64(report.Block)) + if err != nil { + return nil, fmt.Errorf("failed to pack secure mint report for evm into uint224: %w", err) + } + + lggr.Debugw("packed report into answer", "smReportAsAnswer", smReportAsAnswer) + + // This is what the DF Cache contract expects: + // abi: "(bytes16 dataId, uint32 timestamp, uint224 answer)[] Reports" + toWrap := []any{ + map[EVMEncoderKey]any{ + DataIDOutputFieldName: f.dataID, + AnswerOutputFieldName: smReportAsAnswer, + TimestampOutputFieldName: uint32(report.SeqNr), + }, + } + + wrappedReport, err := values.NewMap(map[string]any{ + TopLevelListOutputFieldName: toWrap, + }) + if err != nil { + return nil, fmt.Errorf("failed to wrap report: %w", err) + } + + return wrappedReport, nil +} + +func newEVMReportFormatter(chainSelector securemint.ChainSelector, config SecureMintAggregatorConfig) chainReportFormatter { + return &evmReportFormatter{targetChainSelector: chainSelector, dataID: config.DataID} +} + +type solanaReportFormatter struct { + targetChainSelector securemint.ChainSelector + dataID [16]byte +} + +func (f *solanaReportFormatter) packReport(lggr logger.Logger, wreport *wrappedMintReport) (*values.Map, error) { + report := wreport.Report + // pack answer + smReportAsAnswer, err := packSecureMintReportIntoU128ForSolana(report.Mintable, uint64(report.Block)) + if err != nil { + return nil, fmt.Errorf("failed to pack secure mint report for solana into u128: %w", err) + } + lggr.Debugw("packed report into answer", "smReportAsAnswer", smReportAsAnswer) + + // hash account contexts + var accounts = make([]byte, 0) + for _, acc := range wreport.SolanaAccountContext { + accounts = append(accounts, acc.PublicKey[:]...) + } + lggr.Debugf("accounts length: %d", len(wreport.SolanaAccountContext)) + accountContextHash := sha256.Sum256(accounts) + lggr.Debugw("calculated account context hash", "accountContextHash", accountContextHash) + + if report.SeqNr > (1<<32 - 1) { // timestamp must fit in u32 in solana + return nil, fmt.Errorf("timestamp exceeds u32 bounds: %v", report.SeqNr) + } + + toWrap := []any{ + map[SolanaEncoderKey]any{ + SolTimestampOutputFieldName: uint32(report.SeqNr), + SolAnswerOutputFieldName: smReportAsAnswer, + SolDataIDOutputFieldName: f.dataID, + }, + } + + wrappedReport, err := values.NewMap(map[string]any{ + TopLevelAccountCtxHashFieldName: accountContextHash, + TopLevelPayloadListFieldName: toWrap, + }) + + if err != nil { + return nil, fmt.Errorf("failed to wrap report: %w", err) + } + + return wrappedReport, nil +} + +func newSolanaReportFormatter(chainSelector securemint.ChainSelector, config SecureMintAggregatorConfig) chainReportFormatter { + return &solanaReportFormatter{targetChainSelector: chainSelector, dataID: config.DataID} +} + +// chainReportFormatterBuilder is a function that returns a chainReportFormatter for a given chain selector and config +type chainReportFormatterBuilder func(chainSelector securemint.ChainSelector, config SecureMintAggregatorConfig) chainReportFormatter + +type formatterFactory struct { + builders map[securemint.ChainSelector]chainReportFormatterBuilder +} + +// register registers a new chain report formatter builder for a given chain selector +func (r *formatterFactory) register(chSel securemint.ChainSelector, builder chainReportFormatterBuilder) { + r.builders[chSel] = builder +} + +// get uses a chain report formatter builder to create a chain report formatter +func (r *formatterFactory) get(chSel securemint.ChainSelector, config SecureMintAggregatorConfig) (chainReportFormatter, error) { + b, ok := r.builders[chSel] + if !ok { + return nil, fmt.Errorf("no formatter registered for chain selector: %d", chSel) + } + + return b(chSel, config), nil +} + +// newFormatterFactory collects all chain report formatters per chain family so that they can be used to pack reports for different chains +func newFormatterFactory() *formatterFactory { + r := formatterFactory{ + builders: map[securemint.ChainSelector]chainReportFormatterBuilder{}, + } + + // EVM + for _, selector := range chainselectors.EvmChainIdToChainSelector() { + r.register(securemint.ChainSelector(selector), newEVMReportFormatter) + } + + // Solana + for _, selector := range chainselectors.SolanaChainIdToChainSelector() { + r.register(securemint.ChainSelector(selector), newSolanaReportFormatter) + } + + return &r +} + +// NewSecureMintAggregator creates a new SecureMintAggregator instance based on the provided configuration. +// The config should be a [values.Map] that represents the [SecureMintAggregatorConfig]. See [SecureMintAggregatorConfig.ToMap] +func NewSecureMintAggregator(config values.Map) (types.Aggregator, error) { + parsedConfig, err := parseSecureMintConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to parse config (%+v): %w", config, err) + } + registry := newFormatterFactory() + + return &SecureMintAggregator{ + config: parsedConfig, + formatters: registry, + }, nil +} + +// Aggregate implements the Aggregator interface +// This implementation: +// 1. Extracts OCRTriggerEvent from observations +// 2. Deserializes the inner ReportWithInfo to get chain selector and report +// 3. Validates chain selector matches target and sequence number is higher than previous +// 4. Returns the report in the format expected by the DF Cache, packing the mintable and block number into the decimal 'answer' field +func (a *SecureMintAggregator) Aggregate(lggr logger.Logger, previousOutcome *types.AggregationOutcome, observations map[ocrcommon.OracleID][]values.Value, f int) (*types.AggregationOutcome, error) { + lggr = logger.Named(lggr, "SecureMintAggregator") + + lggr.Debugw("Aggregate called", "config", a.config, "observations", len(observations), "f", f, "previousOutcome", previousOutcome) + + if len(observations) == 0 { + return nil, errors.New("no observations") + } + + // Extract and validate reports from all observations + validReports, err := a.extractAndValidateReports(lggr, observations, previousOutcome) + if err != nil { + return nil, fmt.Errorf("failed to extract and validate reports: %w", err) + } + + if len(validReports) == 0 { + lggr.Infow("no reports selected", "targetChainSelector", a.config.TargetChainSelector) + return &types.AggregationOutcome{ + ShouldReport: false, + }, nil + } + + // Take the first valid report + targetReport := validReports[0] + + // Create the aggregation outcome + outcome, err := a.createOutcome(lggr, targetReport) + if err != nil { + return nil, fmt.Errorf("failed to create outcome: %w", err) + } + + lggr.Debugw("SecureMint Aggregate complete", "targetChainSelector", a.config.TargetChainSelector) + return outcome, nil +} + +type ObsWithCtx struct { + Event capabilities.OCRTriggerEvent `mapstructure:"event"` + Solana solana.AccountMetaSlice `mapstructure:"solana"` +} + +// extractAndValidateReports extracts OCRTriggerEvent from observations and validates them +func (a *SecureMintAggregator) extractAndValidateReports(lggr logger.Logger, observations map[ocrcommon.OracleID][]values.Value, previousOutcome *types.AggregationOutcome) ([]*wrappedMintReport, error) { + var validReports []*wrappedMintReport + var foundMatchingChainSelector bool + + for nodeID, nodeObservations := range observations { + lggr = logger.With(lggr, "nodeID", nodeID) + + for _, observation := range nodeObservations { + lggr.Debugw("processing observation", "observation", observation) + + // Extract OCRTriggerEvent from the observations + + obsWithContext := &ObsWithCtx{} + + if err := observation.UnwrapTo(obsWithContext); err != nil { + lggr.Warnw("could not unwrap OCRTriggerEvent", "err", err, "observation", observation) + continue + } + + lggr.Debugw("Obs with context", "obs with ctx", obsWithContext) + + // Deserialize the ReportWithInfo + var reportWithInfo ocr3types.ReportWithInfo[securemint.ChainSelector] + if err := json.Unmarshal(obsWithContext.Event.Report, &reportWithInfo); err != nil { + lggr.Errorw("failed to unmarshal ReportWithInfo", "err", err) + continue + } + + // Check if chain selector matches target + if reportWithInfo.Info != a.config.TargetChainSelector { + lggr.Debugw("chain selector mismatch", "got", reportWithInfo.Info, "expected", a.config.TargetChainSelector) + continue + } + + // We found a matching chain selector + foundMatchingChainSelector = true + + // Deserialize the inner secureMintReport + var innerReport securemint.Report + if err := json.Unmarshal(reportWithInfo.Report, &innerReport); err != nil { + lggr.Errorw("failed to unmarshal securemint.Report", "err", err) + continue + } + report := &wrappedMintReport{ + Report: innerReport, + SolanaAccountContext: obsWithContext.Solana, + } + + validReports = append(validReports, report) + } + } + + // Return appropriate error based on what we found + if !foundMatchingChainSelector { + lggr.Infow("no reports found for target chain selector, ignoring", "targetChainSelector", a.config.TargetChainSelector) + return nil, nil + } + + return validReports, nil +} + +// createOutcome creates the final aggregation outcome which can be sent to the KeystoneForwarder +func (a *SecureMintAggregator) createOutcome(lggr logger.Logger, report *wrappedMintReport) (*types.AggregationOutcome, error) { + lggr = logger.Named(lggr, "SecureMintAggregator") + lggr.Debugw("createOutcome called", "report", report) + + reportFormatter, err := a.formatters.get( + a.config.TargetChainSelector, + a.config, + ) + if err != nil { + return nil, fmt.Errorf("encountered issue fetching report formatter in createOutcome %w", err) + } + + wrappedReport, err := reportFormatter.packReport(lggr, report) + + if err != nil { + return nil, fmt.Errorf("encountered issue generating report in createOutcome %w", err) + } + + reportsProto := values.Proto(wrappedReport) + + // Store the sequence number in metadata for next round + metadata := []byte{byte(report.Report.SeqNr)} // Simple metadata for now + + aggOutcome := &types.AggregationOutcome{ + EncodableOutcome: reportsProto.GetMapValue(), + Metadata: metadata, + LastSeenAt: report.Report.SeqNr, + ShouldReport: true, // Always report since we found and verified the target report + } + + lggr.Debugw("SecureMint AggregationOutcome created", "aggOutcome", aggOutcome) + return aggOutcome, nil +} + +// parseSecureMintConfig parses the user-facing, type-less, SecureMint aggregator config into the internal typed config. +func parseSecureMintConfig(config values.Map) (SecureMintAggregatorConfig, error) { + type rawConfig struct { + TargetChainSelector string `mapstructure:"targetChainSelector"` + DataID string `mapstructure:"dataID"` + } + + var rawCfg rawConfig + if err := config.UnwrapTo(&rawCfg); err != nil { + return SecureMintAggregatorConfig{}, fmt.Errorf("failed to unwrap values.Map %+v: %w", config, err) + } + + if rawCfg.TargetChainSelector == "" { + return SecureMintAggregatorConfig{}, errors.New("targetChainSelector is required") + } + + sel, err := strconv.ParseUint(rawCfg.TargetChainSelector, 10, 64) + if err != nil { + return SecureMintAggregatorConfig{}, fmt.Errorf("invalid chain selector: %w", err) + } + + if rawCfg.DataID == "" { + return SecureMintAggregatorConfig{}, errors.New("dataID is required") + } + + // strip 0x prefix if present + dataID := strings.TrimPrefix(rawCfg.DataID, "0x") + + decodedDataID, err := hex.DecodeString(dataID) + if err != nil { + return SecureMintAggregatorConfig{}, fmt.Errorf("invalid dataID: %v %w", dataID, err) + } + + if len(decodedDataID) != 16 { + return SecureMintAggregatorConfig{}, fmt.Errorf("dataID must be 16 bytes, got %d", len(decodedDataID)) + } + + parsedConfig := SecureMintAggregatorConfig{ + TargetChainSelector: securemint.ChainSelector(sel), + DataID: [16]byte(decodedDataID), + } + + return parsedConfig, nil +} + +var maxMintableEVM = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 128), big.NewInt(1)) // 2^128 - 1 + +// packSecureMintReportIntoUint224ForEVM packs the mintable and block number into a single uint224 so that it can be used as a price in the DF Cache contract +// (top 32 - not used / middle 64 - block number / lower 128 - mintable amount) +func packSecureMintReportIntoUint224ForEVM(mintable *big.Int, blockNumber uint64) (*big.Int, error) { + // Handle nil mintable + if mintable == nil { + return nil, errors.New("mintable cannot be nil") + } + + // Validate that mintable fits in 128 bits + if mintable.Cmp(maxMintableEVM) > 0 { + return nil, fmt.Errorf("mintable amount %v exceeds maximum 128-bit value %v", mintable, maxMintableEVM) + } + + packed := big.NewInt(0) + // Put mintable in lower 128 bits + packed.Or(packed, mintable) + + // Put block number in middle 64 bits (bits 128-191) + blockNumberAsBigInt := new(big.Int).SetUint64(blockNumber) + packed.Or(packed, new(big.Int).Lsh(blockNumberAsBigInt, 128)) + + return packed, nil +} + +var maxMintableSolana = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 91), big.NewInt(1)) // 2^91 - 1 +var maxBlockNumberSolana uint64 = 1<<36 - 1 // 2^36 - 1 + +// TODO: will ripcord be added for top bit? +// (top 1 - not used / middle 36 - block number / lower 91 - mintable amount) +func packSecureMintReportIntoU128ForSolana(mintable *big.Int, blockNumber uint64) (*big.Int, error) { + // Handle nil mintable + if mintable == nil { + return nil, errors.New("mintable cannot be nil") + } + + // Validate that mintable fits in 91 bits + if mintable.Cmp(maxMintableSolana) > 0 { + return nil, fmt.Errorf("mintable amount %v exceeds maximum 91-bit value %v", mintable, maxMintableSolana) + } + + packed := big.NewInt(0) + // Put mintable in lower 91 bits + packed.Or(packed, mintable) + + if blockNumber > maxBlockNumberSolana { + return nil, fmt.Errorf("block number %d exceeds maximum 36-bit value %d", blockNumber, maxBlockNumberSolana) + } + + // Put block number in middle 36 bits (bits 91-126) + blockNumberAsBigInt := new(big.Int).SetUint64(blockNumber) + packed.Or(packed, new(big.Int).Lsh(blockNumberAsBigInt, 91)) + + return packed, nil +} diff --git a/pkg/capabilities/consensus/ocr3/datafeeds/securemint_aggregator_test.go b/pkg/capabilities/consensus/ocr3/datafeeds/securemint_aggregator_test.go new file mode 100644 index 0000000000..e046660752 --- /dev/null +++ b/pkg/capabilities/consensus/ocr3/datafeeds/securemint_aggregator_test.go @@ -0,0 +1,631 @@ +package datafeeds + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/capabilities/consensus/ocr3/types" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/types/chains/solana" + "github.com/smartcontractkit/chainlink-common/pkg/types/core/securemint" + ocrcommon "github.com/smartcontractkit/libocr/commontypes" + ocr2types "github.com/smartcontractkit/libocr/offchainreporting2/types" + ocr3types "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" + + "github.com/smartcontractkit/chainlink-protos/cre/go/values" +) + +var ( + // Test chain selectors + ethSepoliaChainSelector = securemint.ChainSelector(16015286601757825753) // Ethereum Sepolia testnet + bnbTestnetChainSelector = securemint.ChainSelector(13264668187771770619) // Binance Smart Chain testnet + solDevnetChainSelector = securemint.ChainSelector(16423721717087811551) // Solana devnet +) + +func TestSecureMintAggregator_Aggregate(t *testing.T) { + lggr := logger.Test(t) + + type tcase struct { + name string + chainSelector string + dataID string + solAccounts [][32]byte + previousOutcome *types.AggregationOutcome + seqNr uint64 + observations map[ocrcommon.OracleID][]values.Value + f int + expectedShouldReport bool + expectError bool + errorContains string + shouldReportAssertFn func(t *testing.T, tc tcase, topLevelMap map[string]any) + } + acc1 := [32]byte{4, 5, 6} + acc2 := [32]byte{3, 2, 1} + + ethReportAssertFn := func(t *testing.T, tc tcase, topLevelMap map[string]any) { + // Check that we have the expected reports + reportsList, ok := topLevelMap[TopLevelListOutputFieldName].([]any) + require.True(t, ok) + assert.Len(t, reportsList, 1) + + // Check the first (and only) report + report, ok := reportsList[0].(map[string]any) + assert.True(t, ok) + + // Verify dataID + dataIDBytes, ok := report[DataIDOutputFieldName].([]byte) + assert.True(t, ok, "expected dataID to be []byte but got %T", report[DataIDOutputFieldName]) + assert.Len(t, dataIDBytes, 16) + assert.Equal(t, tc.dataID, "0x"+hex.EncodeToString(dataIDBytes)) + + // Verify other fields exist + answer, ok := report[AnswerOutputFieldName].(*big.Int) + assert.True(t, ok) + assert.NotNil(t, answer) + + timestamp := report[TimestampOutputFieldName].(int64) + assert.Equal(t, int64(tc.seqNr), timestamp) + } + + solReportAssertFn := func(t *testing.T, tc tcase, topLevelMap map[string]any) { + // Check that we have the expected reports + reportsList, ok := topLevelMap[TopLevelPayloadListFieldName].([]any) + assert.True(t, ok) + assert.Len(t, reportsList, 1) + + // Check that we have expected account hash + accHash, ok := topLevelMap[TopLevelAccountCtxHashFieldName].([]byte) + require.True(t, ok, "expected account hash to be []byte but got %T", topLevelMap[TopLevelAccountCtxHashFieldName]) + require.Len(t, accHash, 32) + expHash := sha256.Sum256(append(acc1[:], acc2[:]...)) + assert.Equal(t, expHash, ([32]byte)(accHash)) + + // Check the first (and only) report + report, ok := reportsList[0].(map[string]any) + assert.True(t, ok) + // Verify dataID + dataIDBytes, ok := report[SolDataIDOutputFieldName].([]byte) + assert.True(t, ok, "expected dataID to be []byte but got %T", report[DataIDOutputFieldName]) + assert.Len(t, dataIDBytes, 16) + assert.Equal(t, tc.dataID, "0x"+hex.EncodeToString(dataIDBytes)) + + // Verify other fields exist + answer, ok := report[SolAnswerOutputFieldName].(*big.Int) + assert.True(t, ok) + assert.NotNil(t, answer) + + timestamp := report[SolTimestampOutputFieldName].(int64) + assert.Equal(t, int64(tc.seqNr), timestamp) + } + + tests := []tcase{ + { + name: "successful eth report extraction", + chainSelector: "16015286601757825753", + dataID: "0x01c508f42b0201320000000000000000", + seqNr: 10, + observations: createSecureMintObservations(t, []ocrTriggerEventData{ + { + chainSelector: ethSepoliaChainSelector, + seqNr: 10, + report: &securemint.Report{ + ConfigDigest: ocr2types.ConfigDigest{0: 1, 31: 2}, + SeqNr: 10, + Block: 1000, + Mintable: big.NewInt(99), + }, + }, + { + chainSelector: bnbTestnetChainSelector, + seqNr: 10, + report: &securemint.Report{ + ConfigDigest: ocr2types.ConfigDigest{0: 2, 31: 3}, + SeqNr: 10, + Block: 1100, + Mintable: big.NewInt(200), + }, + }, + }), + f: 1, + expectedShouldReport: true, + expectError: false, + shouldReportAssertFn: ethReportAssertFn, + }, + { + name: "no matching chain selector found", + chainSelector: "16015286601757825753", + dataID: "0x01c508f42b0201320000000000000000", + seqNr: 10, + observations: createSecureMintObservations(t, []ocrTriggerEventData{ + { + chainSelector: bnbTestnetChainSelector, + seqNr: 10, + report: &securemint.Report{ + ConfigDigest: ocr2types.ConfigDigest{0: 1, 31: 2}, + SeqNr: 10, + Block: 1000, + Mintable: big.NewInt(99), + }, + }, + }), + f: 1, + expectError: false, + expectedShouldReport: false, + shouldReportAssertFn: ethReportAssertFn, + }, + { + name: "no observations", + chainSelector: "16015286601757825753", + dataID: "0x01c508f42b0201320000000000000000", + seqNr: 10, + observations: map[ocrcommon.OracleID][]values.Value{}, + f: 1, + expectError: true, + errorContains: "no observations", + }, + { + name: "successful sol report extraction", + chainSelector: "16423721717087811551", // solana devnet + dataID: "0x01c508f42b0201320000000000000000", + seqNr: 10, + solAccounts: [][32]byte{acc1, acc2}, + observations: createSecureMintObservations(t, []ocrTriggerEventData{ + { + chainSelector: solDevnetChainSelector, + seqNr: 10, + report: &securemint.Report{ + ConfigDigest: ocr2types.ConfigDigest{0: 1, 31: 2}, + SeqNr: 10, + Block: 1000, + Mintable: big.NewInt(99), + }, + accCtx: solana.AccountMetaSlice{&solana.AccountMeta{PublicKey: acc1}, &solana.AccountMeta{PublicKey: acc2}}, + }, + { + chainSelector: bnbTestnetChainSelector, + seqNr: 10, + report: &securemint.Report{ + ConfigDigest: ocr2types.ConfigDigest{0: 2, 31: 3}, + SeqNr: 10, + Block: 1100, + Mintable: big.NewInt(200), + }, + accCtx: solana.AccountMetaSlice{&solana.AccountMeta{PublicKey: acc1}, &solana.AccountMeta{PublicKey: acc2}}, + }, + }), + f: 1, + expectedShouldReport: true, + expectError: false, + shouldReportAssertFn: solReportAssertFn, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Create aggregator + rawCfg := map[string]any{ + "targetChainSelector": tc.chainSelector, + "dataID": tc.dataID, + } + if len(tc.solAccounts) > 0 { + accountMetaSlice := make(solana.AccountMetaSlice, len(tc.solAccounts)) + for i, acc := range tc.solAccounts { + accountMetaSlice[i] = &solana.AccountMeta{PublicKey: acc} + } + + rawCfg["solana"] = map[string]any{ + "remaining_accounts": accountMetaSlice, + } + } + + configMap, err := values.WrapMap(rawCfg) + require.NoError(t, err) + aggregator, err := NewSecureMintAggregator(*configMap) + require.NoError(t, err) + + // Run aggregation + outcome, err := aggregator.Aggregate(lggr, tc.previousOutcome, tc.observations, tc.f) + + // Check error expectations + if tc.expectError { + assert.Error(t, err) + if tc.errorContains != "" { + assert.Contains(t, err.Error(), tc.errorContains) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedShouldReport, outcome.ShouldReport) + + if outcome.ShouldReport { + // Verify the output structure matches the feeds aggregator format + val, err := values.FromMapValueProto(outcome.EncodableOutcome) + require.NoError(t, err) + + topLevelMap, err := val.Unwrap() + require.NoError(t, err) + mm, ok := topLevelMap.(map[string]any) + require.True(t, ok) + + tc.shouldReportAssertFn(t, tc, mm) + } + }) + } +} + +func TestSecureMintAggregatorConfig_Validation(t *testing.T) { + acc1 := [32]byte{4, 5, 6} + + tests := []struct { + name string + chainSelector string + dataID string + solanaAccounts solana.AccountMetaSlice + expectedChainSelector securemint.ChainSelector + expectedDataID [16]byte + expectError bool + errorMsg string + }{ + { + name: "valid chain selector, dataID and solana accounts", + chainSelector: "1", + dataID: "0x01c508f42b0201320000000000000000", + solanaAccounts: solana.AccountMetaSlice{&solana.AccountMeta{PublicKey: acc1, IsWritable: true, IsSigner: false}}, + expectedChainSelector: 1, + expectedDataID: [16]byte{0x01, 0xc5, 0x08, 0xf4, 0x2b, 0x02, 0x01, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + expectError: false, + }, + { + name: "large chain selector", + chainSelector: "16015286601757825753", // ethereum-testnet-sepolia + dataID: "0x01c508f42b0201320000000000000000", + expectedChainSelector: 16015286601757825753, + expectedDataID: [16]byte{0x01, 0xc5, 0x08, 0xf4, 0x2b, 0x02, 0x01, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + expectError: false, + }, + { + name: "dataID without 0x prefix", + chainSelector: "1", + dataID: "01c508f42b0201320000000000000000", + expectedChainSelector: 1, + expectedDataID: [16]byte{0x01, 0xc5, 0x08, 0xf4, 0x2b, 0x02, 0x01, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + expectError: false}, + { + name: "invalid chain selector", + chainSelector: "invalid", + expectError: true, + errorMsg: "invalid chain selector", + }, + { + name: "negative chain selector", + chainSelector: "-1", + dataID: "0x01c508f42b0201320000000000000000", + expectError: true, + errorMsg: "invalid chain selector", + }, + { + name: "invalid dataID", + chainSelector: "1", + dataID: "invalid_data_id", + expectError: true, + errorMsg: "invalid dataID", + }, + { + name: "dataID too short", + chainSelector: "1", + dataID: "0x0000", + expectError: true, + errorMsg: "dataID must be 16 bytes", + }, + { + name: "dataID with odd length", + chainSelector: "1", + dataID: "0x0", + expectError: true, + errorMsg: "odd length hex string", + }, + { + name: "dataID too long", + chainSelector: "1", + dataID: "0x01111111111111111111111111111111111111111111", + expectError: true, + errorMsg: "dataID must be 16 bytes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rawCfg := map[string]any{ + "targetChainSelector": tt.chainSelector, + "dataID": tt.dataID, + } + if len(tt.solanaAccounts) > 0 { + rawCfg["solana"] = map[string]any{ + "remaining_accounts": tt.solanaAccounts, + } + } + + configMap, err := values.WrapMap(rawCfg) + require.NoError(t, err) + + aggregator, err := NewSecureMintAggregator(*configMap) + if tt.expectError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expectedChainSelector, aggregator.(*SecureMintAggregator).config.TargetChainSelector) + assert.Equal(t, tt.expectedDataID, aggregator.(*SecureMintAggregator).config.DataID) + }) + } +} + +// Helper types and functions + +type ocrTriggerEventData struct { + chainSelector securemint.ChainSelector + seqNr uint64 + report *securemint.Report + accCtx solana.AccountMetaSlice +} + +func createSecureMintObservations(t *testing.T, events []ocrTriggerEventData) map[ocrcommon.OracleID][]values.Value { + observations := make(map[ocrcommon.OracleID][]values.Value) + + // Create three observations with identical data to ensure f+1 consensus + for i := ocrcommon.OracleID(1); i <= 3; i++ { + // For each oracle, create observations for all events + var oracleObservations []values.Value + for _, event := range events { + // Create the ReportWithInfo + ocr3Report := &ocr3types.ReportWithInfo[securemint.ChainSelector]{ + Report: createReportBytes(t, event.report), + Info: event.chainSelector, + } + + // Marshal the ReportWithInfo + jsonReport, err := json.Marshal(ocr3Report) + require.NoError(t, err) + + // Create the OCRTriggerEvent + triggerEvent := &capabilities.OCRTriggerEvent{ + ConfigDigest: event.report.ConfigDigest[:], + SeqNr: event.seqNr, + Report: jsonReport, + Sigs: []capabilities.OCRAttributedOnchainSignature{ + { + Signature: []byte("signature1"), + Signer: 1, + }, + { + Signature: []byte("signature2"), + Signer: 2, + }, + }, + } + + // wrap with account context if present + val, err := values.Wrap(map[string]any{ + "event": triggerEvent, + "solana": event.accCtx, + }) + require.NoError(t, err) + oracleObservations = append(oracleObservations, val) + } + + observations[i] = oracleObservations + } + + return observations +} + +func createReportBytes(t *testing.T, report *securemint.Report) []byte { + reportBytes, err := json.Marshal(report) + require.NoError(t, err) + return reportBytes +} + +func TestPackSecureMintReportForIntoUint224(t *testing.T) { + tests := []struct { + name string + mintable *big.Int + blockNumber uint64 + expected *big.Int + expectError bool + errorMsg string + }{ + { + name: "zero values", + mintable: big.NewInt(0), + blockNumber: 0, + expected: big.NewInt(0), + expectError: false, + }, + { + name: "small positive values", + mintable: big.NewInt(100), + blockNumber: 12345, + expected: new(big.Int).Add(big.NewInt(100), new(big.Int).Lsh(big.NewInt(12345), 128)), + expectError: false, + }, + { + name: "maximum mintable value (2^128 - 1)", + mintable: new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 128), big.NewInt(1)), + blockNumber: 999999, + expected: new(big.Int).Add( + new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 128), big.NewInt(1)), + new(big.Int).Lsh(big.NewInt(999999), 128), + ), + expectError: false, + }, + { + name: "large block number", + mintable: big.NewInt(500), + blockNumber: 18446744073709551615, // max uint64 + expected: new(big.Int).Add(big.NewInt(500), new(big.Int).Lsh(new(big.Int).SetUint64(18446744073709551615), 128)), + expectError: false, + }, + { + name: "mintable exceeds 128 bits", + mintable: new(big.Int).Lsh(big.NewInt(1), 128), // 2^128 + blockNumber: 1000, + expectError: true, + errorMsg: "mintable amount", + }, + { + name: "very large mintable that exceeds 128 bits", + mintable: new(big.Int).Lsh(big.NewInt(1), 256), // 2^256 + blockNumber: 1000, + expectError: true, + errorMsg: "mintable amount", + }, + { + name: "nil mintable", + mintable: nil, + blockNumber: 1000, + expectError: true, + errorMsg: "mintable cannot be nil", + }, + { + name: "bit pattern verification - mintable 1, block 1", + mintable: big.NewInt(1), + blockNumber: 1, + expected: new(big.Int).Add(big.NewInt(1), new(big.Int).Lsh(big.NewInt(1), 128)), + expectError: false, + }, + { + name: "bit pattern verification - mintable 0xFFFFFFFF, block 0xFFFFFFFF", + mintable: big.NewInt(0xFFFFFFFF), + blockNumber: 0xFFFFFFFF, + expected: new(big.Int).Add(big.NewInt(0xFFFFFFFF), new(big.Int).Lsh(big.NewInt(0xFFFFFFFF), 128)), + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := packSecureMintReportIntoUint224ForEVM(tt.mintable, tt.blockNumber) + + if tt.expectError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, result) + + if tt.expected != nil { + assert.Equal(t, tt.expected, result) + } + + // Additional validation: ensure the result fits in 224 bits + maxUint224 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 224), big.NewInt(1)) + assert.True(t, result.Cmp(maxUint224) <= 0, "result should fit in 224 bits") + + // Verify bit layout if we have expected values and not a large block number + if tt.expected != nil { + verifyBitLayout(t, result, tt.mintable, tt.blockNumber) + } + }) + } +} + +func TestPackSecureMintReportForIntoUint224_EdgeCases(t *testing.T) { + // Test edge cases and boundary conditions + tests := []struct { + name string + mintable *big.Int + blockNumber uint64 + expectError bool + }{ + { + name: "mintable exactly at 128-bit boundary", + mintable: new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 128), big.NewInt(1)), // 2^128 - 1 + blockNumber: 1000, + expectError: false, + }, + { + name: "mintable one over 128-bit boundary", + mintable: new(big.Int).Lsh(big.NewInt(1), 128), // 2^128 + blockNumber: 1000, + expectError: true, + }, + { + name: "block number at max uint64", + mintable: big.NewInt(100), + blockNumber: 0xFFFFFFFFFFFFFFFF, + expectError: false, + }, + { + name: "both values at maximum", + mintable: new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 128), big.NewInt(1)), + blockNumber: 0xFFFFFFFFFFFFFFFF, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := packSecureMintReportIntoUint224ForEVM(tt.mintable, tt.blockNumber) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.NotNil(t, result) + + // Verify the result is within uint224 bounds + maxUint224 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 224), big.NewInt(1)) + assert.True(t, result.Cmp(maxUint224) <= 0, "result should fit in 224 bits") + }) + } +} + +// verifyBitLayout verifies that the packed result has the correct bit layout +// mintable should be in bits 0-127, block number in bits 128-191 +func verifyBitLayout(t *testing.T, packed *big.Int, mintable *big.Int, blockNumber uint64) { + // Extract mintable from lower 128 bits + mintableMask := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 128), big.NewInt(1)) + extractedMintable := new(big.Int).And(packed, mintableMask) + + // Extract block number from bits 128-191 + blockNumberMask := new(big.Int).Lsh(new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 64), big.NewInt(1)), 128) + extractedBlockNumber := new(big.Int).And(packed, blockNumberMask) + extractedBlockNumber = new(big.Int).Rsh(extractedBlockNumber, 128) + + // Always use big.NewInt(0) for zero-value mintable + expectedMintable := mintable + if mintable == nil || (mintable != nil && mintable.Sign() == 0) { + expectedMintable = big.NewInt(0) + } + + assert.Equal(t, expectedMintable, extractedMintable, "mintable bits should match") + assert.Equal(t, new(big.Int).SetUint64(blockNumber), extractedBlockNumber, "block number bits should match") +} + +func TestMaxMintableConstant(t *testing.T) { + // Verify the maxMintable constant is correctly defined + expectedMax := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 128), big.NewInt(1)) + assert.Equal(t, expectedMax, maxMintableEVM, "maxMintable should be 2^128 - 1") + + // Verify it's exactly 128 bits + bitLen := maxMintableEVM.BitLen() + assert.Equal(t, 128, bitLen, "maxMintable should be exactly 128 bits") +} diff --git a/pkg/capabilities/consensus/ocr3/models.go b/pkg/capabilities/consensus/ocr3/models.go index 43c187fa3e..1080b8f584 100644 --- a/pkg/capabilities/consensus/ocr3/models.go +++ b/pkg/capabilities/consensus/ocr3/models.go @@ -5,7 +5,7 @@ import ( ) type config struct { - AggregationMethod string `mapstructure:"aggregation_method" json:"aggregation_method" jsonschema:"enum=data_feeds,enum=llo_streams,enum=identical,enum=reduce"` + AggregationMethod string `mapstructure:"aggregation_method" json:"aggregation_method" jsonschema:"enum=data_feeds,enum=llo_streams,enum=identical,enum=reduce,enum=secure_mint"` AggregationConfig *values.Map `mapstructure:"aggregation_config" json:"aggregation_config"` Encoder string `mapstructure:"encoder" json:"encoder"` EncoderConfig *values.Map `mapstructure:"encoder_config" json:"encoder_config"` diff --git a/pkg/capabilities/consensus/ocr3/ocr3cap/ocr3cap_common-schema.json b/pkg/capabilities/consensus/ocr3/ocr3cap/ocr3cap_common-schema.json index dd4b9b6992..4318a2b5bd 100644 --- a/pkg/capabilities/consensus/ocr3/ocr3cap/ocr3cap_common-schema.json +++ b/pkg/capabilities/consensus/ocr3/ocr3cap/ocr3cap_common-schema.json @@ -46,7 +46,7 @@ }, "encoder": { "type": "string", - "enum": ["EVM", "ValueMap"] + "enum": ["EVM","Borsh","ValueMap"] }, "encoder_config": { "type": "object", diff --git a/pkg/capabilities/consensus/ocr3/ocr3cap/ocr3cap_common_generated.go b/pkg/capabilities/consensus/ocr3/ocr3cap/ocr3cap_common_generated.go index 6d58692968..505f876c58 100644 --- a/pkg/capabilities/consensus/ocr3/ocr3cap/ocr3cap_common_generated.go +++ b/pkg/capabilities/consensus/ocr3/ocr3cap/ocr3cap_common_generated.go @@ -11,6 +11,8 @@ import ( type Encoder string +const EncoderBorsh Encoder = "Borsh" + type EncoderConfig map[string]interface{} const EncoderEVM Encoder = "EVM" @@ -18,6 +20,7 @@ const EncoderValueMap Encoder = "ValueMap" var enumValues_Encoder = []interface{}{ "EVM", + "Borsh", "ValueMap", } diff --git a/pkg/capabilities/consensus/ocr3/testdata/fixtures/capability/schema.json b/pkg/capabilities/consensus/ocr3/testdata/fixtures/capability/schema.json index 7cfd0a2b8c..31c20ed685 100644 --- a/pkg/capabilities/consensus/ocr3/testdata/fixtures/capability/schema.json +++ b/pkg/capabilities/consensus/ocr3/testdata/fixtures/capability/schema.json @@ -10,7 +10,8 @@ "data_feeds", "llo_streams", "identical", - "reduce" + "reduce", + "secure_mint" ] }, "aggregation_config": { diff --git a/pkg/loop/internal/pb/reporting_plugin_service.pb.go b/pkg/loop/internal/pb/reporting_plugin_service.pb.go index c326797b81..697b560aed 100644 --- a/pkg/loop/internal/pb/reporting_plugin_service.pb.go +++ b/pkg/loop/internal/pb/reporting_plugin_service.pb.go @@ -188,6 +188,7 @@ type NewReportingPluginFactoryRequest struct { CapRegistryID uint32 `protobuf:"varint,6,opt,name=capRegistryID,proto3" json:"capRegistryID,omitempty"` KeyValueStoreID uint32 `protobuf:"varint,7,opt,name=keyValueStoreID,proto3" json:"keyValueStoreID,omitempty"` RelayerSetID uint32 `protobuf:"varint,8,opt,name=relayerSetID,proto3" json:"relayerSetID,omitempty"` + SecureMintExternalAdapterID uint32 `protobuf:"varint,9,opt,name=secureMintExternalAdapterID,proto3" json:"secureMintExternalAdapterID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -278,6 +279,13 @@ func (x *NewReportingPluginFactoryRequest) GetRelayerSetID() uint32 { return 0 } +func (x *NewReportingPluginFactoryRequest) GetSecureMintExternalAdapterID() uint32 { + if x != nil { + return x.SecureMintExternalAdapterID + } + return 0 +} + // NewReportingPluginFactoryReply has return arguments for [github.com/smartcontractkit/chainlink-common/pkg/loop/reporting_plugins/LOOPPService.NewReportingPluginFactory]. type NewReportingPluginFactoryReply struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -338,7 +346,7 @@ const file_reporting_plugin_service_proto_rawDesc = "" + "pluginName\x18\x03 \x01(\tR\n" + "pluginName\x12\"\n" + "\fpluginConfig\x18\x04 \x01(\tR\fpluginConfig\x12$\n" + - "\rtelemetryType\x18\x05 \x01(\tR\rtelemetryType\"\x8c\x03\n" + + "\rtelemetryType\x18\x05 \x01(\tR\rtelemetryType\"\xce\x03\n" + " NewReportingPluginFactoryRequest\x12\x1e\n" + "\n" + "providerID\x18\x01 \x01(\rR\n" + @@ -351,7 +359,8 @@ const file_reporting_plugin_service_proto_rawDesc = "" + "\x1cReportingPluginServiceConfig\x18\x05 \x01(\v2\".loop.ReportingPluginServiceConfigR\x1cReportingPluginServiceConfig\x12$\n" + "\rcapRegistryID\x18\x06 \x01(\rR\rcapRegistryID\x12(\n" + "\x0fkeyValueStoreID\x18\a \x01(\rR\x0fkeyValueStoreID\x12\"\n" + - "\frelayerSetID\x18\b \x01(\rR\frelayerSetID\"0\n" + + "\frelayerSetID\x18\b \x01(\rR\frelayerSetID\x12@\n" + + "\x1bsecureMintExternalAdapterID\x18\t \x01(\rR\x1bsecureMintExternalAdapterID\"0\n" + "\x1eNewReportingPluginFactoryReply\x12\x0e\n" + "\x02ID\x18\x01 \x01(\rR\x02ID2\xe0\x01\n" + "\x16ReportingPluginService\x12k\n" + diff --git a/pkg/loop/internal/pb/reporting_plugin_service.proto b/pkg/loop/internal/pb/reporting_plugin_service.proto index 64f7f7efcb..1e41260030 100644 --- a/pkg/loop/internal/pb/reporting_plugin_service.proto +++ b/pkg/loop/internal/pb/reporting_plugin_service.proto @@ -35,6 +35,7 @@ message NewReportingPluginFactoryRequest { uint32 capRegistryID = 6; uint32 keyValueStoreID = 7; uint32 relayerSetID = 8; + uint32 secureMintExternalAdapterID = 9; } // NewReportingPluginFactoryReply has return arguments for [github.com/smartcontractkit/chainlink-common/pkg/loop/reporting_plugins/LOOPPService.NewReportingPluginFactory]. diff --git a/pkg/loop/internal/pb/securemint/external_adapter.pb.go b/pkg/loop/internal/pb/securemint/external_adapter.pb.go new file mode 100644 index 0000000000..0ce249aa27 --- /dev/null +++ b/pkg/loop/internal/pb/securemint/external_adapter.pb.go @@ -0,0 +1,329 @@ +// protobuf representation of https://github.com/smartcontractkit/por_mock_ocr3plugin/blob/main/por/external_adapter_interface.go + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.7 +// protoc v5.29.3 +// source: external_adapter.proto + +package securemintpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Blocks contains the latest blocks per chain selector. +type Blocks struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value map[uint64]uint64 `protobuf:"bytes,1,rep,name=value,proto3" json:"value,omitempty" protobuf_key:"varint,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` // map from ChainSelector to BlockNumber + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Blocks) Reset() { + *x = Blocks{} + mi := &file_external_adapter_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Blocks) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Blocks) ProtoMessage() {} + +func (x *Blocks) ProtoReflect() protoreflect.Message { + mi := &file_external_adapter_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Blocks.ProtoReflect.Descriptor instead. +func (*Blocks) Descriptor() ([]byte, []int) { + return file_external_adapter_proto_rawDescGZIP(), []int{0} +} + +func (x *Blocks) GetValue() map[uint64]uint64 { + if x != nil { + return x.Value + } + return nil +} + +// BlockMintablePair is a mintable amount at a certain block number. +// The mintable amount is a big.Int encoded as string. +type BlockMintablePair struct { + state protoimpl.MessageState `protogen:"open.v1"` + BlockNumber uint64 `protobuf:"varint,1,opt,name=blockNumber,proto3" json:"blockNumber,omitempty"` + Mintable string `protobuf:"bytes,2,opt,name=mintable,proto3" json:"mintable,omitempty"` // big.Int in reality + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BlockMintablePair) Reset() { + *x = BlockMintablePair{} + mi := &file_external_adapter_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BlockMintablePair) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BlockMintablePair) ProtoMessage() {} + +func (x *BlockMintablePair) ProtoReflect() protoreflect.Message { + mi := &file_external_adapter_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BlockMintablePair.ProtoReflect.Descriptor instead. +func (*BlockMintablePair) Descriptor() ([]byte, []int) { + return file_external_adapter_proto_rawDescGZIP(), []int{1} +} + +func (x *BlockMintablePair) GetBlockNumber() uint64 { + if x != nil { + return x.BlockNumber + } + return 0 +} + +func (x *BlockMintablePair) GetMintable() string { + if x != nil { + return x.Mintable + } + return "" +} + +// ReserveInfo is a reserve amount at a certain timestamp. +// The reserve amount is a big.Int encoded as string. +type ReserveInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + ReserveAmount string `protobuf:"bytes,1,opt,name=reserveAmount,proto3" json:"reserveAmount,omitempty"` // big.Int in reality + Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReserveInfo) Reset() { + *x = ReserveInfo{} + mi := &file_external_adapter_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReserveInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReserveInfo) ProtoMessage() {} + +func (x *ReserveInfo) ProtoReflect() protoreflect.Message { + mi := &file_external_adapter_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReserveInfo.ProtoReflect.Descriptor instead. +func (*ReserveInfo) Descriptor() ([]byte, []int) { + return file_external_adapter_proto_rawDescGZIP(), []int{2} +} + +func (x *ReserveInfo) GetReserveAmount() string { + if x != nil { + return x.ReserveAmount + } + return "" +} + +func (x *ReserveInfo) GetTimestamp() *timestamppb.Timestamp { + if x != nil { + return x.Timestamp + } + return nil +} + +// ExternalAdapterPayload is the response from an EA containing various secure mint related data points. +type ExternalAdapterPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mintables map[uint64]*BlockMintablePair `protobuf:"bytes,1,rep,name=mintables,proto3" json:"mintables,omitempty" protobuf_key:"varint,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // The mintable amounts for each chain and its block. + ReserveInfo *ReserveInfo `protobuf:"bytes,2,opt,name=reserveInfo,proto3" json:"reserveInfo,omitempty"` // The latest reserve amount and timestamp used to calculate the minting allowance above. + LatestBlocks *Blocks `protobuf:"bytes,3,opt,name=latestBlocks,proto3" json:"latestBlocks,omitempty"` // The latest blocks for each chain. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExternalAdapterPayload) Reset() { + *x = ExternalAdapterPayload{} + mi := &file_external_adapter_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExternalAdapterPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExternalAdapterPayload) ProtoMessage() {} + +func (x *ExternalAdapterPayload) ProtoReflect() protoreflect.Message { + mi := &file_external_adapter_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExternalAdapterPayload.ProtoReflect.Descriptor instead. +func (*ExternalAdapterPayload) Descriptor() ([]byte, []int) { + return file_external_adapter_proto_rawDescGZIP(), []int{3} +} + +func (x *ExternalAdapterPayload) GetMintables() map[uint64]*BlockMintablePair { + if x != nil { + return x.Mintables + } + return nil +} + +func (x *ExternalAdapterPayload) GetReserveInfo() *ReserveInfo { + if x != nil { + return x.ReserveInfo + } + return nil +} + +func (x *ExternalAdapterPayload) GetLatestBlocks() *Blocks { + if x != nil { + return x.LatestBlocks + } + return nil +} + +var File_external_adapter_proto protoreflect.FileDescriptor + +const file_external_adapter_proto_rawDesc = "" + + "\n" + + "\x16external_adapter.proto\x12\x1bloop.internal.pb.securemint\x1a\x1fgoogle/protobuf/timestamp.proto\"\x88\x01\n" + + "\x06Blocks\x12D\n" + + "\x05value\x18\x01 \x03(\v2..loop.internal.pb.securemint.Blocks.ValueEntryR\x05value\x1a8\n" + + "\n" + + "ValueEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\x04R\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x04R\x05value:\x028\x01\"Q\n" + + "\x11BlockMintablePair\x12 \n" + + "\vblockNumber\x18\x01 \x01(\x04R\vblockNumber\x12\x1a\n" + + "\bmintable\x18\x02 \x01(\tR\bmintable\"m\n" + + "\vReserveInfo\x12$\n" + + "\rreserveAmount\x18\x01 \x01(\tR\rreserveAmount\x128\n" + + "\ttimestamp\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\"\xfd\x02\n" + + "\x16ExternalAdapterPayload\x12`\n" + + "\tmintables\x18\x01 \x03(\v2B.loop.internal.pb.securemint.ExternalAdapterPayload.MintablesEntryR\tmintables\x12J\n" + + "\vreserveInfo\x18\x02 \x01(\v2(.loop.internal.pb.securemint.ReserveInfoR\vreserveInfo\x12G\n" + + "\flatestBlocks\x18\x03 \x01(\v2#.loop.internal.pb.securemint.BlocksR\flatestBlocks\x1al\n" + + "\x0eMintablesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\x04R\x03key\x12D\n" + + "\x05value\x18\x02 \x01(\v2..loop.internal.pb.securemint.BlockMintablePairR\x05value:\x028\x012{\n" + + "\x0fExternalAdapter\x12h\n" + + "\n" + + "GetPayload\x12#.loop.internal.pb.securemint.Blocks\x1a3.loop.internal.pb.securemint.ExternalAdapterPayload\"\x00B[ZYgithub.com/smartcontractkit/chainlink-common/pkg/loop/internal/pb/securemint;securemintpbb\x06proto3" + +var ( + file_external_adapter_proto_rawDescOnce sync.Once + file_external_adapter_proto_rawDescData []byte +) + +func file_external_adapter_proto_rawDescGZIP() []byte { + file_external_adapter_proto_rawDescOnce.Do(func() { + file_external_adapter_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_external_adapter_proto_rawDesc), len(file_external_adapter_proto_rawDesc))) + }) + return file_external_adapter_proto_rawDescData +} + +var file_external_adapter_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_external_adapter_proto_goTypes = []any{ + (*Blocks)(nil), // 0: loop.internal.pb.securemint.Blocks + (*BlockMintablePair)(nil), // 1: loop.internal.pb.securemint.BlockMintablePair + (*ReserveInfo)(nil), // 2: loop.internal.pb.securemint.ReserveInfo + (*ExternalAdapterPayload)(nil), // 3: loop.internal.pb.securemint.ExternalAdapterPayload + nil, // 4: loop.internal.pb.securemint.Blocks.ValueEntry + nil, // 5: loop.internal.pb.securemint.ExternalAdapterPayload.MintablesEntry + (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp +} +var file_external_adapter_proto_depIdxs = []int32{ + 4, // 0: loop.internal.pb.securemint.Blocks.value:type_name -> loop.internal.pb.securemint.Blocks.ValueEntry + 6, // 1: loop.internal.pb.securemint.ReserveInfo.timestamp:type_name -> google.protobuf.Timestamp + 5, // 2: loop.internal.pb.securemint.ExternalAdapterPayload.mintables:type_name -> loop.internal.pb.securemint.ExternalAdapterPayload.MintablesEntry + 2, // 3: loop.internal.pb.securemint.ExternalAdapterPayload.reserveInfo:type_name -> loop.internal.pb.securemint.ReserveInfo + 0, // 4: loop.internal.pb.securemint.ExternalAdapterPayload.latestBlocks:type_name -> loop.internal.pb.securemint.Blocks + 1, // 5: loop.internal.pb.securemint.ExternalAdapterPayload.MintablesEntry.value:type_name -> loop.internal.pb.securemint.BlockMintablePair + 0, // 6: loop.internal.pb.securemint.ExternalAdapter.GetPayload:input_type -> loop.internal.pb.securemint.Blocks + 3, // 7: loop.internal.pb.securemint.ExternalAdapter.GetPayload:output_type -> loop.internal.pb.securemint.ExternalAdapterPayload + 7, // [7:8] is the sub-list for method output_type + 6, // [6:7] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_external_adapter_proto_init() } +func file_external_adapter_proto_init() { + if File_external_adapter_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_external_adapter_proto_rawDesc), len(file_external_adapter_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_external_adapter_proto_goTypes, + DependencyIndexes: file_external_adapter_proto_depIdxs, + MessageInfos: file_external_adapter_proto_msgTypes, + }.Build() + File_external_adapter_proto = out.File + file_external_adapter_proto_goTypes = nil + file_external_adapter_proto_depIdxs = nil +} diff --git a/pkg/loop/internal/pb/securemint/external_adapter.proto b/pkg/loop/internal/pb/securemint/external_adapter.proto new file mode 100644 index 0000000000..c9586f9c81 --- /dev/null +++ b/pkg/loop/internal/pb/securemint/external_adapter.proto @@ -0,0 +1,42 @@ +// protobuf representation of https://github.com/smartcontractkit/por_mock_ocr3plugin/blob/main/por/external_adapter_interface.go + +syntax = "proto3"; + +option go_package = "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/pb/securemint;securemintpb"; + +package loop.internal.pb.securemint; + +import "google/protobuf/timestamp.proto"; + +// Blocks contains the latest blocks per chain selector. +message Blocks { + map value = 1; // map from ChainSelector to BlockNumber +} + +// BlockMintablePair is a mintable amount at a certain block number. +// The mintable amount is a big.Int encoded as string. +message BlockMintablePair { + uint64 blockNumber = 1; + string mintable = 2; // big.Int in reality +} + +// ReserveInfo is a reserve amount at a certain timestamp. +// The reserve amount is a big.Int encoded as string. +message ReserveInfo { + string reserveAmount = 1; // big.Int in reality + google.protobuf.Timestamp timestamp = 2; +} + +// ExternalAdapterPayload is the response from an EA containing various secure mint related data points. +message ExternalAdapterPayload { + map mintables = 1; // The mintable amounts for each chain and its block. + ReserveInfo reserveInfo = 2; // The latest reserve amount and timestamp used to calculate the minting allowance above. + + Blocks latestBlocks = 3; // The latest blocks for each chain. +} + +// ExternalAdapter is the component used by the secure mint plugin to request various secure mint related data points. +service ExternalAdapter { + rpc GetPayload(Blocks) returns (ExternalAdapterPayload) {} +} + diff --git a/pkg/loop/internal/pb/securemint/external_adapter_grpc.pb.go b/pkg/loop/internal/pb/securemint/external_adapter_grpc.pb.go new file mode 100644 index 0000000000..55884acc76 --- /dev/null +++ b/pkg/loop/internal/pb/securemint/external_adapter_grpc.pb.go @@ -0,0 +1,127 @@ +// protobuf representation of https://github.com/smartcontractkit/por_mock_ocr3plugin/blob/main/por/external_adapter_interface.go + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.29.3 +// source: external_adapter.proto + +package securemintpb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + ExternalAdapter_GetPayload_FullMethodName = "/loop.internal.pb.securemint.ExternalAdapter/GetPayload" +) + +// ExternalAdapterClient is the client API for ExternalAdapter service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ExternalAdapter is the component used by the secure mint plugin to request various secure mint related data points. +type ExternalAdapterClient interface { + GetPayload(ctx context.Context, in *Blocks, opts ...grpc.CallOption) (*ExternalAdapterPayload, error) +} + +type externalAdapterClient struct { + cc grpc.ClientConnInterface +} + +func NewExternalAdapterClient(cc grpc.ClientConnInterface) ExternalAdapterClient { + return &externalAdapterClient{cc} +} + +func (c *externalAdapterClient) GetPayload(ctx context.Context, in *Blocks, opts ...grpc.CallOption) (*ExternalAdapterPayload, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExternalAdapterPayload) + err := c.cc.Invoke(ctx, ExternalAdapter_GetPayload_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ExternalAdapterServer is the server API for ExternalAdapter service. +// All implementations must embed UnimplementedExternalAdapterServer +// for forward compatibility. +// +// ExternalAdapter is the component used by the secure mint plugin to request various secure mint related data points. +type ExternalAdapterServer interface { + GetPayload(context.Context, *Blocks) (*ExternalAdapterPayload, error) + mustEmbedUnimplementedExternalAdapterServer() +} + +// UnimplementedExternalAdapterServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedExternalAdapterServer struct{} + +func (UnimplementedExternalAdapterServer) GetPayload(context.Context, *Blocks) (*ExternalAdapterPayload, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetPayload not implemented") +} +func (UnimplementedExternalAdapterServer) mustEmbedUnimplementedExternalAdapterServer() {} +func (UnimplementedExternalAdapterServer) testEmbeddedByValue() {} + +// UnsafeExternalAdapterServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ExternalAdapterServer will +// result in compilation errors. +type UnsafeExternalAdapterServer interface { + mustEmbedUnimplementedExternalAdapterServer() +} + +func RegisterExternalAdapterServer(s grpc.ServiceRegistrar, srv ExternalAdapterServer) { + // If the following call pancis, it indicates UnimplementedExternalAdapterServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ExternalAdapter_ServiceDesc, srv) +} + +func _ExternalAdapter_GetPayload_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Blocks) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ExternalAdapterServer).GetPayload(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ExternalAdapter_GetPayload_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ExternalAdapterServer).GetPayload(ctx, req.(*Blocks)) + } + return interceptor(ctx, in, info, handler) +} + +// ExternalAdapter_ServiceDesc is the grpc.ServiceDesc for ExternalAdapter service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ExternalAdapter_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "loop.internal.pb.securemint.ExternalAdapter", + HandlerType: (*ExternalAdapterServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetPayload", + Handler: _ExternalAdapter_GetPayload_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "external_adapter.proto", +} diff --git a/pkg/loop/internal/pb/securemint/generate.go b/pkg/loop/internal/pb/securemint/generate.go new file mode 100644 index 0000000000..edb81bd4e9 --- /dev/null +++ b/pkg/loop/internal/pb/securemint/generate.go @@ -0,0 +1,2 @@ +//go:generate protoc --proto_path=.:..:. --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative external_adapter.proto +package securemintpb diff --git a/pkg/loop/internal/reportingplugin/securemint/external_adapter_pb.go b/pkg/loop/internal/reportingplugin/securemint/external_adapter_pb.go new file mode 100644 index 0000000000..2dbc6aaaa3 --- /dev/null +++ b/pkg/loop/internal/reportingplugin/securemint/external_adapter_pb.go @@ -0,0 +1,146 @@ +package securemint + +import ( + "context" + "fmt" + "math/big" + + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + pb "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/pb/securemint" + sm "github.com/smartcontractkit/chainlink-common/pkg/types/core/securemint" +) + +// externalAdapterClient is a protobuf client that implements the securemint.ExternalAdapter interface. +// It's basically a wrapper around the protobuf external adapter client so that it can be used as a securemint.ExternalAdapter. +type externalAdapterClient struct { + lggr logger.Logger + grpc pb.ExternalAdapterClient +} + +var _ sm.ExternalAdapter = (*externalAdapterClient)(nil) + +func newExternalAdapterClient(lggr logger.Logger, cc grpc.ClientConnInterface) *externalAdapterClient { + return &externalAdapterClient{lggr: logger.Named(lggr, "ExternalAdapterClient"), grpc: pb.NewExternalAdapterClient(cc)} +} + +func (d *externalAdapterClient) GetPayload(ctx context.Context, blocks sm.Blocks) (sm.ExternalAdapterPayload, error) { + d.lggr.Infof("GetPayload request pb client: %+v", blocks) + + request := &pb.Blocks{ + Value: make(map[uint64]uint64, len(blocks)), + } + for chainSelector, blockNumber := range blocks { + request.Value[uint64(chainSelector)] = uint64(blockNumber) + } + + reply, err := d.grpc.GetPayload(ctx, request) + if err != nil { + return sm.ExternalAdapterPayload{}, err + } + + mintables := make(map[sm.ChainSelector]sm.BlockMintablePair, len(reply.Mintables)) + for chainSelector, blockMintablePair := range reply.Mintables { + mintable, err := stringToBigInt(blockMintablePair.Mintable) + if err != nil { + return sm.ExternalAdapterPayload{}, err + } + mintables[sm.ChainSelector(chainSelector)] = sm.BlockMintablePair{ + Block: sm.BlockNumber(blockMintablePair.BlockNumber), + Mintable: mintable, + } + } + + reserveAmount, err := stringToBigInt(reply.ReserveInfo.ReserveAmount) + if err != nil { + return sm.ExternalAdapterPayload{}, err + } + reserveInfo := sm.ReserveInfo{ + ReserveAmount: reserveAmount, + Timestamp: reply.ReserveInfo.Timestamp.AsTime(), + } + + latestBlocks := make(sm.Blocks, len(reply.LatestBlocks.Value)) + for chainSelector, blockNumber := range reply.LatestBlocks.Value { + latestBlocks[sm.ChainSelector(chainSelector)] = sm.BlockNumber(blockNumber) + } + + result := sm.ExternalAdapterPayload{ + Mintables: mintables, + ReserveInfo: reserveInfo, + LatestBlocks: latestBlocks, + } + + d.lggr.Infof("GetPayload response pb client: %+v", result) + return result, nil +} + +var _ pb.ExternalAdapterServer = (*externalAdapterServer)(nil) + +// externalAdapterServer is a protobuf server that implements the pb.ExternalAdapterServer interface. +// It's basically a protobuf wrapper around the securemint.ExternalAdapter implementation. +type externalAdapterServer struct { + pb.UnimplementedExternalAdapterServer + + lggr logger.Logger + impl sm.ExternalAdapter +} + +func newExternalAdapterServer(lggr logger.Logger, impl sm.ExternalAdapter) *externalAdapterServer { + return &externalAdapterServer{lggr: logger.Named(lggr, "ExternalAdapterServer"), impl: impl} +} + +func (d *externalAdapterServer) GetPayload(ctx context.Context, request *pb.Blocks) (*pb.ExternalAdapterPayload, error) { + d.lggr.Infof("GetPayload request pb server: %+v", request) + + blocks := make(sm.Blocks, len(request.Value)) + for chainSelector, blockNumber := range request.Value { + blocks[sm.ChainSelector(chainSelector)] = sm.BlockNumber(blockNumber) + } + + val, err := d.impl.GetPayload(ctx, blocks) + if err != nil { + return nil, fmt.Errorf("failed to get payload from external adapter for request %v: %w", request, err) + } + + mintables := make(map[uint64]*pb.BlockMintablePair, len(val.Mintables)) + for chainSelector, blockMintablePair := range val.Mintables { + mintables[uint64(chainSelector)] = &pb.BlockMintablePair{ + BlockNumber: uint64(blockMintablePair.Block), + Mintable: blockMintablePair.Mintable.String(), + } + } + + reserveInfo := &pb.ReserveInfo{ + ReserveAmount: val.ReserveInfo.ReserveAmount.String(), + Timestamp: timestamppb.New(val.ReserveInfo.Timestamp), + } + + valLatestBlocks := make(map[uint64]uint64, len(val.LatestBlocks)) + for chainSelector, blockNumber := range val.LatestBlocks { + valLatestBlocks[uint64(chainSelector)] = uint64(blockNumber) + } + latestBlocks := &pb.Blocks{ + Value: valLatestBlocks, + } + + response := &pb.ExternalAdapterPayload{ + Mintables: mintables, + ReserveInfo: reserveInfo, + LatestBlocks: latestBlocks, + } + + d.lggr.Infof("GetPayload response pb server: %+v", response) + return response, nil +} + +func stringToBigInt(s string) (*big.Int, error) { + z := new(big.Int) + _, ok := z.SetString(s, 10) + if !ok { + return nil, fmt.Errorf("invalid integer %q", s) + } + return z, nil +} diff --git a/pkg/loop/internal/reportingplugin/securemint/external_adapter_pb_test.go b/pkg/loop/internal/reportingplugin/securemint/external_adapter_pb_test.go new file mode 100644 index 0000000000..5eefc07523 --- /dev/null +++ b/pkg/loop/internal/reportingplugin/securemint/external_adapter_pb_test.go @@ -0,0 +1,501 @@ +package securemint + +import ( + "context" + "errors" + "math/big" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + pb "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/pb/securemint" + sm "github.com/smartcontractkit/chainlink-common/pkg/types/core/securemint" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// mockExternalAdapterClient is a mock implementation of pb.ExternalAdapterClient +type mockExternalAdapterClient struct { + mock.Mock +} + +func (m *mockExternalAdapterClient) GetPayload(ctx context.Context, in *pb.Blocks, opts ...grpc.CallOption) (*pb.ExternalAdapterPayload, error) { + args := m.Called(ctx, in, opts) + return args.Get(0).(*pb.ExternalAdapterPayload), args.Error(1) +} + +// mockExternalAdapter is a mock implementation of securemint.ExternalAdapter +type mockExternalAdapter struct { + mock.Mock +} + +func (m *mockExternalAdapter) GetPayload(ctx context.Context, blocks sm.Blocks) (sm.ExternalAdapterPayload, error) { + args := m.Called(ctx, blocks) + return args.Get(0).(sm.ExternalAdapterPayload), args.Error(1) +} + +func TestExternalAdapterClient_GetPayload(t *testing.T) { + tests := []struct { + name string + inputBlocks sm.Blocks + mockResponse *pb.ExternalAdapterPayload + mockError error + expectedResult sm.ExternalAdapterPayload + expectedError bool + }{ + { + name: "successful request with single chain", + inputBlocks: sm.Blocks{ + sm.ChainSelector(1): sm.BlockNumber(100), + }, + mockResponse: &pb.ExternalAdapterPayload{ + Mintables: map[uint64]*pb.BlockMintablePair{ + 1: { + BlockNumber: 100, + Mintable: "1000", + }, + }, + ReserveInfo: &pb.ReserveInfo{ + ReserveAmount: "5000", + Timestamp: timestamppb.New(time.Unix(1640995200, 0).UTC()), // 2022-01-01 00:00:00 UTC + }, + LatestBlocks: &pb.Blocks{ + Value: map[uint64]uint64{ + 1: 100, + }, + }, + }, + expectedResult: sm.ExternalAdapterPayload{ + Mintables: map[sm.ChainSelector]sm.BlockMintablePair{ + sm.ChainSelector(1): { + Block: sm.BlockNumber(100), + Mintable: big.NewInt(1000), + }, + }, + ReserveInfo: sm.ReserveInfo{ + ReserveAmount: big.NewInt(5000), + Timestamp: time.Unix(1640995200, 0).UTC(), + }, + LatestBlocks: sm.Blocks{ + sm.ChainSelector(1): sm.BlockNumber(100), + }, + }, + expectedError: false, + }, + { + name: "successful request with multiple chains", + inputBlocks: sm.Blocks{ + sm.ChainSelector(1): sm.BlockNumber(100), + sm.ChainSelector(2): sm.BlockNumber(200), + }, + mockResponse: &pb.ExternalAdapterPayload{ + Mintables: map[uint64]*pb.BlockMintablePair{ + 1: { + BlockNumber: 100, + Mintable: "1000", + }, + 2: { + BlockNumber: 200, + Mintable: "2000", + }, + }, + ReserveInfo: &pb.ReserveInfo{ + ReserveAmount: "5000", + Timestamp: timestamppb.New(time.Unix(1640995200, 0).UTC()), + }, + LatestBlocks: &pb.Blocks{ + Value: map[uint64]uint64{ + 1: 100, + 2: 200, + }, + }, + }, + expectedResult: sm.ExternalAdapterPayload{ + Mintables: map[sm.ChainSelector]sm.BlockMintablePair{ + sm.ChainSelector(1): { + Block: sm.BlockNumber(100), + Mintable: big.NewInt(1000), + }, + sm.ChainSelector(2): { + Block: sm.BlockNumber(200), + Mintable: big.NewInt(2000), + }, + }, + ReserveInfo: sm.ReserveInfo{ + ReserveAmount: big.NewInt(5000), + Timestamp: time.Unix(1640995200, 0).UTC(), + }, + LatestBlocks: sm.Blocks{ + sm.ChainSelector(1): sm.BlockNumber(100), + sm.ChainSelector(2): sm.BlockNumber(200), + }, + }, + expectedError: false, + }, + { + name: "empty input blocks", + inputBlocks: sm.Blocks{}, + mockResponse: &pb.ExternalAdapterPayload{ + Mintables: map[uint64]*pb.BlockMintablePair{}, + ReserveInfo: &pb.ReserveInfo{ + ReserveAmount: "0", + Timestamp: timestamppb.New(time.Unix(1640995200, 0).UTC()), + }, + LatestBlocks: &pb.Blocks{ + Value: map[uint64]uint64{}, + }, + }, + expectedResult: sm.ExternalAdapterPayload{ + Mintables: map[sm.ChainSelector]sm.BlockMintablePair{}, + ReserveInfo: sm.ReserveInfo{ + ReserveAmount: big.NewInt(0), + Timestamp: time.Unix(1640995200, 0).UTC(), + }, + LatestBlocks: sm.Blocks{}, + }, + expectedError: false, + }, + { + name: "grpc error", + inputBlocks: sm.Blocks{ + sm.ChainSelector(1): sm.BlockNumber(100), + }, + mockResponse: nil, + mockError: errors.New("grpc error"), + expectedError: true, + }, + { + name: "invalid mintable string", + inputBlocks: sm.Blocks{ + sm.ChainSelector(1): sm.BlockNumber(100), + }, + mockResponse: &pb.ExternalAdapterPayload{ + Mintables: map[uint64]*pb.BlockMintablePair{ + 1: { + BlockNumber: 100, + Mintable: "invalid", + }, + }, + ReserveInfo: &pb.ReserveInfo{ + ReserveAmount: "5000", + Timestamp: timestamppb.New(time.Unix(1640995200, 0).UTC()), + }, + LatestBlocks: &pb.Blocks{ + Value: map[uint64]uint64{ + 1: 100, + }, + }, + }, + expectedError: true, + }, + { + name: "invalid reserve amount string", + inputBlocks: sm.Blocks{ + sm.ChainSelector(1): sm.BlockNumber(100), + }, + mockResponse: &pb.ExternalAdapterPayload{ + Mintables: map[uint64]*pb.BlockMintablePair{ + 1: { + BlockNumber: 100, + Mintable: "1000", + }, + }, + ReserveInfo: &pb.ReserveInfo{ + ReserveAmount: "invalid", + Timestamp: timestamppb.New(time.Unix(1640995200, 0).UTC()), + }, + LatestBlocks: &pb.Blocks{ + Value: map[uint64]uint64{ + 1: 100, + }, + }, + }, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock client + mockClient := new(mockExternalAdapterClient) + if tt.mockError != nil { + mockClient.On("GetPayload", mock.Anything, mock.Anything, mock.Anything).Return((*pb.ExternalAdapterPayload)(nil), tt.mockError) + } else { + mockClient.On("GetPayload", mock.Anything, mock.Anything, mock.Anything).Return(tt.mockResponse, nil) + } + + // Create client with mock + client := &externalAdapterClient{ + lggr: logger.Test(t), + grpc: mockClient, + } + + // Call GetPayload + result, err := client.GetPayload(context.Background(), tt.inputBlocks) + + // Assertions + if tt.expectedError { + assert.Error(t, err) + assert.Equal(t, sm.ExternalAdapterPayload{}, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, result) + } + + // Verify mock expectations + mockClient.AssertExpectations(t) + }) + } +} + +func TestExternalAdapterServer_GetPayload(t *testing.T) { + tests := []struct { + name string + inputRequest *pb.Blocks + mockResponse sm.ExternalAdapterPayload + mockError error + expectedResult *pb.ExternalAdapterPayload + expectedError bool + }{ + { + name: "successful request with single chain", + inputRequest: &pb.Blocks{ + Value: map[uint64]uint64{ + 1: 100, + }, + }, + mockResponse: sm.ExternalAdapterPayload{ + Mintables: map[sm.ChainSelector]sm.BlockMintablePair{ + sm.ChainSelector(1): { + Block: sm.BlockNumber(100), + Mintable: big.NewInt(1000), + }, + }, + ReserveInfo: sm.ReserveInfo{ + ReserveAmount: big.NewInt(5000), + Timestamp: time.Unix(1640995200, 0).UTC(), + }, + LatestBlocks: sm.Blocks{ + sm.ChainSelector(1): sm.BlockNumber(100), + }, + }, + expectedResult: &pb.ExternalAdapterPayload{ + Mintables: map[uint64]*pb.BlockMintablePair{ + 1: { + BlockNumber: 100, + Mintable: "1000", + }, + }, + ReserveInfo: &pb.ReserveInfo{ + ReserveAmount: "5000", + Timestamp: timestamppb.New(time.Unix(1640995200, 0).UTC()), + }, + LatestBlocks: &pb.Blocks{ + Value: map[uint64]uint64{ + 1: 100, + }, + }, + }, + expectedError: false, + }, + { + name: "successful request with multiple chains", + inputRequest: &pb.Blocks{ + Value: map[uint64]uint64{ + 1: 100, + 2: 200, + }, + }, + mockResponse: sm.ExternalAdapterPayload{ + Mintables: map[sm.ChainSelector]sm.BlockMintablePair{ + sm.ChainSelector(1): { + Block: sm.BlockNumber(100), + Mintable: big.NewInt(1000), + }, + sm.ChainSelector(2): { + Block: sm.BlockNumber(200), + Mintable: big.NewInt(2000), + }, + }, + ReserveInfo: sm.ReserveInfo{ + ReserveAmount: big.NewInt(5000), + Timestamp: time.Unix(1640995200, 0).UTC(), + }, + LatestBlocks: sm.Blocks{ + sm.ChainSelector(1): sm.BlockNumber(100), + sm.ChainSelector(2): sm.BlockNumber(200), + }, + }, + expectedResult: &pb.ExternalAdapterPayload{ + Mintables: map[uint64]*pb.BlockMintablePair{ + 1: { + BlockNumber: 100, + Mintable: "1000", + }, + 2: { + BlockNumber: 200, + Mintable: "2000", + }, + }, + ReserveInfo: &pb.ReserveInfo{ + ReserveAmount: "5000", + Timestamp: timestamppb.New(time.Unix(1640995200, 0).UTC()), + }, + LatestBlocks: &pb.Blocks{ + Value: map[uint64]uint64{ + 1: 100, + 2: 200, + }, + }, + }, + expectedError: false, + }, + { + name: "empty input request", + inputRequest: &pb.Blocks{ + Value: map[uint64]uint64{}, + }, + mockResponse: sm.ExternalAdapterPayload{ + Mintables: map[sm.ChainSelector]sm.BlockMintablePair{}, + ReserveInfo: sm.ReserveInfo{ + ReserveAmount: big.NewInt(0), + Timestamp: time.Unix(1640995200, 0).UTC(), + }, + LatestBlocks: sm.Blocks{}, + }, + expectedResult: &pb.ExternalAdapterPayload{ + Mintables: map[uint64]*pb.BlockMintablePair{}, + ReserveInfo: &pb.ReserveInfo{ + ReserveAmount: "0", + Timestamp: timestamppb.New(time.Unix(1640995200, 0).UTC()), + }, + LatestBlocks: &pb.Blocks{ + Value: map[uint64]uint64{}, + }, + }, + expectedError: false, + }, + { + name: "external adapter error", + inputRequest: &pb.Blocks{ + Value: map[uint64]uint64{ + 1: 100, + }, + }, + mockResponse: sm.ExternalAdapterPayload{}, + mockError: errors.New("external adapter error"), + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock external adapter + mockAdapter := new(mockExternalAdapter) + if tt.mockError != nil { + mockAdapter.On("GetPayload", mock.Anything, mock.Anything).Return(sm.ExternalAdapterPayload{}, tt.mockError) + } else { + mockAdapter.On("GetPayload", mock.Anything, mock.Anything).Return(tt.mockResponse, nil) + } + + // Create server with mock + server := &externalAdapterServer{ + lggr: logger.Test(t), + impl: mockAdapter, + } + + // Call GetPayload + result, err := server.GetPayload(context.Background(), tt.inputRequest) + + // Assertions + if tt.expectedError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult.LatestBlocks.Value, result.LatestBlocks.Value) + assert.Equal(t, tt.expectedResult.ReserveInfo.ReserveAmount, result.ReserveInfo.ReserveAmount) + assert.Equal(t, tt.expectedResult.ReserveInfo.Timestamp.AsTime(), result.ReserveInfo.Timestamp.AsTime()) + + // loop through the mintables and assert that the values are equal + assert.Equal(t, len(tt.expectedResult.Mintables), len(result.Mintables)) + for chainSelector, blockMintablePair := range tt.expectedResult.Mintables { + assert.Equal(t, blockMintablePair.BlockNumber, result.Mintables[chainSelector].BlockNumber) + assert.Equal(t, blockMintablePair.Mintable, result.Mintables[chainSelector].Mintable) + } + } + + // Verify mock expectations + mockAdapter.AssertExpectations(t) + }) + } +} + +func TestStringToBigInt(t *testing.T) { + tests := []struct { + name string + input string + expected *big.Int + expectError bool + }{ + { + name: "valid positive integer", + input: "123456789", + expected: big.NewInt(123456789), + expectError: false, + }, + { + name: "valid zero", + input: "0", + expected: big.NewInt(0), + expectError: false, + }, + { + name: "valid large integer", + input: "999999999999999999999999999999", + expected: func() *big.Int { z, _ := new(big.Int).SetString("999999999999999999999999999999", 10); return z }(), + expectError: false, + }, + { + name: "invalid string", + input: "invalid", + expected: nil, + expectError: true, + }, + { + name: "empty string", + input: "", + expected: nil, + expectError: true, + }, + { + name: "non-numeric string", + input: "abc123", + expected: nil, + expectError: true, + }, + { + name: "string with spaces", + input: " 123 ", + expected: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := stringToBigInt(tt.input) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} diff --git a/pkg/loop/internal/reportingplugin/securemint/pb_client.go b/pkg/loop/internal/reportingplugin/securemint/pb_client.go new file mode 100644 index 0000000000..b5a6ab2d0e --- /dev/null +++ b/pkg/loop/internal/reportingplugin/securemint/pb_client.go @@ -0,0 +1,72 @@ +package securemint + +import ( + "context" + + "google.golang.org/grpc" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/core/services/reportingplugin/ocr3" + "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/goplugin" + net "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/net" + "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/pb" + securemintpb "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/pb/securemint" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + sm "github.com/smartcontractkit/chainlink-common/pkg/types/core/securemint" +) + +// PluginSecureMintClient is a client that runs on the core node to connect to the SecureMint LOOP server. +type PluginSecureMintClient struct { + // hashicorp plugin client + *goplugin.PluginClient + // client to base service + *goplugin.ServiceClient + + reportingPluginService pb.ReportingPluginServiceClient +} + +var _ core.PluginSecureMint = (*PluginSecureMintClient)(nil) + +func NewPluginSecureMintClient(brokerCfg net.BrokerConfig) *PluginSecureMintClient { + brokerCfg.Logger = logger.Named(brokerCfg.Logger, "PluginSecureMintClient") + pc := goplugin.NewPluginClient(brokerCfg) + return &PluginSecureMintClient{ + PluginClient: pc, + ServiceClient: goplugin.NewServiceClient(pc.BrokerExt, pc), + reportingPluginService: pb.NewReportingPluginServiceClient(pc), + } +} + +// NewSecureMintFactory is called by the go-plugin client side to create a client-side ReportingPluginFactory. +func (c *PluginSecureMintClient) NewSecureMintFactory( + ctx context.Context, + lggr logger.Logger, + externalAdapter sm.ExternalAdapter, +) (core.ReportingPluginFactory[sm.ChainSelector], error) { + lggr.Infow("NewSecureMintFactory Client called", "externalAdapter", externalAdapter) + + cc := c.NewClientConn("SecureMintFactory", func(ctx context.Context) (id uint32, deps net.Resources, err error) { + lggr.Infow("Creating new client connection", "externalAdapter", externalAdapter) + + externalAdapterID, externalAdapterRes, err := c.ServeNew("ExternalAdapter", func(s *grpc.Server) { + securemintpb.RegisterExternalAdapterServer(s, newExternalAdapterServer(lggr, externalAdapter)) + }) + if err != nil { + return 0, nil, err + } + deps.Add(externalAdapterRes) + + // this calls into plugin_securemint_server_pb.go#pluginSecureMintServer.NewReportingPluginFactory + reply, err := c.reportingPluginService.NewReportingPluginFactory(ctx, &pb.NewReportingPluginFactoryRequest{ + SecureMintExternalAdapterID: externalAdapterID, + }) + if err != nil { + return 0, nil, err + } + return reply.ID, deps, nil + }) + + return &ocr3ReportingPluginFactoryBytesToChainSelectorAdapter{ + ocr3.NewReportingPluginFactoryClient(c.BrokerExt, cc), // protobuf client + }, nil +} diff --git a/pkg/loop/internal/reportingplugin/securemint/pb_server.go b/pkg/loop/internal/reportingplugin/securemint/pb_server.go new file mode 100644 index 0000000000..7ab3373e2e --- /dev/null +++ b/pkg/loop/internal/reportingplugin/securemint/pb_server.go @@ -0,0 +1,70 @@ +package securemint + +import ( + "context" + + "google.golang.org/grpc" + + "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/core/services/reportingplugin/ocr3" + "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/goplugin" + "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/net" + "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/pb" + ocr3pb "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/pb/ocr3" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" +) + +// pluginSecureMintServer is the protobuf server that runs on the loopp plugin side to handle requests from the core node. +type pluginSecureMintServer struct { + pb.UnimplementedReportingPluginServiceServer + + *net.BrokerExt + impl core.PluginSecureMint +} + +var _ pb.ReportingPluginServiceServer = (*pluginSecureMintServer)(nil) + +// NewValidationService is not implemented for the secure mint plugin. +func (m *pluginSecureMintServer) NewValidationService(ctx context.Context, request *pb.ValidationServiceRequest) (*pb.ValidationServiceResponse, error) { + m.Logger.Infof("NewValidationService called, not implemented") + return &pb.ValidationServiceResponse{}, nil +} + +// NewReportingPluginFactory is called by the core node to create a new reporting plugin factory. +// It delegates to the NewSecureMintFactory function in the plugin implementation. +func (m *pluginSecureMintServer) NewReportingPluginFactory(ctx context.Context, request *pb.NewReportingPluginFactoryRequest) (*pb.NewReportingPluginFactoryReply, error) { + m.Logger.Infof("NewReportingPluginFactory called, delegating to impl.NewSecureMintFactory") + + externalAdapterConn, err := m.Dial(request.SecureMintExternalAdapterID) + if err != nil { + return nil, net.ErrConnDial{Name: "ExternalAdapter", ID: request.SecureMintExternalAdapterID, Err: err} + } + externalAdapterRes := net.Resource{Closer: externalAdapterConn, Name: "ExternalAdapter"} + externalAdapter := newExternalAdapterClient(m.Logger, externalAdapterConn) + + reportingPluginFactory, err := m.impl.NewSecureMintFactory(ctx, m.Logger, externalAdapter) + if err != nil { + m.CloseAll(externalAdapterRes) + return nil, err + } + + id, _, err := m.ServeNew("ReportingPluginProvider", func(s *grpc.Server) { + pb.RegisterServiceServer(s, &goplugin.ServiceServer{Srv: reportingPluginFactory}) + ocr3pb.RegisterReportingPluginFactoryServer(s, ocr3.NewReportingPluginFactoryServer(&reportingPluginFactoryChainSelectorToBytesAdapter{reportingPluginFactory}, m.BrokerExt)) + }, externalAdapterRes) + if err != nil { + return nil, err + } + + return &pb.NewReportingPluginFactoryReply{ID: id}, nil +} + +// RegisterPluginSecureMintServer registers the plugin server with the given broker and broker config so that it can be called by the protobuf client. +func RegisterPluginSecureMintServer(server *grpc.Server, broker net.Broker, brokerCfg net.BrokerConfig, impl core.PluginSecureMint) error { + pb.RegisterServiceServer(server, &goplugin.ServiceServer{Srv: impl}) + pb.RegisterReportingPluginServiceServer(server, newPluginSecureMintServer(&net.BrokerExt{Broker: broker, BrokerConfig: brokerCfg}, impl)) + return nil +} + +func newPluginSecureMintServer(b *net.BrokerExt, gp core.PluginSecureMint) *pluginSecureMintServer { + return &pluginSecureMintServer{BrokerExt: b.WithName("PluginSecureMintServer"), impl: gp} +} diff --git a/pkg/loop/internal/reportingplugin/securemint/reportingplugin_adapter.go b/pkg/loop/internal/reportingplugin/securemint/reportingplugin_adapter.go new file mode 100644 index 0000000000..130b7b3451 --- /dev/null +++ b/pkg/loop/internal/reportingplugin/securemint/reportingplugin_adapter.go @@ -0,0 +1,204 @@ +package securemint + +import ( + "context" + "encoding/binary" + "fmt" + + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + sm "github.com/smartcontractkit/chainlink-common/pkg/types/core/securemint" + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" + "github.com/smartcontractkit/libocr/offchainreporting2plus/types" +) + +// ocr3ReportingPluginFactoryBytesToChainSelectorAdapter wraps a core.OCR3ReportingPluginFactory to implement ReportingPluginFactory[securemint.ChainSelector] +type ocr3ReportingPluginFactoryBytesToChainSelectorAdapter struct { + core.OCR3ReportingPluginFactory +} + +var _ ocr3types.ReportingPluginFactory[sm.ChainSelector] = (*ocr3ReportingPluginFactoryBytesToChainSelectorAdapter)(nil) + +func (a *ocr3ReportingPluginFactoryBytesToChainSelectorAdapter) NewReportingPlugin(ctx context.Context, config ocr3types.ReportingPluginConfig) (ocr3types.ReportingPlugin[sm.ChainSelector], ocr3types.ReportingPluginInfo, error) { + plugin, info, err := a.OCR3ReportingPluginFactory.NewReportingPlugin(ctx, config) + if err != nil { + return nil, ocr3types.ReportingPluginInfo{}, err + } + + // Create a wrapper that converts between []byte and securemint.ChainSelector + wrappedPlugin := &reportingPluginBytesToChainSelectorAdapter{plugin: plugin} + return wrappedPlugin, info, nil +} + +// reportingPluginBytesToChainSelectorAdapter wraps a ReportingPlugin[[]byte] to implement ReportingPlugin[securemint.ChainSelector] +type reportingPluginBytesToChainSelectorAdapter struct { + plugin ocr3types.ReportingPlugin[[]byte] +} + +var _ ocr3types.ReportingPlugin[sm.ChainSelector] = (*reportingPluginBytesToChainSelectorAdapter)(nil) + +func (r *reportingPluginBytesToChainSelectorAdapter) Query(ctx context.Context, outctx ocr3types.OutcomeContext) (types.Query, error) { + return r.plugin.Query(ctx, outctx) +} + +func (r *reportingPluginBytesToChainSelectorAdapter) Observation(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query) (types.Observation, error) { + return r.plugin.Observation(ctx, outctx, query) +} + +func (r *reportingPluginBytesToChainSelectorAdapter) ValidateObservation(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, ao types.AttributedObservation) error { + return r.plugin.ValidateObservation(ctx, outctx, query, ao) +} + +func (r *reportingPluginBytesToChainSelectorAdapter) ObservationQuorum(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, aos []types.AttributedObservation) (bool, error) { + return r.plugin.ObservationQuorum(ctx, outctx, query, aos) +} + +func (r *reportingPluginBytesToChainSelectorAdapter) Outcome(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, aos []types.AttributedObservation) (ocr3types.Outcome, error) { + return r.plugin.Outcome(ctx, outctx, query, aos) +} + +func (r *reportingPluginBytesToChainSelectorAdapter) Reports(ctx context.Context, seqNr uint64, outcome ocr3types.Outcome) ([]ocr3types.ReportPlus[sm.ChainSelector], error) { + // Get reports from the underlying plugin (which returns []ocr3types.ReportPlus[[]byte]) + reports, err := r.plugin.Reports(ctx, seqNr, outcome) + if err != nil { + return nil, err + } + + // Convert []ocr3types.ReportPlus[[]byte] to []ocr3types.ReportPlus[securemint.ChainSelector] + reportsWithInfo := make([]ocr3types.ReportPlus[sm.ChainSelector], len(reports)) + for i, report := range reports { + var chainSelector sm.ChainSelector + if len(report.ReportWithInfo.Info) < 8 { + return nil, fmt.Errorf("info is less than 8 bytes: %+v", report.ReportWithInfo.Info) + } + + // info is a uint64 encoded as []byte (8 bytes, little endian) + chainSelector = sm.ChainSelector(binary.LittleEndian.Uint64(report.ReportWithInfo.Info[:8])) + + reportsWithInfo[i] = ocr3types.ReportPlus[sm.ChainSelector]{ + ReportWithInfo: ocr3types.ReportWithInfo[sm.ChainSelector]{ + Report: report.ReportWithInfo.Report, + Info: chainSelector, + }, + TransmissionScheduleOverride: report.TransmissionScheduleOverride, + } + } + + return reportsWithInfo, nil +} + +func (r *reportingPluginBytesToChainSelectorAdapter) ShouldAcceptAttestedReport(ctx context.Context, seqNr uint64, report ocr3types.ReportWithInfo[sm.ChainSelector]) (bool, error) { + chainSelectorBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(chainSelectorBytes, uint64(report.Info)) + + reportBytes := ocr3types.ReportWithInfo[[]byte]{ + Report: report.Report, + Info: chainSelectorBytes, + } + return r.plugin.ShouldAcceptAttestedReport(ctx, seqNr, reportBytes) +} + +func (r *reportingPluginBytesToChainSelectorAdapter) ShouldTransmitAcceptedReport(ctx context.Context, seqNr uint64, report ocr3types.ReportWithInfo[sm.ChainSelector]) (bool, error) { + chainSelectorBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(chainSelectorBytes, uint64(report.Info)) + + reportBytes := ocr3types.ReportWithInfo[[]byte]{ + Report: report.Report, + Info: chainSelectorBytes, + } + return r.plugin.ShouldTransmitAcceptedReport(ctx, seqNr, reportBytes) +} + +func (r *reportingPluginBytesToChainSelectorAdapter) Close() error { + return r.plugin.Close() +} + +// reportingPluginFactoryChainSelectorToBytesAdapter wraps a ReportingPluginFactory[securemint.ChainSelector] to implement ocr3types.ReportingPluginFactory[[]byte] +type reportingPluginFactoryChainSelectorToBytesAdapter struct { + ocr3types.ReportingPluginFactory[sm.ChainSelector] +} + +var _ ocr3types.ReportingPluginFactory[[]byte] = (*reportingPluginFactoryChainSelectorToBytesAdapter)(nil) + +func (r *reportingPluginFactoryChainSelectorToBytesAdapter) NewReportingPlugin(ctx context.Context, config ocr3types.ReportingPluginConfig) (ocr3types.ReportingPlugin[[]byte], ocr3types.ReportingPluginInfo, error) { + plugin, info, err := r.ReportingPluginFactory.NewReportingPlugin(ctx, config) + if err != nil { + return nil, ocr3types.ReportingPluginInfo{}, err + } + + wrappedPlugin := &reportingPluginChainSelectorToBytesAdapter{plugin: plugin} + return wrappedPlugin, info, nil +} + +// reportingPluginChainSelectorToBytesAdapter wraps a ReportingPlugin[securemint.ChainSelector] to implement ReportingPlugin[[]byte] +type reportingPluginChainSelectorToBytesAdapter struct { + plugin ocr3types.ReportingPlugin[sm.ChainSelector] +} + +var _ ocr3types.ReportingPlugin[[]byte] = (*reportingPluginChainSelectorToBytesAdapter)(nil) + +func (r *reportingPluginChainSelectorToBytesAdapter) Query(ctx context.Context, outctx ocr3types.OutcomeContext) (types.Query, error) { + return r.plugin.Query(ctx, outctx) +} + +func (r *reportingPluginChainSelectorToBytesAdapter) Observation(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query) (types.Observation, error) { + return r.plugin.Observation(ctx, outctx, query) +} + +func (r *reportingPluginChainSelectorToBytesAdapter) ValidateObservation(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, ao types.AttributedObservation) error { + return r.plugin.ValidateObservation(ctx, outctx, query, ao) +} + +func (r *reportingPluginChainSelectorToBytesAdapter) ObservationQuorum(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, aos []types.AttributedObservation) (bool, error) { + return r.plugin.ObservationQuorum(ctx, outctx, query, aos) +} + +func (r *reportingPluginChainSelectorToBytesAdapter) Outcome(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, aos []types.AttributedObservation) (ocr3types.Outcome, error) { + return r.plugin.Outcome(ctx, outctx, query, aos) +} + +func (r *reportingPluginChainSelectorToBytesAdapter) Reports(ctx context.Context, seqNr uint64, outcome ocr3types.Outcome) ([]ocr3types.ReportPlus[[]byte], error) { + // Get reports from the underlying plugin (which returns []ocr3types.ReportPlus[securemint.ChainSelector]) + reports, err := r.plugin.Reports(ctx, seqNr, outcome) + if err != nil { + return nil, err + } + + // Convert []ocr3types.ReportPlus[securemint.ChainSelector] to []ocr3types.ReportPlus[[]byte] + reportsWithInfo := make([]ocr3types.ReportPlus[[]byte], len(reports)) + for i, report := range reports { + info := make([]byte, 8) + binary.LittleEndian.PutUint64(info, uint64(report.ReportWithInfo.Info)) + reportsWithInfo[i] = ocr3types.ReportPlus[[]byte]{ + ReportWithInfo: ocr3types.ReportWithInfo[[]byte]{ + Report: report.ReportWithInfo.Report, + Info: info, + }, + TransmissionScheduleOverride: report.TransmissionScheduleOverride, + } + } + return reportsWithInfo, nil +} + +func (r *reportingPluginChainSelectorToBytesAdapter) ShouldAcceptAttestedReport(ctx context.Context, seqNr uint64, report ocr3types.ReportWithInfo[[]byte]) (bool, error) { + chainSelector := sm.ChainSelector(binary.LittleEndian.Uint64(report.Info[:8])) + + reportBytes := ocr3types.ReportWithInfo[sm.ChainSelector]{ + Report: report.Report, + Info: chainSelector, + } + return r.plugin.ShouldAcceptAttestedReport(ctx, seqNr, reportBytes) +} + +func (r *reportingPluginChainSelectorToBytesAdapter) ShouldTransmitAcceptedReport(ctx context.Context, seqNr uint64, report ocr3types.ReportWithInfo[[]byte]) (bool, error) { + chainSelector := sm.ChainSelector(binary.LittleEndian.Uint64(report.Info[:8])) + + reportBytes := ocr3types.ReportWithInfo[sm.ChainSelector]{ + Report: report.Report, + Info: chainSelector, + } + return r.plugin.ShouldTransmitAcceptedReport(ctx, seqNr, reportBytes) +} + +func (r *reportingPluginChainSelectorToBytesAdapter) Close() error { + return r.plugin.Close() +} diff --git a/pkg/loop/internal/reportingplugin/securemint/reportingplugin_adapter_test.go b/pkg/loop/internal/reportingplugin/securemint/reportingplugin_adapter_test.go new file mode 100644 index 0000000000..c6052a7a95 --- /dev/null +++ b/pkg/loop/internal/reportingplugin/securemint/reportingplugin_adapter_test.go @@ -0,0 +1,412 @@ +package securemint + +import ( + "context" + "encoding/binary" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + sm "github.com/smartcontractkit/chainlink-common/pkg/types/core/securemint" + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" + "github.com/smartcontractkit/libocr/offchainreporting2plus/types" +) + +func TestReportingPluginBytesToChainSelectorAdapter_Reports(t *testing.T) { + tests := []struct { + name string + mockReports []ocr3types.ReportPlus[[]byte] + mockError error + expectedError bool + expectedCount int + }{ + { + name: "successful conversion with single report", + mockReports: []ocr3types.ReportPlus[[]byte]{ + { + ReportWithInfo: ocr3types.ReportWithInfo[[]byte]{ + Report: []byte("test report"), + Info: []byte{0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // uint64(1) in little endian + }, + }, + }, + mockError: nil, + expectedError: false, + expectedCount: 1, + }, + { + name: "successful conversion with multiple reports", + mockReports: []ocr3types.ReportPlus[[]byte]{ + { + ReportWithInfo: ocr3types.ReportWithInfo[[]byte]{ + Report: []byte("test report 1"), + Info: []byte{0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // uint64(1) + }, + }, + { + ReportWithInfo: ocr3types.ReportWithInfo[[]byte]{ + Report: []byte("test report 2"), + Info: []byte{0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // uint64(2) + }, + }, + }, + mockError: nil, + expectedError: false, + expectedCount: 2, + }, + { + name: "empty reports", + mockReports: []ocr3types.ReportPlus[[]byte]{}, + mockError: nil, + expectedError: false, + expectedCount: 0, + }, + { + name: "short info bytes (less than 8 bytes)", + mockReports: []ocr3types.ReportPlus[[]byte]{ + { + ReportWithInfo: ocr3types.ReportWithInfo[[]byte]{ + Report: []byte("test report"), + Info: []byte{0x01, 0x02}, // Only 2 bytes + }, + }, + }, + mockError: errors.New("info is less than 8 bytes"), + expectedError: true, + expectedCount: 1, + }, + { + name: "underlying plugin error", + mockReports: nil, + mockError: errors.New("plugin error"), + expectedError: true, + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockPlugin := new(MockReportingPluginBytes) + adapter := &reportingPluginBytesToChainSelectorAdapter{plugin: mockPlugin} + + mockPlugin.On("Reports", mock.Anything, mock.Anything, mock.Anything).Return(tt.mockReports, tt.mockError) + + reports, err := adapter.Reports(context.Background(), 1, ocr3types.Outcome([]byte("test outcome"))) + + if tt.expectedError { + assert.Error(t, err) + assert.Nil(t, reports) + } else { + assert.NoError(t, err) + assert.Len(t, reports, tt.expectedCount) + + // Verify conversion for each report + for i, report := range reports { + assert.Equal(t, tt.mockReports[i].ReportWithInfo.Report, report.ReportWithInfo.Report) + expectedChainSelector := sm.ChainSelector(binary.LittleEndian.Uint64(tt.mockReports[i].ReportWithInfo.Info[:8])) + assert.Equal(t, expectedChainSelector, report.ReportWithInfo.Info) + } + } + + mockPlugin.AssertExpectations(t) + }) + } +} + +func TestReportingPluginBytesToChainSelectorAdapter_ShouldAcceptAttestedReport(t *testing.T) { + mockPlugin := new(MockReportingPluginBytes) + adapter := &reportingPluginBytesToChainSelectorAdapter{plugin: mockPlugin} + + chainSelector := sm.ChainSelector(123) + report := ocr3types.ReportWithInfo[sm.ChainSelector]{ + Report: []byte("test report"), + Info: chainSelector, + } + + // Convert chain selector to bytes for mock expectation + expectedBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(expectedBytes, uint64(chainSelector)) + expectedReport := ocr3types.ReportWithInfo[[]byte]{ + Report: []byte("test report"), + Info: expectedBytes, + } + + mockPlugin.On("ShouldAcceptAttestedReport", mock.Anything, uint64(1), expectedReport).Return(true, nil) + + accepted, err := adapter.ShouldAcceptAttestedReport(context.Background(), 1, report) + + assert.NoError(t, err) + assert.True(t, accepted) + mockPlugin.AssertExpectations(t) +} + +func TestReportingPluginBytesToChainSelectorAdapter_ShouldTransmitAcceptedReport(t *testing.T) { + mockPlugin := new(MockReportingPluginBytes) + adapter := &reportingPluginBytesToChainSelectorAdapter{plugin: mockPlugin} + + chainSelector := sm.ChainSelector(456) + report := ocr3types.ReportWithInfo[sm.ChainSelector]{ + Report: []byte("test report"), + Info: chainSelector, + } + + // Convert chain selector to bytes for mock expectation + expectedBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(expectedBytes, uint64(chainSelector)) + expectedReport := ocr3types.ReportWithInfo[[]byte]{ + Report: []byte("test report"), + Info: expectedBytes, + } + + mockPlugin.On("ShouldTransmitAcceptedReport", mock.Anything, uint64(2), expectedReport).Return(false, nil) + + transmit, err := adapter.ShouldTransmitAcceptedReport(context.Background(), 2, report) + + assert.NoError(t, err) + assert.False(t, transmit) + mockPlugin.AssertExpectations(t) +} + +func TestReportingPluginChainSelectorToBytesAdapter_Reports(t *testing.T) { + tests := []struct { + name string + mockReports []ocr3types.ReportPlus[sm.ChainSelector] + mockError error + expectedError bool + expectedCount int + }{ + { + name: "successful conversion with single report", + mockReports: []ocr3types.ReportPlus[sm.ChainSelector]{ + { + ReportWithInfo: ocr3types.ReportWithInfo[sm.ChainSelector]{ + Report: []byte("test report"), + Info: sm.ChainSelector(1), + }, + }, + }, + mockError: nil, + expectedError: false, + expectedCount: 1, + }, + { + name: "successful conversion with multiple reports", + mockReports: []ocr3types.ReportPlus[sm.ChainSelector]{ + { + ReportWithInfo: ocr3types.ReportWithInfo[sm.ChainSelector]{ + Report: []byte("test report 1"), + Info: sm.ChainSelector(1), + }, + }, + { + ReportWithInfo: ocr3types.ReportWithInfo[sm.ChainSelector]{ + Report: []byte("test report 2"), + Info: sm.ChainSelector(2), + }, + }, + }, + mockError: nil, + expectedError: false, + expectedCount: 2, + }, + { + name: "empty reports", + mockReports: []ocr3types.ReportPlus[sm.ChainSelector]{}, + mockError: nil, + expectedError: false, + expectedCount: 0, + }, + { + name: "underlying plugin error", + mockReports: nil, + mockError: errors.New("plugin error"), + expectedError: true, + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockPlugin := new(MockReportingPluginChainSelector) + adapter := &reportingPluginChainSelectorToBytesAdapter{plugin: mockPlugin} + + mockPlugin.On("Reports", mock.Anything, mock.Anything, mock.Anything).Return(tt.mockReports, tt.mockError) + + reports, err := adapter.Reports(context.Background(), 1, ocr3types.Outcome([]byte("test outcome"))) + + if tt.expectedError { + assert.Error(t, err) + assert.Nil(t, reports) + } else { + assert.NoError(t, err) + assert.Len(t, reports, tt.expectedCount) + + // Verify conversion for each report + for i, report := range reports { + assert.Equal(t, tt.mockReports[i].ReportWithInfo.Report, report.ReportWithInfo.Report) + + // Check bytes conversion + expectedBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(expectedBytes, uint64(tt.mockReports[i].ReportWithInfo.Info)) + assert.Equal(t, expectedBytes, report.ReportWithInfo.Info) + } + } + + mockPlugin.AssertExpectations(t) + }) + } +} + +func TestReportingPluginChainSelectorToBytesAdapter_ShouldAcceptAttestedReport(t *testing.T) { + mockPlugin := new(MockReportingPluginChainSelector) + adapter := &reportingPluginChainSelectorToBytesAdapter{plugin: mockPlugin} + + chainSelectorBytes := []byte{0x7B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} // uint64(123) in little endian + report := ocr3types.ReportWithInfo[[]byte]{ + Report: []byte("test report"), + Info: chainSelectorBytes, + } + + // Convert bytes to chain selector for mock expectation + expectedChainSelector := sm.ChainSelector(binary.LittleEndian.Uint64(chainSelectorBytes)) + expectedReport := ocr3types.ReportWithInfo[sm.ChainSelector]{ + Report: []byte("test report"), + Info: expectedChainSelector, + } + + mockPlugin.On("ShouldAcceptAttestedReport", mock.Anything, uint64(1), expectedReport).Return(true, nil) + + accepted, err := adapter.ShouldAcceptAttestedReport(context.Background(), 1, report) + + assert.NoError(t, err) + assert.True(t, accepted) + mockPlugin.AssertExpectations(t) +} + +func TestReportingPluginChainSelectorToBytesAdapter_ShouldTransmitAcceptedReport(t *testing.T) { + mockPlugin := new(MockReportingPluginChainSelector) + adapter := &reportingPluginChainSelectorToBytesAdapter{plugin: mockPlugin} + + chainSelectorBytes := []byte{0xC8, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} // uint64(456) in little endian + report := ocr3types.ReportWithInfo[[]byte]{ + Report: []byte("test report"), + Info: chainSelectorBytes, + } + + // Convert bytes to chain selector for mock expectation + expectedChainSelector := sm.ChainSelector(binary.LittleEndian.Uint64(chainSelectorBytes)) + expectedReport := ocr3types.ReportWithInfo[sm.ChainSelector]{ + Report: []byte("test report"), + Info: expectedChainSelector, + } + + mockPlugin.On("ShouldTransmitAcceptedReport", mock.Anything, uint64(2), expectedReport).Return(false, nil) + + transmit, err := adapter.ShouldTransmitAcceptedReport(context.Background(), 2, report) + + assert.NoError(t, err) + assert.False(t, transmit) + mockPlugin.AssertExpectations(t) +} + +// MockReportingPluginBytes is a mock implementation of ocr3types.ReportingPlugin[[]byte] +type MockReportingPluginBytes struct { + mock.Mock +} + +func (m *MockReportingPluginBytes) Query(ctx context.Context, outctx ocr3types.OutcomeContext) (types.Query, error) { + args := m.Called(ctx, outctx) + return args.Get(0).(types.Query), args.Error(1) +} + +func (m *MockReportingPluginBytes) Observation(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query) (types.Observation, error) { + args := m.Called(ctx, outctx, query) + return args.Get(0).(types.Observation), args.Error(1) +} + +func (m *MockReportingPluginBytes) ValidateObservation(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, ao types.AttributedObservation) error { + args := m.Called(ctx, outctx, query, ao) + return args.Error(0) +} + +func (m *MockReportingPluginBytes) ObservationQuorum(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, aos []types.AttributedObservation) (bool, error) { + args := m.Called(ctx, outctx, query, aos) + return args.Bool(0), args.Error(1) +} + +func (m *MockReportingPluginBytes) Outcome(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, aos []types.AttributedObservation) (ocr3types.Outcome, error) { + args := m.Called(ctx, outctx, query, aos) + return args.Get(0).(ocr3types.Outcome), args.Error(1) +} + +func (m *MockReportingPluginBytes) Reports(ctx context.Context, seqNr uint64, outcome ocr3types.Outcome) ([]ocr3types.ReportPlus[[]byte], error) { + args := m.Called(ctx, seqNr, outcome) + return args.Get(0).([]ocr3types.ReportPlus[[]byte]), args.Error(1) +} + +func (m *MockReportingPluginBytes) ShouldAcceptAttestedReport(ctx context.Context, seqNr uint64, report ocr3types.ReportWithInfo[[]byte]) (bool, error) { + args := m.Called(ctx, seqNr, report) + return args.Bool(0), args.Error(1) +} + +func (m *MockReportingPluginBytes) ShouldTransmitAcceptedReport(ctx context.Context, seqNr uint64, report ocr3types.ReportWithInfo[[]byte]) (bool, error) { + args := m.Called(ctx, seqNr, report) + return args.Bool(0), args.Error(1) +} + +func (m *MockReportingPluginBytes) Close() error { + args := m.Called() + return args.Error(0) +} + +// MockReportingPluginChainSelector is a mock implementation of ocr3types.ReportingPlugin[securemint.ChainSelector] +type MockReportingPluginChainSelector struct { + mock.Mock +} + +func (m *MockReportingPluginChainSelector) Query(ctx context.Context, outctx ocr3types.OutcomeContext) (types.Query, error) { + args := m.Called(ctx, outctx) + return args.Get(0).(types.Query), args.Error(1) +} + +func (m *MockReportingPluginChainSelector) Observation(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query) (types.Observation, error) { + args := m.Called(ctx, outctx, query) + return args.Get(0).(types.Observation), args.Error(1) +} + +func (m *MockReportingPluginChainSelector) ValidateObservation(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, ao types.AttributedObservation) error { + args := m.Called(ctx, outctx, query, ao) + return args.Error(0) +} + +func (m *MockReportingPluginChainSelector) ObservationQuorum(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, aos []types.AttributedObservation) (bool, error) { + args := m.Called(ctx, outctx, query, aos) + return args.Bool(0), args.Error(1) +} + +func (m *MockReportingPluginChainSelector) Outcome(ctx context.Context, outctx ocr3types.OutcomeContext, query types.Query, aos []types.AttributedObservation) (ocr3types.Outcome, error) { + args := m.Called(ctx, outctx, query, aos) + return args.Get(0).(ocr3types.Outcome), args.Error(1) +} + +func (m *MockReportingPluginChainSelector) Reports(ctx context.Context, seqNr uint64, outcome ocr3types.Outcome) ([]ocr3types.ReportPlus[sm.ChainSelector], error) { + args := m.Called(ctx, seqNr, outcome) + return args.Get(0).([]ocr3types.ReportPlus[sm.ChainSelector]), args.Error(1) +} + +func (m *MockReportingPluginChainSelector) ShouldAcceptAttestedReport(ctx context.Context, seqNr uint64, report ocr3types.ReportWithInfo[sm.ChainSelector]) (bool, error) { + args := m.Called(ctx, seqNr, report) + return args.Bool(0), args.Error(1) +} + +func (m *MockReportingPluginChainSelector) ShouldTransmitAcceptedReport(ctx context.Context, seqNr uint64, report ocr3types.ReportWithInfo[sm.ChainSelector]) (bool, error) { + args := m.Called(ctx, seqNr, report) + return args.Bool(0), args.Error(1) +} + +func (m *MockReportingPluginChainSelector) Close() error { + args := m.Called() + return args.Error(0) +} diff --git a/pkg/loop/plugin_secure_mint.go b/pkg/loop/plugin_secure_mint.go new file mode 100644 index 0000000000..6941c62ea4 --- /dev/null +++ b/pkg/loop/plugin_secure_mint.go @@ -0,0 +1,59 @@ +package loop + +import ( + "context" + + "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" + + securemint "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/reportingplugin/securemint" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" +) + +// GRPCPluginSecureMint implements a go-plugin [plugin.GRPCPlugin] for [core.PluginSecureMint]. +type GRPCPluginSecureMint struct { + plugin.NetRPCUnsupportedPlugin + + BrokerConfig + + PluginServer core.PluginSecureMint + + pluginClient *securemint.PluginSecureMintClient +} + +var _ plugin.GRPCPlugin = (*GRPCPluginSecureMint)(nil) + +// GRPCServer is called by the go-plugin framework. It registers the plugin server with the given broker and server. +func (p *GRPCPluginSecureMint) GRPCServer(broker *plugin.GRPCBroker, server *grpc.Server) error { + return securemint.RegisterPluginSecureMintServer(server, broker, p.BrokerConfig, p.PluginServer) +} + +// GRPCClient is called by the go-plugin framework. It returns the pluginClient [types.PluginSecureMint], updated with refreshed broker and conn. +func (p *GRPCPluginSecureMint) GRPCClient(_ context.Context, broker *plugin.GRPCBroker, conn *grpc.ClientConn) (any, error) { + if p.pluginClient == nil { + p.pluginClient = securemint.NewPluginSecureMintClient(p.BrokerConfig) + } + p.pluginClient.Refresh(broker, conn) + + return core.PluginSecureMint(p.pluginClient), nil +} + +// ClientConfig is called by the loopp plugin framework to configure the plugin. +func (p *GRPCPluginSecureMint) ClientConfig() *plugin.ClientConfig { + c := &plugin.ClientConfig{ + HandshakeConfig: PluginSecureMintHandshakeConfig(), + Plugins: map[string]plugin.Plugin{core.PluginSecureMintName: p}, + } + if p.pluginClient == nil { + p.pluginClient = securemint.NewPluginSecureMintClient(p.BrokerConfig) + } + return ManagedGRPCClientConfig(c, p.BrokerConfig) +} + +// PluginSecureMintHandshakeConfig is used for making a connection between the loopp plugin client and server. +func PluginSecureMintHandshakeConfig() plugin.HandshakeConfig { + return plugin.HandshakeConfig{ + MagicCookieKey: "CL_PLUGIN_SECURE_MINT_MAGIC_COOKIE", + MagicCookieValue: "2cba6293d2ae66563d6838334f8b9c3b11c8d3388a1763835a7104d63f44b932", + } +} diff --git a/pkg/loop/secure_mint_service.go b/pkg/loop/secure_mint_service.go new file mode 100644 index 0000000000..895274e42e --- /dev/null +++ b/pkg/loop/secure_mint_service.go @@ -0,0 +1,56 @@ +package loop + +import ( + "context" + "fmt" + "os/exec" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/loop/internal/goplugin" + "github.com/smartcontractkit/chainlink-common/pkg/services" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + sm "github.com/smartcontractkit/chainlink-common/pkg/types/core/securemint" + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" +) + +// PluginSecureMintService is a [goplugin.PluginService] that maintains an internal [core.PluginSecureMint]. +type PluginSecureMintService struct { + goplugin.PluginService[*GRPCPluginSecureMint, core.ReportingPluginFactory[sm.ChainSelector]] +} + +var _ ocr3types.ReportingPluginFactory[sm.ChainSelector] = (*PluginSecureMintService)(nil) + +// NewPluginSecureMintService returns a new [*PluginSecureMintService]. +// `cmd` must return a new exec.Cmd each time it is called. +// This is the entry point for the core node to use the secure mint loopp plugin. +func NewPluginSecureMintService(lggr logger.Logger, grpcOpts GRPCOpts, cmd func() *exec.Cmd, externalAdapter sm.ExternalAdapter) *PluginSecureMintService { + newService := func(ctx context.Context, instance any) (core.ReportingPluginFactory[sm.ChainSelector], services.HealthReporter, error) { + lggr.Infof("creating new PluginSecureMintService for client or server: type %T: %+v", instance, instance) + plug, ok := instance.(core.PluginSecureMint) + if !ok { + return nil, nil, fmt.Errorf("expected PluginSecureMint but got %T", instance) + } + factory, err := plug.NewSecureMintFactory(ctx, lggr, externalAdapter) + if err != nil { + return nil, nil, err + } + lggr.Infof("created factory of type %T: %+v", factory, factory) + + return factory, plug, nil + } + stopCh := make(chan struct{}) + lggr = logger.Named(lggr, "PluginSecureMintService") + var ms PluginSecureMintService + broker := BrokerConfig{StopCh: stopCh, Logger: lggr, GRPCOpts: grpcOpts} + ms.Init(core.PluginSecureMintName, &GRPCPluginSecureMint{BrokerConfig: broker}, newService, lggr, cmd, stopCh) + return &ms +} + +// NewReportingPlugin is called by libocr to create a new reporting plugin. +// It delegates to the internal ReportingPluginFactory, which was created by the NewSecureMintFactory function. +func (m *PluginSecureMintService) NewReportingPlugin(ctx context.Context, config ocr3types.ReportingPluginConfig) (ocr3types.ReportingPlugin[sm.ChainSelector], ocr3types.ReportingPluginInfo, error) { + if err := m.WaitCtx(ctx); err != nil { + return nil, ocr3types.ReportingPluginInfo{}, err + } + return m.Service.NewReportingPlugin(ctx, config) +} diff --git a/pkg/types/chains/solana/solana.go b/pkg/types/chains/solana/solana.go new file mode 100644 index 0000000000..fd7b795a96 --- /dev/null +++ b/pkg/types/chains/solana/solana.go @@ -0,0 +1,14 @@ +package solana + +const ( + PublicKeyLength = 32 +) + +// represents solana-style AccountsMeta +type AccountMeta struct { + PublicKey [PublicKeyLength]byte + IsWritable bool + IsSigner bool +} + +type AccountMetaSlice []*AccountMeta diff --git a/pkg/types/core/secure_mint_interface.go b/pkg/types/core/secure_mint_interface.go new file mode 100644 index 0000000000..1ad9631054 --- /dev/null +++ b/pkg/types/core/secure_mint_interface.go @@ -0,0 +1,25 @@ +package core + +import ( + "context" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/services" + "github.com/smartcontractkit/chainlink-common/pkg/types/core/securemint" + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" +) + +const PluginSecureMintName = "securemint" + +// PluginSecureMint is the interface for the secure mint plugin. +type PluginSecureMint interface { + services.Service + // NewSecureMintFactory returns a ReportingPluginFactory for the secure mint plugin. + NewSecureMintFactory(ctx context.Context, lggr logger.Logger, externalAdapter securemint.ExternalAdapter) (ReportingPluginFactory[securemint.ChainSelector], error) +} + +// ReportingPluginFactory wraps ocr3types.ReportingPluginFactory[RI] to add a Service to it. +type ReportingPluginFactory[RI any] interface { + services.Service + ocr3types.ReportingPluginFactory[RI] +} diff --git a/pkg/types/core/securemint/types.go b/pkg/types/core/securemint/types.go new file mode 100644 index 0000000000..cfc38db3f1 --- /dev/null +++ b/pkg/types/core/securemint/types.go @@ -0,0 +1,59 @@ +package securemint + +import ( + "context" + "math/big" + "time" + + "github.com/smartcontractkit/libocr/offchainreporting2/types" +) + +// Report is the report that's created by the secure mint plugin. +// It contains a mintable token amount at a certain block number for a specific chain. +type Report struct { + ConfigDigest types.ConfigDigest + SeqNr uint64 + Block BlockNumber + Mintable *big.Int + + // The following fields might be useful in the future, but are not currently used + // ReserveAmount *big.Int + // ReserveTimestamp time.Time +} + +// ExternalAdapter is the component used by the secure mint plugin to request various secure mint related data points. +type ExternalAdapter interface { + GetPayload(ctx context.Context, blocks Blocks) (ExternalAdapterPayload, error) +} + +// BlockNumber is a block number. +type BlockNumber uint64 + +// ChainSelector is a way of uniquely identifying a chain, see https://github.com/smartcontractkit/chain-selectors. +type ChainSelector uint64 + +// Blocks contains the latest blocks per chain. +type Blocks map[ChainSelector]BlockNumber + +// BlockMintablePair is a mintable amount of a specific token at a certain block number. +type BlockMintablePair struct { + Block BlockNumber + Mintable *big.Int +} + +// Mintables contains the mintable amounts of a specific token per chain. +type Mintables map[ChainSelector]BlockMintablePair + +// ReserveInfo is a reserve amount of a specific token at a certain timestamp. +type ReserveInfo struct { + ReserveAmount *big.Int + Timestamp time.Time +} + +// ExternalAdapterPayload is the response from an EA containing various secure mint related data points. +type ExternalAdapterPayload struct { + Mintables Mintables // The mintable amounts for each chain and its block. + ReserveInfo ReserveInfo // The latest reserve amount and timestamp used to calculate the minting allowance above. + + LatestBlocks Blocks // The latest blocks for each chain. +} diff --git a/pkg/types/plugin.go b/pkg/types/plugin.go index a371b6cd70..88ffeece73 100644 --- a/pkg/types/plugin.go +++ b/pkg/types/plugin.go @@ -17,6 +17,7 @@ const ( OCR3Capability OCR2PluginType = "ocr3-capability" VaultPlugin OCR2PluginType = "vault-plugin" DonTimePlugin OCR2PluginType = "dontime" + SecureMint OCR2PluginType = "securemint" CCIPCommit OCR2PluginType = "ccip-commit" CCIPExecution OCR2PluginType = "ccip-execution"