diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 639766cd5da..cdf76b27ce3 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -87,6 +87,8 @@ jobs: secrets: AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID_SDLC }} AWS_ROLE_PUBLISH_ARN: ${{ secrets.AWS_OIDC_IAM_ROLE_BUILD_PUBLISH_DEVELOP_PR }} + AWS_ROLE_GATI_ARN: ${{ secrets.AWS_OIDC_GLOBAL_READ_ONLY_TOKEN_ISSUER_ROLE_ARN }} + AWS_LAMBDA_GATI_URL: ${{ secrets.AWS_INFRA_RELENG_TOKEN_ISSUER_LAMBDA_URL }} docker-core-plugins: needs: [init] diff --git a/core/chainlink.Dockerfile b/core/chainlink.Dockerfile index 5d9865e9700..4c96441026d 100644 --- a/core/chainlink.Dockerfile +++ b/core/chainlink.Dockerfile @@ -7,6 +7,10 @@ COPY GNUmakefile package.json ./ COPY tools/bin/ldflags ./tools/bin/ ADD go.mod go.sum ./ + +COPY ./plugins/scripts/setup_git_auth.sh /tmp/ +RUN --mount=type=secret,id=GIT_AUTH_TOKEN /tmp/setup_git_auth.sh + RUN --mount=type=cache,target=/go/pkg/mod \ go mod download diff --git a/core/config/env/env.go b/core/config/env/env.go index f97bb30c808..6cf87f9200b 100644 --- a/core/config/env/env.go +++ b/core/config/env/env.go @@ -24,13 +24,14 @@ var ( // LOOPP commands and vars var ( - MedianPlugin = NewPlugin("median") - MercuryPlugin = NewPlugin("mercury") - AptosPlugin = NewPlugin("aptos") - CosmosPlugin = NewPlugin("cosmos") - SolanaPlugin = NewPlugin("solana") - StarknetPlugin = NewPlugin("starknet") - TronPlugin = NewPlugin("tron") + MedianPlugin = NewPlugin("median") + SecureMintPlugin = NewPlugin("securemint") + MercuryPlugin = NewPlugin("mercury") + AptosPlugin = NewPlugin("aptos") + CosmosPlugin = NewPlugin("cosmos") + SolanaPlugin = NewPlugin("solana") + StarknetPlugin = NewPlugin("starknet") + TronPlugin = NewPlugin("tron") // PrometheusDiscoveryHostName is the externally accessible hostname // published by the node in the `/discovery` endpoint. Generally, it is expected to match // the public hostname of node. diff --git a/core/services/job/orm.go b/core/services/job/orm.go index daf6caaffe1..c2c869b160c 100644 --- a/core/services/job/orm.go +++ b/core/services/job/orm.go @@ -167,7 +167,7 @@ func (o *orm) AssertBridgesExist(ctx context.Context, p pipeline.Pipeline) error return nil } -// CreateJob creates the job, and it's associated spec record. +// CreateJob creates the job, and its associated spec record. // Expects an unmarshalled job spec as the jb argument i.e. output from ValidatedXX. // Scans all persisted records back into jb func (o *orm) CreateJob(ctx context.Context, jb *Job) error { diff --git a/core/services/ocr2/delegate.go b/core/services/ocr2/delegate.go index 531afb21b81..d3ac027a65d 100644 --- a/core/services/ocr2/delegate.go +++ b/core/services/ocr2/delegate.go @@ -20,6 +20,7 @@ import ( libocr2 "github.com/smartcontractkit/libocr/offchainreporting2plus" "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + "github.com/smartcontractkit/por_mock_ocr3plugin/por" ocr2keepers20 "github.com/smartcontractkit/chainlink-automation/pkg/v2" ocr2keepers20config "github.com/smartcontractkit/chainlink-automation/pkg/v2/config" @@ -66,6 +67,8 @@ import ( ocr2keeper21core "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ocr2keeper/evmregistry/v21/core" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/vault" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/validate" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr3/securemint" + sm_adapter "github.com/smartcontractkit/chainlink/v2/core/services/ocr3/securemint/keyringadapter" "github.com/smartcontractkit/chainlink/v2/core/services/ocrcommon" "github.com/smartcontractkit/chainlink/v2/core/services/pipeline" "github.com/smartcontractkit/chainlink/v2/core/services/relay" @@ -541,6 +544,8 @@ func (d *Delegate) ServicesForSpec(ctx context.Context, jb job.Job) ([]job.Servi case types.VaultPlugin: return d.newServicesVaultPlugin(ctx, lggr, jb, d.gatewayConnectorServiceWrapper) + case types.SecureMint: + return d.newServicesSecureMint(ctx, lggr, jb, bootstrapPeers, kb, ocrDB, lc) default: return nil, errors.Errorf("plugin type %s not supported", spec.PluginType) @@ -1192,6 +1197,54 @@ func (d *Delegate) newServicesMedian( return medianServices, err2 } +func (d *Delegate) newServicesSecureMint( + ctx context.Context, + lggr logger.SugaredLogger, + jb job.Job, + bootstrapPeers []commontypes.BootstrapperLocator, + kb ocr2key.KeyBundle, + ocrDB *db, + lc ocrtypes.LocalConfig, +) ([]job.ServiceCtx, error) { + spec := jb.OCR2OracleSpec + rid, err := spec.RelayID() + if err != nil { + return nil, ErrJobSpecNoRelayer{Err: err, PluginName: "securemint"} + } + + ocrLogger := ocrcommon.NewOCRWrapper(lggr, d.cfg.OCR2().TraceLogging(), func(ctx context.Context, msg string) { + lggr.ErrorIf(d.jobORM.RecordError(ctx, jb.ID, msg), "unable to record error") + }) + + oracleArgsNoPlugin := libocr2.OCR3OracleArgs[por.ChainSelector]{ + BinaryNetworkEndpointFactory: d.peerWrapper.Peer2, + V2Bootstrappers: bootstrapPeers, + Database: ocrDB, + LocalConfig: lc, + Logger: ocrLogger, + MonitoringEndpoint: d.monitoringEndpointGen.GenMonitoringEndpoint(rid.Network, rid.ChainID, spec.ContractID, synchronization.OCR2Median), + OffchainKeyring: kb, + OnchainKeyring: sm_adapter.NewSecureMintOCR3OnchainKeyringAdapter(kb), + MetricsRegisterer: prometheus.WrapRegistererWith(map[string]string{"job_name": jb.Name.ValueOrZero()}, prometheus.DefaultRegisterer), + } + + smConfig := securemint.NewJobConfig( + d.cfg.JobPipeline().MaxSuccessfulRuns(), + d.cfg.JobPipeline().ResultWriteQueueDepth(), + d.cfg, + ) + + relayer, err := d.RelayGetter.Get(rid) + if err != nil { + return nil, ErrRelayNotEnabled{Err: err, PluginName: "securemint", Relay: spec.Relay} + } + + secureMintServices, err := securemint.NewSecureMintServices(ctx, jb, d.isNewlyCreatedJob, relayer, d.pipelineRunner, lggr, oracleArgsNoPlugin, smConfig) + secureMintServices = append(secureMintServices, ocrLogger) + + return secureMintServices, err +} + func (d *Delegate) newServicesOCR2Keepers( ctx context.Context, lggr logger.SugaredLogger, diff --git a/core/services/ocr2/validate/validate.go b/core/services/ocr2/validate/validate.go index 9a8052c0bd0..5bb5fe0ed53 100644 --- a/core/services/ocr2/validate/validate.go +++ b/core/services/ocr2/validate/validate.go @@ -12,23 +12,22 @@ import ( "github.com/pelletier/go-toml" pkgerrors "github.com/pkg/errors" - libocr2 "github.com/smartcontractkit/libocr/offchainreporting2plus" - "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/loop/reportingplugins" "github.com/smartcontractkit/chainlink-common/pkg/types" - "github.com/smartcontractkit/chainlink/v2/core/config/env" "github.com/smartcontractkit/chainlink/v2/core/services/job" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/config" lloconfig "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/llo/config" mercuryconfig "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/mercury/config" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/vault" + sm_config "github.com/smartcontractkit/chainlink/v2/core/services/ocr3/securemint/config" "github.com/smartcontractkit/chainlink/v2/core/services/ocrcommon" "github.com/smartcontractkit/chainlink/v2/core/services/pipeline" "github.com/smartcontractkit/chainlink/v2/core/services/relay" evmtypes "github.com/smartcontractkit/chainlink/v2/core/services/relay/evm/types" "github.com/smartcontractkit/chainlink/v2/plugins" + libocr2 "github.com/smartcontractkit/libocr/offchainreporting2plus" ) // ValidatedOracleSpecToml validates an oracle spec that came from TOML @@ -129,6 +128,9 @@ func validateSpec(ctx context.Context, tree *toml.Tree, spec job.Job, rc plugins return validateGenericPluginSpec(ctx, spec.OCR2OracleSpec, rc) case types.VaultPlugin: return validateVaultPluginSpec(spec.OCR2OracleSpec.PluginConfig) + case types.SecureMint: + return validateSecureMintSpec(spec.OCR2OracleSpec.PluginConfig) + case "": return errors.New("no plugin specified") default: @@ -390,3 +392,20 @@ func validateOCR2LLOSpec(jsonConfig job.JSONConfig) error { } return pkgerrors.Wrap(pluginConfig.Validate(), "LLO PluginConfig is invalid") } + +func validateSecureMintSpec(jsonConfig job.JSONConfig) error { + if jsonConfig == nil { + return errors.New("secure mint plugin config is empty") + } + + smConfig, err := sm_config.Parse(jsonConfig.Bytes()) + if err != nil { + return pkgerrors.Wrap(err, "error while parsing secure mint plugin config") + } + + if err := smConfig.Validate(); err != nil { + return fmt.Errorf("invalid secure mint plugin config: %#v, err: %w", smConfig, err) + } + + return nil +} diff --git a/core/services/ocr3/securemint/README.md b/core/services/ocr3/securemint/README.md new file mode 100644 index 00000000000..3723ef2f4ab --- /dev/null +++ b/core/services/ocr3/securemint/README.md @@ -0,0 +1,38 @@ +# SecureMint Plugin + +## Overview + +The SecureMint plugin is a plugin that allows for secure minting of tokens. + +## Validation + +Validating whether the SecureMint plugin is working as expected is done by running the integration test. + +The test is located in the `core/services/ocr3/securemint` directory. + +### Prerequisites: +```bash +docker run --name cl-postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=dbname -p 5432:5432 -d postgres +make setup-testdb +``` + +### Run test: +```bash + time CL_DATABASE_URL=postgresql://chainlink_dev:insecurepassword@localhost:5432/chainlink_development_test?sslmode=disable go test -timeout 2m -run ^TestIntegration_SecureMint_happy_path$ github.com/smartcontractkit/chainlink/v2/core/services/ocr3/securemint -v 2>&1 | tee all.log | awk '/DEBUG|INFO|WARN|ERROR/ { print > "node_logs.log"; next }; { print > "other.log" }; tail all.log' +``` + +### If you change any dependencies: +```bash +go mod tidy && go mod vendor +modvendor -copy="**/*.a **/*.h" -v +``` + +(the `modvendor` step might not be necessary, but for me it was (see also https://github.com/marcboeker/go-duckdb/issues/174#issuecomment-1979097864)) + +### Logs + +* other.log: Contains all non-node output from the test run, this can be used to quickly see test failures +* node_logs.log: Contains all logs from the nodes started up in the test run, this can be used to see the full output of the test run +* all.log: Contains the complete output of the test run, this can be used to see test failures within the context of the node logs + + diff --git a/core/services/ocr3/securemint/config/config.go b/core/services/ocr3/securemint/config/config.go new file mode 100644 index 00000000000..98ff50da0a0 --- /dev/null +++ b/core/services/ocr3/securemint/config/config.go @@ -0,0 +1,44 @@ +package config + +import ( + "encoding/json" + + "github.com/pkg/errors" +) + +// SecureMintConfig holds secure mint specific configuration +type SecureMintConfig struct { + Token string `json:"token"` + Reserves string `json:"reserves"` +} + +// Parse parses the secure mint configuration from JSON bytes +func Parse(configBytes []byte) (*SecureMintConfig, error) { + if len(configBytes) == 0 { + return nil, errors.New("secure mint config cannot be empty") + } + + var config SecureMintConfig + if err := json.Unmarshal(configBytes, &config); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal SecureMintConfig") + } + + return &config, nil +} + +// Validate validates the secure mint plugin-specific config. +func (cfg *SecureMintConfig) Validate() error { + if cfg == nil { + return errors.New("secure mint plugin config cannot be nil") + } + + if cfg.Token == "" { + return errors.New("token cannot be empty") + } + + if cfg.Reserves == "" { + return errors.New("reserves cannot be empty") + } + + return nil +} diff --git a/core/services/ocr3/securemint/config/config_test.go b/core/services/ocr3/securemint/config/config_test.go new file mode 100644 index 00000000000..64cd766eb40 --- /dev/null +++ b/core/services/ocr3/securemint/config/config_test.go @@ -0,0 +1,116 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_Validate(t *testing.T) { + tests := []struct { + name string + cfg *SecureMintConfig + err bool + }{ + { + name: "valid config", + cfg: &SecureMintConfig{ + Token: "eth", + Reserves: "platform", + }, + err: false, + }, + { + name: "nil config", + cfg: nil, + err: true, + }, + { + name: "invalid token", + cfg: &SecureMintConfig{ + Token: "", + Reserves: "platform", + }, + err: true, + }, + { + name: "invalid reserves", + cfg: &SecureMintConfig{ + Token: "eth", + Reserves: "", + }, + err: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if tt.err { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestParseSecureMintConfig(t *testing.T) { + tests := []struct { + name string + configJSON string + expectedToken string + expectedReserves string + expectError bool + }{ + { + name: "empty config is invalid", + configJSON: "", + expectError: true, + }, + { + name: "custom values", + configJSON: `{"token": "btc", "reserves": "custom"}`, + expectedToken: "btc", + expectedReserves: "custom", + expectError: false, + }, + { + name: "partial config uses empty string", + configJSON: `{"token": "link"}`, + expectedToken: "link", + expectedReserves: "", + expectError: false, + }, + { + name: "partial config uses empty string 2", + configJSON: `{"reserves": "custom"}`, + expectedToken: "", + expectedReserves: "custom", + expectError: false, + }, + { + name: "invalid JSON", + configJSON: `{"token": "btc", "reserves":}`, + expectedToken: "", + expectedReserves: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := Parse([]byte(tt.configJSON)) + + if tt.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, config) + require.Equal(t, tt.expectedToken, config.Token) + require.Equal(t, tt.expectedReserves, config.Reserves) + }) + } +} diff --git a/core/services/ocr3/securemint/ea/ea.go b/core/services/ocr3/securemint/ea/ea.go new file mode 100644 index 00000000000..58b99235d3f --- /dev/null +++ b/core/services/ocr3/securemint/ea/ea.go @@ -0,0 +1,157 @@ +package ea + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "strconv" + "time" + + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/job" + sm_config "github.com/smartcontractkit/chainlink/v2/core/services/ocr3/securemint/config" + "github.com/smartcontractkit/chainlink/v2/core/services/ocrcommon" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline" + "github.com/smartcontractkit/por_mock_ocr3plugin/por" +) + +// externalAdapter implements por.ExternalAdapter +var _ por.ExternalAdapter = &externalAdapter{} + +type externalAdapter struct { + config *sm_config.SecureMintConfig + runner pipeline.Runner + job job.Job + spec pipeline.Spec + saver ocrcommon.Saver + lggr logger.Logger +} + +func NewExternalAdapter(config *sm_config.SecureMintConfig, runner pipeline.Runner, job job.Job, spec pipeline.Spec, saver ocrcommon.Saver, lggr logger.Logger) *externalAdapter { + return &externalAdapter{config: config, runner: runner, job: job, spec: spec, saver: saver, lggr: lggr} +} + +// GetPayload retrieves the payload for the given blocks by executing a pipeline run. +func (ea *externalAdapter) GetPayload(ctx context.Context, blocks por.Blocks) (por.ExternalAdapterPayload, error) { + ea.lggr.Debugf("GetPayload called with blocks parameter: %v", blocks) + + // Create the request for the external adapter + req := Request{ + Token: ea.config.Token, + Reserves: ea.config.Reserves, + } + + for chainSelector, blockNumber := range blocks { + req.SupplyChains = append(req.SupplyChains, fmt.Sprintf("%d", chainSelector)) + req.SupplyChainBlocks = append(req.SupplyChainBlocks, uint64(blockNumber)) + } + + // Serialize EA request to JSON + reqJSON, err := json.Marshal(req) + if err != nil { + return por.ExternalAdapterPayload{}, fmt.Errorf("failed to marshal ea request: %w (request: %#v)", err, req) + } + + ea.lggr.Debugf("GetPayload serialized ea request to JSON: %v", string(reqJSON)) + + // Execute the request + vars := map[string]any{ + "jb": map[string]any{ + "databaseID": ea.job.ID, + "externalJobID": ea.job.ExternalJobID, + "name": ea.job.Name.ValueOrZero(), + }, + "action": "get_payload", + "ea_request": req, + } + + run, trrs, err := ea.runner.ExecuteRun(ctx, ea.spec, pipeline.NewVarsFrom(vars)) + if err != nil { + return por.ExternalAdapterPayload{}, fmt.Errorf("failed to execute GetPayload: %w", err) + } + + ea.saver.Save(run) + + // Parse and return results + for _, trr := range trrs { + if !trr.IsTerminal() { + continue + } + + resultMap, ok := trr.Result.Value.(map[string]any) + if !ok { + return por.ExternalAdapterPayload{}, fmt.Errorf("unexpected result type for GetPayload: %T", trr.Result.Value) + } + + payload, err := ea.convertMapToPayload(resultMap) + if err != nil { + return por.ExternalAdapterPayload{}, fmt.Errorf("failed to convert EA response map to payload: %w, map: %#v", err, resultMap) + } + + ea.lggr.Debugw("GetPayload result", "payload", payload) + return payload, nil + } + + return por.ExternalAdapterPayload{}, errors.New("no terminal result for GetPayload") +} + +// convertMapToPayload converts a map[string]any response to por.ExternalAdapterPayload +func (ea *externalAdapter) convertMapToPayload(resultMap map[string]any) (por.ExternalAdapterPayload, error) { + // Marshal and unmarshal to convert to Response struct + b, err := json.Marshal(resultMap) + if err != nil { + return por.ExternalAdapterPayload{}, fmt.Errorf("failed to marshal EA payload map: %w", err) + } + + var eaResponse Response + if err := json.Unmarshal(b, &eaResponse); err != nil { + return por.ExternalAdapterPayload{}, fmt.Errorf("failed to unmarshal EA response: %w", err) + } + + // Create the payload + payload := por.ExternalAdapterPayload{ + Mintables: make(por.Mintables), + LatestBlocks: make(por.Blocks), + } + + // Convert mintables + for chainSelector, mintable := range eaResponse.Mintables { + chainSelectorUint64, err := strconv.ParseUint(chainSelector, 10, 64) + if err != nil { + return por.ExternalAdapterPayload{}, fmt.Errorf("failed to parse chain selector: %s", chainSelector) + } + + mintableAmount, ok := new(big.Int).SetString(mintable.Mintable, 10) + if !ok { + return por.ExternalAdapterPayload{}, fmt.Errorf("failed to parse mintable amount: %s", mintable.Mintable) + } + + payload.Mintables[por.ChainSelector(chainSelectorUint64)] = por.BlockMintablePair{ + Block: por.BlockNumber(mintable.Block), + Mintable: mintableAmount, + } + } + + // Convert reserve info + reserveAmount, ok := new(big.Int).SetString(eaResponse.ReserveInfo.ReserveAmount, 10) + if !ok { + return por.ExternalAdapterPayload{}, fmt.Errorf("failed to parse reserve amount: %s", eaResponse.ReserveInfo.ReserveAmount) + } + payload.ReserveInfo = por.ReserveInfo{ + ReserveAmount: reserveAmount, + Timestamp: time.UnixMilli(eaResponse.ReserveInfo.Timestamp), + } + + // Convert latest blocks + for chainSelector, block := range eaResponse.LatestBlocks { + chainSelectorUint64, err := strconv.ParseUint(chainSelector, 10, 64) + if err != nil { + return por.ExternalAdapterPayload{}, fmt.Errorf("failed to parse chain selector: %s", chainSelector) + } + payload.LatestBlocks[por.ChainSelector(chainSelectorUint64)] = por.BlockNumber(block) + } + + return payload, nil +} diff --git a/core/services/ocr3/securemint/ea/ea_test.go b/core/services/ocr3/securemint/ea/ea_test.go new file mode 100644 index 00000000000..68dbe93a6d3 --- /dev/null +++ b/core/services/ocr3/securemint/ea/ea_test.go @@ -0,0 +1,117 @@ +package ea + +import ( + "context" + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/job" + sm_config "github.com/smartcontractkit/chainlink/v2/core/services/ocr3/securemint/config" + "github.com/smartcontractkit/chainlink/v2/core/services/ocrcommon" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/mocks" + "github.com/smartcontractkit/por_mock_ocr3plugin/por" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func Test_GetPayload(t *testing.T) { + + // Setup test context, logger, and other dependencies + ctx := testutils.Context(t) + lggr := logger.NullLogger + runner := mocks.NewRunner(t) + saver := ocrcommon.NewResultRunSaver( + runner, + lggr, + 1000, + 100, + ) + + config := &sm_config.SecureMintConfig{ + Token: "eth", + Reserves: "platform", + } + job := job.Job{} + spec := pipeline.Spec{} + executedRun := &pipeline.Run{} + + ea := NewExternalAdapter(config, runner, job, spec, saver, lggr) + + results := pipeline.TaskRunResults{ + { + Task: &pipeline.AnyTask{}, + Result: pipeline.Result{ + Value: map[string]any{ // outer `data` field is already stripped off in the parse step of the pipeline + "mintables": map[string]any{ + "1234567890": map[string]any{ + "mintable": "10", + "block": 8, + }, + }, + "reserveInfo": map[string]any{ + "reserveAmount": "10332550000000000000000", + "timestamp": 1749483841486, + }, + "latestBlocks": map[string]any{ + "1234567890": 23, + }, + }, + Error: nil, + }, + }, + } + + // Capture the 'ea_request' parameter from the pipeline run + var eaRequest any + runner.EXPECT().ExecuteRun(mock.Anything, mock.Anything, mock.Anything).Return(executedRun, results, nil).Run(func(_ context.Context, _ pipeline.Spec, vars pipeline.Vars) { + var err error + eaRequest, err = vars.Get("ea_request") + require.NoError(t, err) + }) + + payload, err := ea.GetPayload(ctx, por.Blocks{1234567890: 1234567890}) + require.NoError(t, err, "GetPayload should not return an error") + + // Validate the 'ea_request' parameter serialized to json + eaRequestJSON, err := json.Marshal(eaRequest) + require.NoError(t, err, "Failed to marshal ea_request to JSON") + assert.JSONEq(t, + `{ + "reserves": "platform", + "supplyChains": [ + "1234567890" + ], + "supplyChainBlocks": [ + 1234567890 + ], + "token": "eth" + }`, + string(eaRequestJSON), + ) + + // Validate the resulting payload + amount, ok := big.NewInt(10).SetString("10332550000000000000000", 10) + require.True(t, ok, "Failed to parse reserve amount from string") + expectedPayload := por.ExternalAdapterPayload{ + Mintables: por.Mintables{ + 1234567890: { + Block: 8, + Mintable: big.NewInt(10), + }, + }, + ReserveInfo: por.ReserveInfo{ + ReserveAmount: amount, + Timestamp: time.UnixMilli(1749483841486), + }, + LatestBlocks: por.Blocks{ + 1234567890: 23, + }, + } + assert.Equal(t, expectedPayload, payload) +} diff --git a/core/services/ocr3/securemint/ea/types.go b/core/services/ocr3/securemint/ea/types.go new file mode 100644 index 00000000000..aaa93e8a76d --- /dev/null +++ b/core/services/ocr3/securemint/ea/types.go @@ -0,0 +1,57 @@ +package ea + +// Request represents the request structure sent to the secure mint external adapter. +// Example (sent in the 'data' field): +// +// { +// "data": { +// "token": "eth", +// "reserves": "platform", +// "supplyChains": [ +// "5009297550715157269" +// ], +// "supplyChainBlocks": [ +// 0 +// ] +// } +// } +type Request struct { + Token string `json:"token"` + Reserves string `json:"reserves"` + SupplyChains []string `json:"supplyChains,omitempty"` + SupplyChainBlocks []uint64 `json:"supplyChainBlocks,omitempty"` +} + +// Response represents the response structure from the secure mint external adapter. +// Example: +// +// { +// "mintables": { +// "5009297550715157269": { +// "mintable": "5", +// "block": 22667990 +// } +// }, +// "reserveInfo": { +// "reserveAmount": "10332550000000000000000", +// "timestamp": 1749483841486 +// }, +// "latestBlocks": { +// "5009297550715157269": 22667990 +// } +// } +type Response struct { + Mintables map[string]MintableInfo `json:"mintables"` + ReserveInfo ReserveInfo `json:"reserveInfo"` + LatestBlocks map[string]uint64 `json:"latestBlocks"` +} + +type MintableInfo struct { + Mintable string `json:"mintable"` + Block uint64 `json:"block"` +} + +type ReserveInfo struct { + ReserveAmount string `json:"reserveAmount"` + Timestamp int64 `json:"timestamp"` +} diff --git a/core/services/ocr3/securemint/integrationtest/helpers_test.go b/core/services/ocr3/securemint/integrationtest/helpers_test.go new file mode 100644 index 00000000000..0a16acd4fd8 --- /dev/null +++ b/core/services/ocr3/securemint/integrationtest/helpers_test.go @@ -0,0 +1,352 @@ +package integrationtest + +import ( + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + commonconfig "github.com/smartcontractkit/chainlink-common/pkg/config" + evmtypes "github.com/smartcontractkit/chainlink-evm/pkg/types" + "github.com/smartcontractkit/chainlink/v2/core/bridges" + "github.com/smartcontractkit/chainlink/v2/core/config/toml" + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils/keystest" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" + "github.com/smartcontractkit/chainlink/v2/core/services/job" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/chaintype" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/csakey" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/ocr2key" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/p2pkey" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/validate" + sm_ea "github.com/smartcontractkit/chainlink/v2/core/services/ocr3/securemint/ea" + "github.com/smartcontractkit/chainlink/v2/core/services/ocrbootstrap" + "github.com/smartcontractkit/chainlink/v2/core/store/models" + "github.com/smartcontractkit/chainlink/v2/core/utils/testutils/heavyweight" + "github.com/smartcontractkit/wsrpc/credentials" +) + +type node struct { + app chainlink.Application + clientPubKey credentials.StaticSizedPublicKey + keyBundle ocr2key.KeyBundle + observedLogs *observer.ObservedLogs +} + +func (node *node) addBootstrapJob(t *testing.T, spec string) *job.Job { + job, err := ocrbootstrap.ValidatedBootstrapSpecToml(spec) + require.NoError(t, err) + err = node.app.AddJobV2(testutils.Context(t), &job) + require.NoError(t, err) + return &job +} + +func setupNode( + t *testing.T, + port int, + dbName string, + backend evmtypes.Backend, + csaKey csakey.KeyV2, + f func(*chainlink.Config), +) (app chainlink.Application, peerID string, clientPubKey credentials.StaticSizedPublicKey, ocr2kb ocr2key.KeyBundle, observedLogs *observer.ObservedLogs) { + k := big.NewInt(int64(port)) // keys unique to port + p2pKey := p2pkey.MustNewV2XXXTestingOnly(k) + rdr := keystest.NewRandReaderFromSeed(int64(port)) + ocr2kb = ocr2key.MustNewInsecure(rdr, chaintype.EVM) + + p2paddresses := []string{fmt.Sprintf("127.0.0.1:%d", port)} + + config, _ := heavyweight.FullTestDBV2(t, func(c *chainlink.Config, _ *chainlink.Secrets) { + // set finality depth to 1 so we don't have to wait for multiple blocks + c.EVM[0].FinalityDepth = ptr[uint32](1) + + // [JobPipeline] + c.JobPipeline.MaxSuccessfulRuns = ptr(uint64(1000)) + c.JobPipeline.VerboseLogging = ptr(true) + + // [Feature] + c.Feature.UICSAKeys = ptr(true) + c.Feature.LogPoller = ptr(true) + c.Feature.FeedsManager = ptr(false) + + // [OCR] + c.OCR.Enabled = ptr(false) + + // [OCR2] + c.OCR2.Enabled = ptr(true) + c.OCR2.ContractPollInterval = commonconfig.MustNewDuration(500 * time.Millisecond) + + // [P2P] + c.P2P.PeerID = ptr(p2pKey.PeerID()) + c.P2P.TraceLogging = ptr(true) + + // [P2P.V2] + c.P2P.V2.Enabled = ptr(true) + c.P2P.V2.AnnounceAddresses = &p2paddresses + c.P2P.V2.ListenAddresses = &p2paddresses + c.P2P.V2.DeltaDial = commonconfig.MustNewDuration(500 * time.Millisecond) + c.P2P.V2.DeltaReconcile = commonconfig.MustNewDuration(5 * time.Second) + + // [Log] + c.Log.Level = ptr(toml.LogLevel(zapcore.DebugLevel)) // generally speaking we want debug level for logs unless overridden + + // [EVM.Transactions] + for _, evmCfg := range c.EVM { + evmCfg.Transactions.Enabled = ptr(false) // don't need txmgr + } + + // Optional overrides + if f != nil { + f(c) + } + }) + + lggr, observedLogs := logger.TestLoggerObserved(t, config.Log().Level()) + + app = cltest.NewApplicationWithConfigV2AndKeyOnSimulatedBlockchain(t, config, backend, p2pKey, ocr2kb, csaKey, lggr.Named(dbName)) + err := app.Start(testutils.Context(t)) + require.NoError(t, err) + + t.Cleanup(func() { + assert.NoError(t, app.Stop()) + }) + + return app, p2pKey.PeerID().Raw(), csaKey.StaticSizedPublicKey(), ocr2kb, observedLogs +} + +func ptr[T any](t T) *T { return &t } + +func createSecureMintBootstrapJob(t *testing.T, bootstrapNode node, configuratorAddress common.Address, chainID, fromBlock string) *job.Job { + return bootstrapNode.addBootstrapJob(t, fmt.Sprintf(` + type = "bootstrap" + relay = "evm" + schemaVersion = 1 + name = "bootstrap-secure-mint" + contractID = "%s" + contractConfigTrackerPollInterval = "1s" + contractConfigConfirmations = 1 + + [relayConfig] + chainID = %s + fromBlock = %s + providerType = "securemint"`, + configuratorAddress.Hex(), + chainID, + fromBlock), + ) +} + +func addSecureMintOCRJobs( + t *testing.T, + nodes []node, + configuratorAddress common.Address, +) (jobIDs map[int]int32) { + // node idx => job id + jobIDs = make(map[int]int32) + + // Create one bridge and one SM Feed OCR job on each node + for i, node := range nodes { + name := "securemint-ea" + + bmBridge := createSecureMintBridge(t, name, i, node.app.BridgeORM()) + t.Logf("Created secure mint bridge %s on node %d", bmBridge, i) + + addresses, err := node.app.GetKeyStore().Eth().EnabledAddressesForChain(testutils.Context(t), testutils.SimulatedChainID) + require.NoError(t, err) + t.Logf("Using transmitter address %s for node %d", addresses[0].String(), i) + + jobID := addSecureMintJob( + t, + node, + configuratorAddress, + bmBridge, + ) + jobIDs[i] = jobID + t.Logf("Added secure mint job with id %d on node %d", jobID, i) + } + return jobIDs +} + +func addSecureMintJob( + t *testing.T, + node node, + configuratorAddress common.Address, + bridgeName string, +) (id int32) { + + addresses, err := node.app.GetKeyStore().Eth().EnabledAddressesForChain(testutils.Context(t), testutils.SimulatedChainID) + require.NoError(t, err) + c := node.app.GetConfig() + + spec := getSecureMintJobSpec(configuratorAddress.Hex(), node.keyBundle.ID(), addresses[0].String(), bridgeName) + + job, err := validate.ValidatedOracleSpecToml(testutils.Context(t), c.OCR2(), c.Insecure(), spec, nil) + require.NoError(t, err) + + err = node.app.AddJobV2(testutils.Context(t), &job) + require.NoError(t, err) + t.Logf("Added secure mint job spec %s", job.ExternalJobID) + + return job.ID +} + +func getSecureMintJobSpec(ocrContractAddress, keyBundleID, transmitterAddress, bridgeName string) string { + + return fmt.Sprintf(` + type = "offchainreporting2" + relay = "evm" + schemaVersion = 1 + pluginType = "securemint" + name = "secure mint spec" + contractID = "%s" + ocrKeyBundleID = "%s" + transmitterID = "%s" + contractConfigConfirmations = 1 + contractConfigTrackerPollInterval = "1s" + observationSource = """ + // data source 1 + ds1 [type=bridge name="%s" requestData=<{ "data": $(ea_request) }>]; + ds1_parse [type=jsonparse path="data"]; + + ds1 -> ds1_parse -> answer1; + + answer1 [type=any index=0]; + """ + + allowNoBootstrappers = false + + [relayConfig] + chainID = 1337 + fromBlock = 1 + + [pluginConfig] + maxChains = 5 + token = "btc" + reserves = "custom" + `, + ocrContractAddress, // contract address + keyBundleID, // ocr key bundle id + transmitterAddress, // transmitter id + bridgeName) // bridge name +} + +// Based on https://chainlink-core.slack.com/archives/C090PQH50M6/p1749483857095389?thread_ts=1749482941.061609&cid=C090PQH50M6 +func createSecureMintBridge(t *testing.T, name string, i int, borm bridges.ORM) (bridgeName string) { + ctx := testutils.Context(t) + + initialResponse := sm_ea.Response{ + Mintables: map[string]sm_ea.MintableInfo{}, + LatestBlocks: map[string]uint64{ + "8953668971247136127": 5, // "bitcoin-testnet-rootstock" + "729797994450396300": 5, // "telos-evm-testnet" + }, + ReserveInfo: sm_ea.ReserveInfo{ + ReserveAmount: "1000", + Timestamp: time.Now().UnixMilli(), + }, + } + jsonInitialResp, err := json.Marshal(initialResponse) + require.NoError(t, err) + + fullResponse := sm_ea.Response{ + Mintables: map[string]sm_ea.MintableInfo{ + "8953668971247136127": { // "bitcoin-testnet-rootstock" + Block: uint64(5), + Mintable: "10", + }, + "729797994450396300": { // "telos-evm-testnet" + Block: uint64(5), + Mintable: "25", + }, + }, + LatestBlocks: map[string]uint64{ + "8953668971247136127": 8, // "bitcoin-testnet-rootstock" + "729797994450396300": 7, // "telos-evm-testnet" + }, + ReserveInfo: sm_ea.ReserveInfo{ + ReserveAmount: "500", + Timestamp: time.Now().UnixMilli(), + }, + } + jsonFullResponse, err := json.Marshal(fullResponse) + require.NoError(t, err) + + //nolint:testifylint // allow require.NoError in the http server + bridge := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + body, err := io.ReadAll(req.Body) + defer req.Body.Close() + require.NoError(t, err) + t.Logf("Received request for secure mint bridge %s on node %d: path %s, request body %s", name, i, req.URL.String(), string(body)) + + // Parse the request body into a map to extract the 'data' field + var requestMap map[string]any + err = json.Unmarshal(body, &requestMap) + require.NoError(t, err, "Failed to parse request body as map for bridge %s on node %d", name, i) + + dataField, exists := requestMap["data"] + require.True(t, exists, "Request body should contain 'data' field for bridge %s on node %d", name, i) + + // Marshal the data field back to JSON and parse as ea.Request + dataBytes, err := json.Marshal(dataField) + require.NoError(t, err, "Failed to marshal data field for bridge %s on node %d", name, i) + var eaRequest sm_ea.Request + err = json.Unmarshal(dataBytes, &eaRequest) + require.NoError(t, err, "Failed to parse request body as ea.Request for bridge %s on node %d", name, i) + + // Validate the parsed ea.Request + assert.Equal(t, "btc", eaRequest.Token, "Token should be 'eth'") + assert.Equal(t, "custom", eaRequest.Reserves, "Reserves should be 'platform'") + + // Return initial EA response if empty request (first round) + if len(eaRequest.SupplyChains) == 0 && len(eaRequest.SupplyChainBlocks) == 0 { + t.Logf("Received empty supply chains for secure mint bridge %s on node %d, returning initial response", name, i) + res.WriteHeader(http.StatusOK) + _, err = res.Write(fmt.Appendf(nil, `{"data": %s}`, string(jsonInitialResp))) + require.NoError(t, err) + return + } + + // Validate non-empty request + assert.Contains(t, eaRequest.SupplyChains, "8953668971247136127", "Supply chains should contain bitcoin-testnet-rootstock") + assert.Contains(t, eaRequest.SupplyChains, "729797994450396300", "Supply chains should contain telos-evm-testnet") + assert.Len(t, eaRequest.SupplyChains, 2, "Should have exactly 2 supply chains") + + assert.Len(t, eaRequest.SupplyChainBlocks, 2, "Should have exactly 2 supply chain blocks") + assert.GreaterOrEqual(t, eaRequest.SupplyChainBlocks[0], uint64(5), "Supply chain block should be at least 5 (based on initial EA response)") + assert.GreaterOrEqual(t, eaRequest.SupplyChainBlocks[1], uint64(5), "Supply chain block should be at least 5 (based on initial EA response)") + + // Return full EA response with mintable amounts + res.WriteHeader(http.StatusOK) + resp := fmt.Sprintf(`{"data": %s}`, string(jsonFullResponse)) + t.Logf("Responding from secure mint bridge %s on node %d with: %s", name, i, resp) + _, err = res.Write([]byte(resp)) + require.NoError(t, err) + })) + t.Cleanup(func() { + t.Logf("Closing secure mint bridge %s on node %d with url %s", name, i, bridge.URL) + bridge.Close() + }) + t.Logf("Created secure mint bridge %s on node %d with URL %s", name, i, bridge.URL) + + u, _ := url.Parse(bridge.URL) + bridgeName = fmt.Sprintf("bridge-%s-%d", name, i) + require.NoError(t, borm.CreateBridgeType(ctx, &bridges.BridgeType{ + Name: bridges.BridgeName(bridgeName), + URL: models.WebURL(*u), + })) + + return bridgeName +} diff --git a/core/services/ocr3/securemint/integrationtest/integration_test.go b/core/services/ocr3/securemint/integrationtest/integration_test.go new file mode 100644 index 00000000000..bab3d518b2b --- /dev/null +++ b/core/services/ocr3/securemint/integrationtest/integration_test.go @@ -0,0 +1,383 @@ +package integrationtest + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "strings" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/ethconfig" + "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-evm/gethwrappers/data-feeds/generated/data_feeds_cache" + "github.com/smartcontractkit/chainlink-evm/pkg/assets" + evmtestutils "github.com/smartcontractkit/chainlink-evm/pkg/testutils" + evmtypes "github.com/smartcontractkit/chainlink-evm/pkg/types" + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/csakey" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/testhelpers" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr3/securemint" + "github.com/smartcontractkit/chainlink/v2/core/services/relay/evm" + "github.com/smartcontractkit/freeport" + "github.com/smartcontractkit/libocr/commontypes" + "github.com/smartcontractkit/libocr/gethwrappers2/ocr2aggregator" + "github.com/smartcontractkit/libocr/offchainreporting2plus/confighelper" + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3confighelper" + ocr2types "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + "github.com/smartcontractkit/por_mock_ocr3plugin/por" +) + +var ( + fNodes = uint8(1) + nNodes = 4 // number of nodes (not including bootstrap) +) + +// TestIntegration_SecureMint_happy_path tests runs a small DON which runs the secure mint plugin +// and verifies that it can successfully create reports. +// +// Inspired by: +// * core/internal/features/ocr2/features_ocr2_test.go +// * core/services/ocr2/plugins/ocr2keeper/integration_21_test.go +func TestIntegration_SecureMint_happy_path(t *testing.T) { + const salt = 100 + + clientCSAKeys := make([]csakey.KeyV2, nNodes) + clientPubKeys := make([]ed25519.PublicKey, nNodes) + for i := range nNodes { + k := big.NewInt(int64(salt + i)) + key := csakey.MustNewV2XXXTestingOnly(k) + clientCSAKeys[i] = key + clientPubKeys[i] = key.PublicKey + } + + steve, backend := setupBlockchain(t) + fromBlock, err := backend.Client().BlockNumber(testutils.Context(t)) + require.NoError(t, err) + t.Logf("Starting from block: %d", fromBlock) + + // Setup bootstrap + bootstrapCSAKey := csakey.MustNewV2XXXTestingOnly(big.NewInt(salt - 1)) + bootstrapNodePort := freeport.GetOne(t) + appBootstrap, bootstrapPeerID, _, bootstrapKb, _ := setupNode(t, bootstrapNodePort, "bootstrap_securemint", backend, bootstrapCSAKey, nil) + bootstrapNode := node{app: appBootstrap, keyBundle: bootstrapKb} + + p2pV2Bootstrappers := []commontypes.BootstrapperLocator{ + // Supply the bootstrap IP and port as a V2 peer address + {PeerID: bootstrapPeerID, Addrs: []string{fmt.Sprintf("127.0.0.1:%d", bootstrapNodePort)}}, + } + + // Setup oracle nodes + oracles, nodes := setupNodes(t, nNodes, backend, clientCSAKeys, func(c *chainlink.Config) { + // inform node about bootstrap node + c.P2P.V2.DefaultBootstrappers = &p2pV2Bootstrappers + }) + + allowedSenders := make([]common.Address, len(nodes)) + for i, node := range nodes { + keys, err := node.app.GetKeyStore().Eth().EnabledKeysForChain(testutils.Context(t), testutils.SimulatedChainID) + require.NoError(t, err) + allowedSenders[i] = keys[0].Address // assuming the first key is the transmitter + } + + aggregatorAddress := setSecureMintOnchainConfigUsingAggregator(t, steve, backend, nodes, oracles) + + t.Logf("Creating bootstrap job with aggregator address: %s", aggregatorAddress.Hex()) + bootstrapJob := createSecureMintBootstrapJob(t, bootstrapNode, aggregatorAddress, testutils.SimulatedChainID.String(), fmt.Sprintf("%d", fromBlock)) + t.Logf("Created bootstrap job: %s with id %d", bootstrapJob.Name.ValueOrZero(), bootstrapJob.ID) + + jobIDs := addSecureMintOCRJobs(t, nodes, aggregatorAddress) + + t.Logf("jobIDs: %v", jobIDs) + validateJobsRunningSuccessfully(t, nodes, jobIDs) + +} + +func setupBlockchain(t *testing.T) ( + *bind.TransactOpts, + evmtypes.Backend, +) { + steve := evmtestutils.MustNewSimTransactor(t) // config contract deployer and owner + genesisData := gethtypes.GenesisAlloc{steve.From: {Balance: assets.Ether(1000).ToInt()}} + backend := cltest.NewSimulatedBackend(t, genesisData, ethconfig.Defaults.Miner.GasCeil) + backend.Commit() + backend.Commit() // ensure starting block number at least 1 + + return steve, backend +} + +func setupNodes(t *testing.T, nNodes int, backend evmtypes.Backend, clientCSAKeys []csakey.KeyV2, f func(*chainlink.Config)) (oracles []confighelper.OracleIdentityExtra, nodes []node) { + ports := freeport.GetN(t, nNodes) + for i := range nNodes { + app, peerID, transmitter, kb, observedLogs := setupNode(t, ports[i], fmt.Sprintf("oracle_securemint_%d", i), backend, clientCSAKeys[i], f) + + nodes = append(nodes, node{ + app: app, + clientPubKey: transmitter, + keyBundle: kb, + observedLogs: observedLogs, + }) + offchainPublicKey, err := hex.DecodeString(strings.TrimPrefix(kb.OnChainPublicKey(), "0x")) + require.NoError(t, err) + oracles = append(oracles, confighelper.OracleIdentityExtra{ + OracleIdentity: confighelper.OracleIdentity{ + OnchainPublicKey: offchainPublicKey, + TransmitAccount: ocr2types.Account(hex.EncodeToString(transmitter[:])), + OffchainPublicKey: kb.OffchainPublicKey(), + PeerID: peerID, + }, + ConfigEncryptionPublicKey: kb.ConfigEncryptionPublicKey(), + }) + } + return +} + +func validateJobsRunningSuccessfully(t *testing.T, nodes []node, jobIDs map[int]int32) { + + // 1. Assert no job spec errors + for i, node := range nodes { + jobs, _, err := node.app.JobORM().FindJobs(testutils.Context(t), 0, 1000) + require.NoErrorf(t, err, "assert error finding jobs for node %d", i) + t.Logf("%d jobs found for node %d", len(jobs), i) + for _, j := range jobs { + t.Logf("job %d on node %d oracle spec: %#v", j.ID, i, j.OCR2OracleSpec) + t.Logf("job %d on node %d pipeline spec: %#v", j.ID, i, j.PipelineSpec) + } + // No spec errors + for _, j := range jobs { + ignore := 0 + for _, jse := range j.JobSpecErrors { + // Non-fatal timing related error, ignore for testing. + if strings.Contains(jse.Description, "leader's phase conflicts tGrace timeout") { + ignore++ + } else { + t.Errorf("assert error: job spec error on node %d: %v", i, jse) + } + } + require.Lenf(t, j.JobSpecErrors, ignore, "assert error: job spec errors on node %d", i) + } + } + + t.Logf("No job spec errors identified for any node") + + // time.Sleep(30 * time.Second) // wait for jobs to run + + runs, err := nodes[0].app.PipelineORM().GetAllRuns(testutils.Context(t)) + require.NoError(t, err, "assert error getting all runs") + t.Logf("Found %d runs", len(runs)) + for _, run := range runs { + t.Logf("Run ID: %d, Job ID: %d, Status: %s", run.ID, run.JobID, run.Status()) + } + + // 2. Assert that all the Secure Mint jobs get a run with valid values eventually + var wg sync.WaitGroup + for i, node := range nodes { + wg.Add(1) + go func() { + defer wg.Done() + + pr := cltest.WaitForPipelineComplete(t, i, jobIDs[i], 1, 0, node.app.JobORM(), 30*time.Second, 1*time.Second) + outputs, err := pr[0].Outputs.MarshalJSON() + if !assert.NoError(t, err) { + t.Logf("assert error marshalling outputs for job %d: %v", jobIDs[i], err) + return + } + t.Logf("Pipeline itself is %+v", pr[0]) + t.Logf("Pipeline run outputs are %s", string(outputs)) + }() + } + t.Logf("waiting for pipeline runs to complete") + wg.Wait() + t.Logf("All pipeline runs completed successfully") + + // 3. Check that transmissions work + expectedNumTransmissions := int32(4) + gomega.NewWithT(t).Eventually(func() bool { + numTransmissions := securemint.StubTransmissionCounter.Load() + t.Logf("Number of (stub) report transmissions: %d", numTransmissions) + return numTransmissions >= expectedNumTransmissions + }, 30*time.Second, 1*time.Second).Should( + gomega.BeTrue(), + fmt.Sprintf("expected at least %d reports transmitted, but got less", expectedNumTransmissions), + ) +} + +func setSecureMintOnchainConfigUsingAggregator(t *testing.T, steve *bind.TransactOpts, backend evmtypes.Backend, nodes []node, oracles []confighelper.OracleIdentityExtra) common.Address { + + // 1. Deploy aggregator contract + + // these min and max answers are not used by the secure mint oracle but they're needed for validation in aggregator.setConfig() + minAnswer := big.NewInt(0) + maxAnswer := big.NewInt(999999) + aggregatorAddress, _, aggregatorContract, err := ocr2aggregator.DeployOCR2Aggregator( + steve, + backend.Client(), + common.Address{}, // LINK address + minAnswer, + maxAnswer, + common.Address{}, // billingAccessController + common.Address{}, // requesterAccessController + 9, // decimals + "secure mint test", // description + ) + if err != nil { + rPCError, err := rPCErrorFromError(err) + require.NoError(t, err) + t.Fatalf("Failed to deploy OCR2Aggregator contract: %s", rPCError) + } + // Ensure we have finality depth worth of blocks to start. + for range 20 { + backend.Commit() + } + t.Logf("Deployed OCR2Aggregator contract at: %s", aggregatorAddress.Hex()) + + // 2. Create config + onchainConfig, err := testhelpers.GenerateDefaultOCR2OnchainConfig(minAnswer, maxAnswer) + require.NoError(t, err) + + smPluginConfig := por.PorOffchainConfig{MaxChains: 5} + smPluginConfigBytes, err := smPluginConfig.Serialize() + require.NoError(t, err) + + signers, _, f, outOnchainConfig, offchainConfigVersion, offchainConfig, err := ocr3confighelper.ContractSetConfigArgsForTests( + 2*time.Second, // deltaProgress, + 20*time.Second, // deltaResend, + 400*time.Millisecond, // deltaInitial, + 500*time.Millisecond, // deltaRound, + 250*time.Millisecond, // deltaGrace, + 300*time.Millisecond, // deltaCertifiedCommitRequest, + 1*time.Minute, // deltaStage, + 100, // rMax, + []int{len(oracles)}, // s, + oracles, // oracles, + smPluginConfigBytes, // reportingPluginConfig, + nil, // maxDurationInitialization, + 250*time.Millisecond, // maxDurationQuery, + 1*time.Second, // maxDurationObservation, + 1*time.Second, // maxDurationShouldAcceptAttestedReport, + 1*time.Second, // maxDurationShouldTransmitAcceptedReport, + int(fNodes), // f, + onchainConfig, // onchainConfig (binary blob containing configuration passed through to the ReportingPlugin and also available to the contract. Unlike ReportingPluginConfig which is only available offchain.) + ) + require.NoError(t, err) + + // 3. Set config on the contract + signerAddresses, err := evm.OnchainPublicKeyToAddress(signers) + require.NoError(t, err) + + transmitterAddresses := make([]common.Address, len(nodes)) + for i := range nodes { + keys, err := nodes[i].app.GetKeyStore().Eth().EnabledKeysForChain(testutils.Context(t), testutils.SimulatedChainID) + require.NoError(t, err) + transmitterAddresses[i] = keys[0].Address // assuming the first key is the transmitter + } + + _, err = aggregatorContract.SetConfig(steve, signerAddresses, transmitterAddresses, f, outOnchainConfig, offchainConfigVersion, offchainConfig) + if err != nil { + errString, err := rPCErrorFromError(err) + require.NoError(t, err) + t.Fatalf("Failed to configure contract: %s", errString) + } + + // make sure config is finalized + for range 20 { + backend.Commit() + } + + aggregatorConfigDigest, err := aggregatorContract.LatestConfigDigestAndEpoch(&bind.CallOpts{}) + if err != nil { + rPCError, err := rPCErrorFromError(err) + require.NoError(t, err) + t.Fatalf("Failed to get latest config digest: %s", rPCError) + } + t.Logf("Aggregator config digest: 0x%x", aggregatorConfigDigest.ConfigDigest) + + return aggregatorAddress +} + +func rPCErrorFromError(txError error) (string, error) { + errBytes, err := json.Marshal(txError) + if err != nil { + return "", err + } + var callErr struct { + Code int + Data string `json:"data"` + Message string `json:"message"` + } + err = json.Unmarshal(errBytes, &callErr) + if err != nil { + return "", err + } + // If the error data is blank + if len(callErr.Data) == 0 { + return callErr.Data, nil + } + // Some nodes prepend "Reverted " and we also remove the 0x + trimmed := strings.TrimPrefix(callErr.Data, "Reverted ")[2:] + data, err := hex.DecodeString(trimmed) + if err != nil { + return "", err + } + revert, err := abi.UnpackRevert(data) + // If we can't decode the revert reason, return the raw data + if err != nil { + return callErr.Data, nil + } + return revert, nil +} + +func setupDataFeedsCacheContract(t *testing.T, steve *bind.TransactOpts, backend evmtypes.Backend, allowedSenders []common.Address, workflowOwner, workflowName string) ( + common.Address, *data_feeds_cache.DataFeedsCache) { + + addr, _, dataFeedsCache, err := data_feeds_cache.DeployDataFeedsCache(steve, backend.Client()) + require.NoError(t, err) + backend.Commit() + + var nameBytes [10]byte + copy(nameBytes[:], workflowName) + + ownerAddr := common.HexToAddress(workflowOwner) + + _, err = dataFeedsCache.SetFeedAdmin(steve, ownerAddr, true) + require.NoError(t, err) + + backend.Commit() + + metadatas := make([]data_feeds_cache.DataFeedsCacheWorkflowMetadata, len(allowedSenders)) + for i, sender := range allowedSenders { + metadatas[i] = + data_feeds_cache.DataFeedsCacheWorkflowMetadata{ + AllowedSender: sender, + AllowedWorkflowOwner: ownerAddr, + AllowedWorkflowName: nameBytes, + } + } + + feedIDBytes := [16]byte{} + copy(feedIDBytes[:], common.FromHex("0xA1B2C3D4E5F600010203040506070809")) + + _, err = dataFeedsCache.SetDecimalFeedConfigs(steve, [][16]byte{feedIDBytes}, []string{"securemint"}, metadatas) + if err != nil { + errString, err := rPCErrorFromError(err) + require.NoError(t, err) + + t.Fatalf("Failed to configure contract: %s", errString) + } + + backend.Commit() + + return addr, dataFeedsCache +} diff --git a/core/services/ocr3/securemint/keyringadapter/README.md b/core/services/ocr3/securemint/keyringadapter/README.md new file mode 100644 index 00000000000..9406fc70954 --- /dev/null +++ b/core/services/ocr3/securemint/keyringadapter/README.md @@ -0,0 +1,111 @@ +# PoR OCR3 OnchainKeyring Adapter + +This file contains an adapter implementation that enables the use of existing OCR2 OnchainKeyring implementations with the OCR3 PoR (Proof of Reserve) plugin. + +## Overview + +The `OnchainKeyringAdapter` wraps an existing `types.OnchainKeyring` (OCR2) and adapts it to implement `ocr3types.OnchainKeyring[ChainSelector]` (OCR3) specifically for the PoR system. + +## Key Features + +- **Interface Adaptation**: Converts between OCR2 and OCR3 keyring interfaces +- **Parameter Conversion**: Automatically converts OCR3 parameters (config digest, sequence number, report with info) to OCR2 ReportContext format +- **Backward Compatibility**: Allows reuse of existing OCR2 keyring implementations +- **Type Safety**: Strongly typed for PoR ChainSelector + +## Usage Example + +```go +package main + +import ( + "github.com/smartcontractkit/por_mock_ocr3plugin/por" + "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/ocr2key" +) + +func main() { + // Create an OCR2 keyring (example using EVM keyring) + ocr2Bundle, err := ocr2key.New(chaintype.EVM) + if err != nil { + panic(err) + } + + // Wrap the OCR2 keyring with the PoR adapter + porKeyring := por.NewOnchainKeyringAdapter(ocr2Bundle) + + // Now you can use porKeyring as an ocr3types.OnchainKeyring[por.ChainSelector] + // for the PoR OCR3 plugin + + // Example usage in OCR3 context: + configDigest := types.ConfigDigest([32]byte{1, 2, 3}) + seqNr := uint64(42) + reportWithInfo := ocr3types.ReportWithInfo[por.ChainSelector]{ + Report: []byte("example-report"), + Info: por.ChainSelector(1234), + } + + signature, err := porKeyring.Sign(configDigest, seqNr, reportWithInfo) + if err != nil { + panic(err) + } + + isValid := porKeyring.Verify( + porKeyring.PublicKey(), + configDigest, + seqNr, + reportWithInfo, + signature, + ) + + println("Signature valid:", isValid) +} +``` + +## Implementation Details + +### Interface Mapping + +The adapter maps OCR3 interface methods to OCR2 interface methods as follows: + +| OCR3 Method | OCR2 Method | Conversion | +|-------------|-------------|------------| +| `PublicKey()` | `PublicKey()` | Direct passthrough | +| `Sign(ConfigDigest, uint64, ReportWithInfo[ChainSelector])` | `Sign(ReportContext, Report)` | Converts parameters to ReportContext | +| `Verify(OnchainPublicKey, ConfigDigest, uint64, ReportWithInfo[ChainSelector], []byte)` | `Verify(OnchainPublicKey, ReportContext, Report, []byte)` | Converts parameters to ReportContext | +| `MaxSignatureLength()` | `MaxSignatureLength()` | Direct passthrough | + +### Parameter Conversion + +OCR3 parameters are converted to OCR2 ReportContext as follows: + +```go +reportContext := types.ReportContext{ + ReportTimestamp: types.ReportTimestamp{ + ConfigDigest: configDigest, // From OCR3 parameter + Epoch: uint32(seqNr), // OCR3 sequence number as epoch + Round: 0, // Fixed to 0 (OCR3 doesn't use rounds) + }, + ExtraHash: [32]byte{}, // Empty hash +} +``` + +## Benefits + +1. **Reusability**: Existing OCR2 keyrings can be used with OCR3 PoR without modification +2. **Simplicity**: Single adapter handles all necessary conversions +3. **Type Safety**: Generic implementation ensures compile-time type checking +4. **Testing**: Comprehensive test suite ensures correct parameter conversion + +## Related Files + +- `onchain_keyring_adapter.go` - Main adapter implementation +- `onchain_keyring_adapter_test.go` - Comprehensive test suite +- `types.go` - PoR-specific type definitions including ChainSelector +- `external_adapter_interface.go` - Original external adapter interface + +## See Also + +- Reference implementations in `/core/services/ocrcommon/adapters.go` +- OCR2 keyring implementations in `/core/services/keystore/keys/ocr2key/` +- OCR3 types in `libocr/offchainreporting2plus/ocr3types/` diff --git a/core/services/ocr3/securemint/keyringadapter/example_usage.go b/core/services/ocr3/securemint/keyringadapter/example_usage.go new file mode 100644 index 00000000000..528ca938e63 --- /dev/null +++ b/core/services/ocr3/securemint/keyringadapter/example_usage.go @@ -0,0 +1,92 @@ +package keyringadapter + +import ( + "fmt" + + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" + "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + "github.com/smartcontractkit/por_mock_ocr3plugin/por" +) + +// ExampleUsage demonstrates how to use the OnchainKeyringAdapter +// to wrap an existing OCR2 keyring for use with OCR3 PoR plugin. +func ExampleUsage() { + // This is a simplified example showing the adapter usage pattern. + // In real usage, you would obtain the OCR2 keyring from your keystore. + + // Step 1: Get an existing OCR2 OnchainKeyring + // This could be from your keystore, e.g.: + // ocr2Bundle, err := ocr2key.New(chaintype.EVM) + // if err != nil { ... } + // ocr2Keyring := ocr2Bundle + + // For this example, we'll use a mock keyring + mockOCR2Keyring := &mockExampleKeyring{ + publicKey: types.OnchainPublicKey("example-public-key"), + maxSigLen: 65, // typical for ECDSA signatures + } + + // Step 2: Wrap the OCR2 keyring with the PoR adapter + porKeyring := NewSecureMintOCR3OnchainKeyringAdapter(mockOCR2Keyring) + + // Step 3: Use the adapter as an OCR3 OnchainKeyring for PoR + configDigest := types.ConfigDigest([32]byte{1, 2, 3, 4, 5}) // example digest + seqNr := uint64(42) + chainSelector := por.ChainSelector(1234) // example chain selector + + reportWithInfo := ocr3types.ReportWithInfo[por.ChainSelector]{ + Report: []byte("example-por-report"), + Info: chainSelector, + } + + // Sign a report + signature, err := porKeyring.Sign(configDigest, seqNr, reportWithInfo) + if err != nil { + fmt.Printf("Error signing report: %v\n", err) + return + } + + // Verify the signature + isValid := porKeyring.Verify( + porKeyring.PublicKey(), + configDigest, + seqNr, + reportWithInfo, + signature, + ) + + fmt.Printf("Report signed successfully\n") + fmt.Printf("Signature length: %d bytes\n", len(signature)) + fmt.Printf("Max signature length: %d bytes\n", porKeyring.MaxSignatureLength()) + fmt.Printf("Signature valid: %t\n", isValid) + fmt.Printf("Public key: %x\n", porKeyring.PublicKey()) +} + +// mockExampleKeyring is a simple mock implementation for demonstration purposes +type mockExampleKeyring struct { + publicKey types.OnchainPublicKey + maxSigLen int +} + +func (m *mockExampleKeyring) PublicKey() types.OnchainPublicKey { + return m.publicKey +} + +func (m *mockExampleKeyring) Sign(ctx types.ReportContext, report types.Report) ([]byte, error) { + // In a real implementation, this would use cryptographic signing + return []byte("example-signature"), nil +} + +func (m *mockExampleKeyring) Verify( + pubKey types.OnchainPublicKey, + ctx types.ReportContext, + report types.Report, + signature []byte, +) bool { + // In a real implementation, this would verify the cryptographic signature + return true +} + +func (m *mockExampleKeyring) MaxSignatureLength() int { + return m.maxSigLen +} diff --git a/core/services/ocr3/securemint/keyringadapter/onchain_keyring_adapter.go b/core/services/ocr3/securemint/keyringadapter/onchain_keyring_adapter.go new file mode 100644 index 00000000000..071ee025c3d --- /dev/null +++ b/core/services/ocr3/securemint/keyringadapter/onchain_keyring_adapter.go @@ -0,0 +1,81 @@ +package keyringadapter + +import ( + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" + "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + "github.com/smartcontractkit/por_mock_ocr3plugin/por" +) + +// SecureMintOCR3OnchainKeyringAdapter adapts an OCR2 OnchainKeyring to implement ocr3types.OnchainKeyring[ChainSelector] +// This adapter enables the use of existing OCR2 keyrings with the OCR3 PoR plugin. +// Copied and adapted from core/services/ocrcommon/adapters.go +// Ideally we use ocrcommon.OCR3OnchainKeyringMultiChainAdapter instead? Problem is that that one is not typed, it assumes []byte as the Report type, while we use por.ChainSelector. +type SecureMintOCR3OnchainKeyringAdapter struct { + ocr2Keyring types.OnchainKeyring +} + +// Ensure OnchainKeyringAdapter implements the OCR3 OnchainKeyring interface for PoR ChainSelector +var _ ocr3types.OnchainKeyring[por.ChainSelector] = &SecureMintOCR3OnchainKeyringAdapter{} + +// NewSecureMintOCR3OnchainKeyringAdapter creates a new adapter that wraps an OCR2 OnchainKeyring +// to implement the OCR3 OnchainKeyring interface for PoR ChainSelector. +func NewSecureMintOCR3OnchainKeyringAdapter(keyring types.OnchainKeyring) *SecureMintOCR3OnchainKeyringAdapter { + return &SecureMintOCR3OnchainKeyringAdapter{ + ocr2Keyring: keyring, + } +} + +// PublicKey returns the public key of the underlying OCR2 keyring. +func (adapter *SecureMintOCR3OnchainKeyringAdapter) PublicKey() types.OnchainPublicKey { + return adapter.ocr2Keyring.PublicKey() +} + +// Sign creates a signature over the given report using the OCR2 keyring. +// It converts the OCR3 parameters (config digest, sequence number, and report with info) +// into the OCR2 ReportContext format expected by the underlying keyring. +func (adapter *SecureMintOCR3OnchainKeyringAdapter) Sign( + configDigest types.ConfigDigest, + seqNr uint64, + reportWithInfo ocr3types.ReportWithInfo[por.ChainSelector], +) (signature []byte, err error) { + // Convert OCR3 parameters to OCR2 ReportContext + // Note: seqNr is converted to uint32 for Epoch field, which may truncate for very large values + reportContext := types.ReportContext{ + ReportTimestamp: types.ReportTimestamp{ + ConfigDigest: configDigest, + Epoch: uint32(seqNr), //nolint:gosec // Intentional conversion, matches OCR protocol + Round: 0, // OCR3 doesn't use rounds in the same way as OCR2 + }, + ExtraHash: [32]byte{}, // Initialize with empty hash + } + + return adapter.ocr2Keyring.Sign(reportContext, reportWithInfo.Report) +} + +// Verify verifies a signature over the given report using the OCR2 keyring. +// It converts the OCR3 parameters into the OCR2 ReportContext format for verification. +func (adapter *SecureMintOCR3OnchainKeyringAdapter) Verify( + publicKey types.OnchainPublicKey, + configDigest types.ConfigDigest, + seqNr uint64, + reportWithInfo ocr3types.ReportWithInfo[por.ChainSelector], + signature []byte, +) bool { + // Convert OCR3 parameters to OCR2 ReportContext + // Note: seqNr is converted to uint32 for Epoch field, which may truncate for very large values + reportContext := types.ReportContext{ + ReportTimestamp: types.ReportTimestamp{ + ConfigDigest: configDigest, + Epoch: uint32(seqNr), //nolint:gosec // Intentional conversion, matches OCR protocol + Round: 0, // OCR3 doesn't use rounds in the same way as OCR2 + }, + ExtraHash: [32]byte{}, // Initialize with empty hash + } + + return adapter.ocr2Keyring.Verify(publicKey, reportContext, reportWithInfo.Report, signature) +} + +// MaxSignatureLength returns the maximum signature length from the underlying OCR2 keyring. +func (adapter *SecureMintOCR3OnchainKeyringAdapter) MaxSignatureLength() int { + return adapter.ocr2Keyring.MaxSignatureLength() +} diff --git a/core/services/ocr3/securemint/keyringadapter/onchain_keyring_adapter_test.go b/core/services/ocr3/securemint/keyringadapter/onchain_keyring_adapter_test.go new file mode 100644 index 00000000000..b7d65088382 --- /dev/null +++ b/core/services/ocr3/securemint/keyringadapter/onchain_keyring_adapter_test.go @@ -0,0 +1,178 @@ +package keyringadapter + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" + "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + "github.com/smartcontractkit/por_mock_ocr3plugin/por" +) + +// mockOCR2OnchainKeyring is a mock implementation of types.OnchainKeyring for testing +type mockOCR2OnchainKeyring struct { + publicKey types.OnchainPublicKey + maxSignatureLength int + signFunc func(types.ReportContext, types.Report) ([]byte, error) + verifyFunc func(types.OnchainPublicKey, types.ReportContext, types.Report, []byte) bool +} + +func (m *mockOCR2OnchainKeyring) PublicKey() types.OnchainPublicKey { + return m.publicKey +} + +func (m *mockOCR2OnchainKeyring) Sign(ctx types.ReportContext, report types.Report) ([]byte, error) { + if m.signFunc != nil { + return m.signFunc(ctx, report) + } + return []byte("mock-signature"), nil +} + +func (m *mockOCR2OnchainKeyring) Verify( + pubKey types.OnchainPublicKey, + ctx types.ReportContext, + report types.Report, + signature []byte, +) bool { + if m.verifyFunc != nil { + return m.verifyFunc(pubKey, ctx, report, signature) + } + return true +} + +func (m *mockOCR2OnchainKeyring) MaxSignatureLength() int { + return m.maxSignatureLength +} + +func TestPorOnchainKeyringAdapter(t *testing.T) { + // Setup test data + testPublicKey := types.OnchainPublicKey("test-public-key") + testConfigDigest := types.ConfigDigest([32]byte{1, 2, 3, 4, 5}) + testSeqNr := uint64(42) + testReport := types.Report([]byte("test-report")) + testChainSelector := por.ChainSelector(1234) + testSignature := []byte("test-signature") + testMaxSigLen := 65 + + reportWithInfo := ocr3types.ReportWithInfo[por.ChainSelector]{ + Report: testReport, + Info: testChainSelector, + } + + t.Run("adapter implements the correct interface", func(t *testing.T) { + mockKeyring := &mockOCR2OnchainKeyring{ + publicKey: testPublicKey, + maxSignatureLength: testMaxSigLen, + } + + adapter := NewSecureMintOCR3OnchainKeyringAdapter(mockKeyring) + + // Verify that the adapter implements the OCR3 OnchainKeyring interface + var _ ocr3types.OnchainKeyring[por.ChainSelector] = adapter + }) + + t.Run("PublicKey returns the underlying keyring's public key", func(t *testing.T) { + mockKeyring := &mockOCR2OnchainKeyring{ + publicKey: testPublicKey, + maxSignatureLength: testMaxSigLen, + } + + adapter := NewSecureMintOCR3OnchainKeyringAdapter(mockKeyring) + assert.Equal(t, testPublicKey, adapter.PublicKey()) + }) + + t.Run("MaxSignatureLength returns the underlying keyring's max signature length", func(t *testing.T) { + mockKeyring := &mockOCR2OnchainKeyring{ + publicKey: testPublicKey, + maxSignatureLength: testMaxSigLen, + } + + adapter := NewSecureMintOCR3OnchainKeyringAdapter(mockKeyring) + assert.Equal(t, testMaxSigLen, adapter.MaxSignatureLength()) + }) + + t.Run("Sign correctly converts OCR3 parameters to OCR2 format", func(t *testing.T) { + var capturedReportContext types.ReportContext + var capturedReport types.Report + + mockKeyring := &mockOCR2OnchainKeyring{ + publicKey: testPublicKey, + maxSignatureLength: testMaxSigLen, + signFunc: func(ctx types.ReportContext, report types.Report) ([]byte, error) { + capturedReportContext = ctx + capturedReport = report + return testSignature, nil + }, + } + + adapter := NewSecureMintOCR3OnchainKeyringAdapter(mockKeyring) + signature, err := adapter.Sign(testConfigDigest, testSeqNr, reportWithInfo) + + require.NoError(t, err) + assert.Equal(t, testSignature, signature) + + // Verify the conversion from OCR3 to OCR2 format + assert.Equal(t, testConfigDigest, capturedReportContext.ReportTimestamp.ConfigDigest) + assert.Equal(t, uint32(testSeqNr), capturedReportContext.ReportTimestamp.Epoch) + assert.Equal(t, uint8(0), capturedReportContext.ReportTimestamp.Round) + assert.Equal(t, [32]byte{}, capturedReportContext.ExtraHash) + assert.Equal(t, testReport, capturedReport) + }) + + t.Run("Verify correctly converts OCR3 parameters to OCR2 format", func(t *testing.T) { + var capturedPublicKey types.OnchainPublicKey + var capturedReportContext types.ReportContext + var capturedReport types.Report + var capturedSignature []byte + + mockKeyring := &mockOCR2OnchainKeyring{ + publicKey: testPublicKey, + maxSignatureLength: testMaxSigLen, + verifyFunc: func( + pubKey types.OnchainPublicKey, + ctx types.ReportContext, + report types.Report, + signature []byte, + ) bool { + capturedPublicKey = pubKey + capturedReportContext = ctx + capturedReport = report + capturedSignature = signature + return true + }, + } + + adapter := NewSecureMintOCR3OnchainKeyringAdapter(mockKeyring) + result := adapter.Verify(testPublicKey, testConfigDigest, testSeqNr, reportWithInfo, testSignature) + + assert.True(t, result) + + // Verify the conversion from OCR3 to OCR2 format + assert.Equal(t, testPublicKey, capturedPublicKey) + assert.Equal(t, testConfigDigest, capturedReportContext.ReportTimestamp.ConfigDigest) + assert.Equal(t, uint32(testSeqNr), capturedReportContext.ReportTimestamp.Epoch) + assert.Equal(t, uint8(0), capturedReportContext.ReportTimestamp.Round) + assert.Equal(t, [32]byte{}, capturedReportContext.ExtraHash) + assert.Equal(t, testReport, capturedReport) + assert.Equal(t, testSignature, capturedSignature) + }) + + t.Run("Sign and Verify work together", func(t *testing.T) { + mockKeyring := &mockOCR2OnchainKeyring{ + publicKey: testPublicKey, + maxSignatureLength: testMaxSigLen, + } + + adapter := NewSecureMintOCR3OnchainKeyringAdapter(mockKeyring) + + // Sign a report + signature, err := adapter.Sign(testConfigDigest, testSeqNr, reportWithInfo) + require.NoError(t, err) + + // Verify the signature + isValid := adapter.Verify(testPublicKey, testConfigDigest, testSeqNr, reportWithInfo, signature) + assert.True(t, isValid) + }) +} diff --git a/core/services/ocr3/securemint/services.go b/core/services/ocr3/securemint/services.go new file mode 100644 index 00000000000..1a0afbf83d5 --- /dev/null +++ b/core/services/ocr3/securemint/services.go @@ -0,0 +1,158 @@ +package securemint + +import ( + "context" + "errors" + "fmt" + + "github.com/smartcontractkit/chainlink-common/pkg/loop" + "github.com/smartcontractkit/chainlink-common/pkg/types" + "github.com/smartcontractkit/chainlink/v2/core/config/env" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services" + "github.com/smartcontractkit/chainlink/v2/core/services/job" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr3/promwrapper" + sm_plugin_config "github.com/smartcontractkit/chainlink/v2/core/services/ocr3/securemint/config" + sm_ea "github.com/smartcontractkit/chainlink/v2/core/services/ocr3/securemint/ea" + "github.com/smartcontractkit/chainlink/v2/core/services/ocrcommon" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline" + "github.com/smartcontractkit/chainlink/v2/plugins" + libocr "github.com/smartcontractkit/libocr/offchainreporting2plus" + ocr2plus_types "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + "github.com/smartcontractkit/por_mock_ocr3plugin/por" + sm_plugin "github.com/smartcontractkit/por_mock_ocr3plugin/por" +) + +var _ JobConfig = (*smJobConfig)(nil) + +type JobConfig interface { + JobPipelineMaxSuccessfulRuns() uint64 + JobPipelineResultWriteQueueDepth() uint64 + plugins.RegistrarConfig +} + +// concrete implementation of JobConfig +type smJobConfig struct { + jobPipelineMaxSuccessfulRuns uint64 + jobPipelineResultWriteQueueDepth uint64 + plugins.RegistrarConfig +} + +func NewJobConfig(jobPipelineMaxSuccessfulRuns uint64, jobPipelineResultWriteQueueDepth uint64, pluginProcessCfg plugins.RegistrarConfig) JobConfig { + return &smJobConfig{ + jobPipelineMaxSuccessfulRuns: jobPipelineMaxSuccessfulRuns, + jobPipelineResultWriteQueueDepth: jobPipelineResultWriteQueueDepth, + RegistrarConfig: pluginProcessCfg, + } +} + +func (m *smJobConfig) JobPipelineMaxSuccessfulRuns() uint64 { + return m.jobPipelineMaxSuccessfulRuns +} + +func (m *smJobConfig) JobPipelineResultWriteQueueDepth() uint64 { + return m.jobPipelineResultWriteQueueDepth +} + +// NewSecureMintServices creates all securemint plugin specific services. +func NewSecureMintServices(ctx context.Context, + jb job.Job, + isNewlyCreatedJob bool, + relayer loop.Relayer, + pipelineRunner pipeline.Runner, + lggr logger.Logger, + argsNoPlugin libocr.OCR3OracleArgs[por.ChainSelector], + cfg JobConfig, +) (srvs []job.ServiceCtx, err error) { + // Parse and validate the secure mint plugin configuration + secureMintPluginConfig, err := sm_plugin_config.Parse(jb.OCR2OracleSpec.PluginConfig.Bytes()) + if err != nil { + return nil, fmt.Errorf("failed to parse secure mint plugin config: %w", err) + } + + if err = secureMintPluginConfig.Validate(); err != nil { + return nil, fmt.Errorf("invalid secure mint plugin config: %#v, %w", secureMintPluginConfig, err) + } + + spec := jb.OCR2OracleSpec + + // Create result run saver for pipeline execution + runSaver := ocrcommon.NewResultRunSaver( + pipelineRunner, + lggr, + cfg.JobPipelineMaxSuccessfulRuns(), + cfg.JobPipelineResultWriteQueueDepth(), + ) + + // Create plugin provider + provider, err := relayer.NewPluginProvider(ctx, types.RelayArgs{ + ExternalJobID: jb.ExternalJobID, + JobID: jb.ID, + OracleSpecID: *jb.OCR2OracleSpecID, + ContractID: spec.ContractID, + New: isNewlyCreatedJob, + RelayConfig: spec.RelayConfig.Bytes(), + ProviderType: string(spec.PluginType), + }, types.PluginArgs{ + TransmitterID: spec.TransmitterID.String, + PluginConfig: spec.PluginConfig.Bytes(), + }) + if err != nil { + return nil, fmt.Errorf("failed to create plugin provider: %w", err) + } + srvs = append(srvs, provider) + + // Set up provider-specific oracle args + argsNoPlugin.ContractConfigTracker = provider.ContractConfigTracker() + argsNoPlugin.OffchainConfigDigester = provider.OffchainConfigDigester() + + // Using a stub contract transmitter for testing purposes until DF-21404 is done + argsNoPlugin.ContractTransmitter = newStubContractTransmitter(lggr, ocr2plus_types.Account(spec.TransmitterID.String)) + + abort := func() { + if cerr := services.MultiCloser(srvs).Close(); cerr != nil { + lggr.Errorw("Error closing services", "err", cerr) + } + } + + // Create the reporting plugin factory + if cmdName := env.SecureMintPlugin.Cmd.Get(); cmdName != "" { + abort() + return nil, errors.New("LOOPP for securemint plugin not implemented yet") + } + + // Create the original SecureMint plugin factory + smPluginFactory := &sm_plugin.PorReportingPluginFactory{ + Logger: argsNoPlugin.Logger, + ExternalAdapter: sm_ea.NewExternalAdapter(secureMintPluginConfig, pipelineRunner, jb, *jb.PipelineSpec, runSaver, lggr), + ContractReader: newStubContractReader(argsNoPlugin.ContractConfigTracker), // since we don't write to chain yet, we mock the contract reader which returns the most recent config digest from the config contract + ReportMarshaler: sm_plugin.NewMockReportMarshaler(), + } + + // Get relay ID for chain identification + rid, err := spec.RelayID() + if err != nil { + return nil, fmt.Errorf("failed to get relay ID: %w", err) + } + + // Wrap the factory with prometheus metrics monitoring + argsNoPlugin.ReportingPluginFactory = promwrapper.NewReportingPluginFactory( + smPluginFactory, + lggr, + rid.ChainID, + "secure-mint", + ) + + // Create the oracle + var oracle libocr.Oracle + oracle, err = libocr.NewOracle(argsNoPlugin) + if err != nil { + abort() + return nil, fmt.Errorf("failed to create oracle: %w", err) + } + + // Assemble all services + srvs = append(srvs, runSaver, job.NewServiceAdapter(oracle)) + + return srvs, nil +} diff --git a/core/services/ocr3/securemint/stub_contractreader.go b/core/services/ocr3/securemint/stub_contractreader.go new file mode 100644 index 00000000000..f358aea41b5 --- /dev/null +++ b/core/services/ocr3/securemint/stub_contractreader.go @@ -0,0 +1,38 @@ +package securemint + +import ( + "context" + "fmt" + "time" + + ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + "github.com/smartcontractkit/por_mock_ocr3plugin/por" + sm_plugin "github.com/smartcontractkit/por_mock_ocr3plugin/por" +) + +var _ sm_plugin.ContractReader = &stubContractReader{} + +// stubContractReader is a mock implementation of the ContractReader interface. +// It retrieves the latest config digest from the config contract and then uses that to return mocked report details. +type stubContractReader struct { + contractConfigTracker ocrtypes.ContractConfigTracker +} + +func newStubContractReader(contractConfigTracker ocrtypes.ContractConfigTracker) *stubContractReader { + return &stubContractReader{ + contractConfigTracker: contractConfigTracker, + } +} + +func (m *stubContractReader) GetLatestTransmittedReportDetails(ctx context.Context, _ por.ChainSelector) (sm_plugin.TransmittedReportDetails, error) { + _, configDigest, err := m.contractConfigTracker.LatestConfigDetails(ctx) + if err != nil { + return sm_plugin.TransmittedReportDetails{}, fmt.Errorf("failed to get config digest: %w", err) + } + + return sm_plugin.TransmittedReportDetails{ + ConfigDigest: [32]byte(configDigest), + SeqNr: 1, // Mock sequence number + LatestTimestamp: time.Now(), // Mock timestamp + }, nil +} diff --git a/core/services/ocr3/securemint/stub_transmitter.go b/core/services/ocr3/securemint/stub_transmitter.go new file mode 100644 index 00000000000..4b1fb718c30 --- /dev/null +++ b/core/services/ocr3/securemint/stub_transmitter.go @@ -0,0 +1,71 @@ +package securemint + +import ( + "context" + "fmt" + "sync/atomic" + + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" + "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + "github.com/smartcontractkit/por_mock_ocr3plugin/por" +) + +// Ensure StubContractTransmitter implements the ContractTransmitter interface +var _ ocr3types.ContractTransmitter[por.ChainSelector] = (*stubContractTransmitter)(nil) + +// stubContractTransmitter is a stub implementation of the ContractTransmitter interface +// that logs messages when its functions are invoked instead of performing actual operations. +type stubContractTransmitter struct { + logger logger.Logger + fromAccount types.Account +} + +// StubTransmissionCounter is a global counter to track the number of transmissions, used for testing purposes. +// Since this is a stub implementation, we can get away with it. +var StubTransmissionCounter atomic.Int32 + +// newStubContractTransmitter creates a new StubContractTransmitter instance +func newStubContractTransmitter(logger logger.Logger, fromAccount types.Account) *stubContractTransmitter { + return &stubContractTransmitter{ + logger: logger, + fromAccount: fromAccount, + } +} + +// Transmit logs the transmission details instead of actually transmitting +func (s *stubContractTransmitter) Transmit( + _ context.Context, + configDigest types.ConfigDigest, + seqNr uint64, + reportWithInfo ocr3types.ReportWithInfo[por.ChainSelector], + aos []types.AttributedOnchainSignature, +) error { + s.logger.Info("Transmit called ", map[string]any{ + "configDigest": fmt.Sprintf("%x", configDigest), + "sequenceNumber": seqNr, + "reportLength": len(reportWithInfo.Report), + "reportInfo": reportWithInfo.Info, + "signaturesCount": len(aos), + }) + + // Log report details if available + if len(reportWithInfo.Report) > 0 { + s.logger.Debug("Report data ", map[string]any{ + "reportHex": fmt.Sprintf("%x", reportWithInfo.Report), + }) + } + + s.logger.Info("Transmit completed successfully (stub implementation)", nil) + StubTransmissionCounter.Add(1) + return nil +} + +// FromAccount returns the configured account and logs the call +func (s *stubContractTransmitter) FromAccount(_ context.Context) (types.Account, error) { + s.logger.Debug("FromAccount called ", map[string]any{ + "account": string(s.fromAccount), + }) + + return s.fromAccount, nil +} diff --git a/core/services/relay/evm/evm.go b/core/services/relay/evm/evm.go index de38f2a0cdc..6fe93c4e867 100644 --- a/core/services/relay/evm/evm.go +++ b/core/services/relay/evm/evm.go @@ -702,7 +702,7 @@ func (r *Relayer) NewConfigProvider(ctx context.Context, args commontypes.RelayA } switch args.ProviderType { - case "median": + case "median", "securemint": configProvider, err = newStandardConfigProvider(ctx, lggr, r.chain, relayOpts) case "mercury": configProvider, err = newMercuryConfigProvider(ctx, lggr, r.chain, relayOpts) diff --git a/core/services/relay/relay.go b/core/services/relay/relay.go index 32f8b836295..cba326e0093 100644 --- a/core/services/relay/relay.go +++ b/core/services/relay/relay.go @@ -61,7 +61,7 @@ func (r *ServerAdapter) NewPluginProvider(ctx context.Context, rargs types.Relay return r.NewCCIPCommitProvider(ctx, rargs, pargs) case types.CCIPExecution: return r.NewCCIPExecProvider(ctx, rargs, pargs) - case types.DKG, types.OCR2VRF, types.GenericPlugin, types.VaultPlugin: + case types.DKG, types.OCR2VRF, types.GenericPlugin, types.VaultPlugin, types.SecureMint: return r.Relayer.NewPluginProvider(ctx, rargs, pargs) case types.LLO: return nil, fmt.Errorf("provider type not supported: %s", rargs.ProviderType) diff --git a/go.md b/go.md index 004b6468459..257f7a6349a 100644 --- a/go.md +++ b/go.md @@ -78,6 +78,8 @@ flowchart LR chainlink/v2 --> chainlink-feeds chainlink/v2 --> chainlink-protos/orchestrator chainlink/v2 --> chainlink-solana + chainlink/v2 --> chainlink-tron/relayer + chainlink/v2 --> por_mock_ocr3plugin chainlink/v2 --> tdh2/go/ocr2/decryptionplugin click chainlink/v2 href "https://github.com/smartcontractkit/chainlink" freeport @@ -86,6 +88,8 @@ flowchart LR click grpc-proxy href "https://github.com/smartcontractkit/grpc-proxy" libocr click libocr href "https://github.com/smartcontractkit/libocr" + por_mock_ocr3plugin --> libocr + click por_mock_ocr3plugin href "https://github.com/smartcontractkit/por_mock_ocr3plugin" tdh2/go/ocr2/decryptionplugin --> libocr tdh2/go/ocr2/decryptionplugin --> tdh2/go/tdh2 click tdh2/go/ocr2/decryptionplugin href "https://github.com/smartcontractkit/tdh2" @@ -258,6 +262,8 @@ flowchart LR chainlink/v2 --> chainlink-feeds chainlink/v2 --> chainlink-protos/orchestrator chainlink/v2 --> chainlink-solana + chainlink/v2 --> chainlink-tron/relayer + chainlink/v2 --> por_mock_ocr3plugin chainlink/v2 --> tdh2/go/ocr2/decryptionplugin click chainlink/v2 href "https://github.com/smartcontractkit/chainlink" freeport @@ -270,6 +276,8 @@ flowchart LR mcms --> chainlink-ccip/chains/solana mcms --> chainlink-testing-framework/framework click mcms href "https://github.com/smartcontractkit/mcms" + por_mock_ocr3plugin --> libocr + click por_mock_ocr3plugin href "https://github.com/smartcontractkit/por_mock_ocr3plugin" tdh2/go/ocr2/decryptionplugin --> libocr tdh2/go/ocr2/decryptionplugin --> tdh2/go/tdh2 click tdh2/go/ocr2/decryptionplugin href "https://github.com/smartcontractkit/tdh2" diff --git a/go.mod b/go.mod index 28ec2f2eab4..b3192c5076f 100644 --- a/go.mod +++ b/go.mod @@ -76,7 +76,7 @@ require ( github.com/smartcontractkit/chainlink-automation v0.8.1 github.com/smartcontractkit/chainlink-ccip v0.0.0-20250610112507-2a6e86a83c4a github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20250609091505-5c8cd74b92ed - github.com/smartcontractkit/chainlink-common v0.7.1-0.20250611104723-26e78071ce46 + github.com/smartcontractkit/chainlink-common v0.7.1-0.20250612155859-d23a6493b707 github.com/smartcontractkit/chainlink-data-streams v0.1.1-0.20250604171706-a98fa6515eae github.com/smartcontractkit/chainlink-evm v0.0.0-20250609190927-f1e8aecc0d1c github.com/smartcontractkit/chainlink-feeds v0.1.2-0.20250227211209-7cd000095135 @@ -89,6 +89,7 @@ require ( github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20250528121202-292529af39df github.com/smartcontractkit/freeport v0.1.1 github.com/smartcontractkit/libocr v0.0.0-20250604151357-2fe8c61bbf2e + github.com/smartcontractkit/por_mock_ocr3plugin v0.0.0-20250611023616-cdd3409730eb github.com/smartcontractkit/tdh2/go/ocr2/decryptionplugin v0.0.0-20241009055228-33d0c0bf38de github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20241009055228-33d0c0bf38de github.com/smartcontractkit/wsrpc v0.8.5-0.20250502134807-c57d3d995945 diff --git a/go.sum b/go.sum index 83387e865cd..1a2b6a1f00a 100644 --- a/go.sum +++ b/go.sum @@ -1055,8 +1055,8 @@ github.com/smartcontractkit/chainlink-ccip v0.0.0-20250610112507-2a6e86a83c4a h1 github.com/smartcontractkit/chainlink-ccip v0.0.0-20250610112507-2a6e86a83c4a/go.mod h1:uhUQUnJA5DkBtJ/0SuBJwD+DuwiK+kRBTyz9IlSY1k0= github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20250609091505-5c8cd74b92ed h1:rjtXQLTCLa/+AmMwMTP5WwJUdPBeBAF3Ivwc1GXetBw= github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20250609091505-5c8cd74b92ed/go.mod h1:k3/Z6AvwurPUlfuDFEonRbkkiTSgNSrtVNhJEWNlUZA= -github.com/smartcontractkit/chainlink-common v0.7.1-0.20250611104723-26e78071ce46 h1:iqWy3/O2sLssrR1y4jSGo5WV5f5AEpjAGYT88GapIrM= -github.com/smartcontractkit/chainlink-common v0.7.1-0.20250611104723-26e78071ce46/go.mod h1:H7gOuN4Jzf+DWllfP5Pb7AiCWBMQrDX1D1KYXAEhdnw= +github.com/smartcontractkit/chainlink-common v0.7.1-0.20250612155859-d23a6493b707 h1:Pl3tzvIVuqlbBbowYDKcQA7+cwBV8godqMjPwfaMs00= +github.com/smartcontractkit/chainlink-common v0.7.1-0.20250612155859-d23a6493b707/go.mod h1:H7gOuN4Jzf+DWllfP5Pb7AiCWBMQrDX1D1KYXAEhdnw= github.com/smartcontractkit/chainlink-common/pkg/monitoring v0.0.0-20250415235644-8703639403c7 h1:9wh1G+WbXwPVqf0cfSRSgwIcaXTQgvYezylEAfwmrbw= github.com/smartcontractkit/chainlink-common/pkg/monitoring v0.0.0-20250415235644-8703639403c7/go.mod h1:yaDOAZF6MNB+NGYpxGCUc+owIdKrjvFW0JODdTcQ3V0= github.com/smartcontractkit/chainlink-data-streams v0.1.1-0.20250604171706-a98fa6515eae h1:BmqiIDbA9FB/uOCOHi/shgL7P0XmjFxhfRtJHdKPLE4= @@ -1093,6 +1093,8 @@ github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 h1:12i github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7/go.mod h1:FX7/bVdoep147QQhsOPkYsPEXhGZjeYx6lBSaSXtZOA= github.com/smartcontractkit/libocr v0.0.0-20250604151357-2fe8c61bbf2e h1:o7GTU8Vh7geHHt5uq2TrgqnVq+2P/Uo2n0sSYO+kznA= github.com/smartcontractkit/libocr v0.0.0-20250604151357-2fe8c61bbf2e/go.mod h1:lzZ0Hq8zK1FfPb7aHuKQKrsWlrsCtBs6gNRNXh59H7Q= +github.com/smartcontractkit/por_mock_ocr3plugin v0.0.0-20250611023616-cdd3409730eb h1:cCVZQ0ITDbqS/F3xaap9c31GnwKKoswzKfoK9IkIZxQ= +github.com/smartcontractkit/por_mock_ocr3plugin v0.0.0-20250611023616-cdd3409730eb/go.mod h1:EywkCIhAgu9uIQTSyvGqpRgkLsa/vU3NQR5+ZkOdO80= github.com/smartcontractkit/tdh2/go/ocr2/decryptionplugin v0.0.0-20241009055228-33d0c0bf38de h1:n0w0rKF+SVM+S3WNlup6uabXj2zFlFNfrlsKCMMb/co= github.com/smartcontractkit/tdh2/go/ocr2/decryptionplugin v0.0.0-20241009055228-33d0c0bf38de/go.mod h1:Sl2MF/Fp3fgJIVzhdGhmZZX2BlnM0oUUyBP4s4xYb6o= github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20241009055228-33d0c0bf38de h1:66VQxXx3lvTaAZrMBkIcdH9VEjujUEvmBQdnyOJnkOc= diff --git a/plugins/plugins.private.yaml b/plugins/plugins.private.yaml index 987ae3c0537..d7b741aea2c 100644 --- a/plugins/plugins.private.yaml +++ b/plugins/plugins.private.yaml @@ -26,4 +26,7 @@ plugins: moduleURI: "github.com/smartcontractkit/capabilities/workflowevent" gitRef: "1b414f8954d071345255fa0ffb3c374b13f18a0d" installPath: "github.com/smartcontractkit/capabilities/workflowevent" - + securemint: + moduleURI: "github.com/smartcontractkit/por_mock_ocr3plugin" + gitRef: "cdd3409730eb2122d118f8324d2d4e7b6b7030ed" + installPath: "github.com/smartcontractkit/por_mock_ocr3plugin"