diff --git a/.github/workflows/test-cl-smoke.yaml b/.github/workflows/test-cl-smoke.yaml index d3d6724e9..d99a194d2 100644 --- a/.github/workflows/test-cl-smoke.yaml +++ b/.github/workflows/test-cl-smoke.yaml @@ -79,6 +79,18 @@ jobs: run_cmd: TestHA_CrossComponentDown config: "env.toml,env-HA.toml,env-cl.toml,env-cl-ci.toml" timeout: 10m + - name: TestEnvironmentChangeReconcile_CommitteeVerifierAllowlistDecoyExpectErrorThenDeployerHappyPath + run_cmd: TestEnvironmentChangeReconcile_CommitteeVerifierAllowlistDecoyExpectErrorThenDeployerHappyPath + config: "env.toml,env-cl.toml,env-cl-ci.toml" + timeout: 10m + - name: TestEnvironmentChangeReconcile_TestRouterLaneThenProductionRouterExpectMessagesSucceedEachStage + run_cmd: TestEnvironmentChangeReconcile_TestRouterLaneThenProductionRouterExpectMessagesSucceedEachStage + config: "env.toml,env-cl.toml,env-cl-ci.toml" + timeout: 10m + - name: TestEnvironmentChangeReconcile_RemoveDefaultCommitteeNOPAndLowerThresholdExpectMessageSuccessWithoutRemovedNOPVerification + run_cmd: TestEnvironmentChangeReconcile_RemoveDefaultCommitteeNOPAndLowerThresholdExpectMessageSuccessWithoutRemovedNOPVerification + config: "env.toml,env-cl.toml,env-cl-ci.toml" + timeout: 10m # We need to configure HeadTracker for the CL tests to have finality depth. Otherwise, it does instant finality. # - name: TestE2EReorg # config: "env.toml,env-src-auto-mine.toml,env-cl.toml,env-cl-ci.toml" diff --git a/build/devenv/environment.go b/build/devenv/environment.go index 1c917e5de..d29666143 100644 --- a/build/devenv/environment.go +++ b/build/devenv/environment.go @@ -487,12 +487,12 @@ func enrichEnvironmentTopology(cfg *ccipOffchain.EnvironmentTopology, verifiers } } -// buildEnvironmentTopology creates a copy of the EnvironmentTopology from the Cfg, +// BuildEnvironmentTopology creates a copy of the EnvironmentTopology from the Cfg, // enriches it with signer addresses, and returns it. This is used by both executor // and verifier changesets as the single source of truth. // For each chain_config entry that lacks a FeeAggregator, the corresponding // chain's deployer key is used as a fallback via the registered ImplFactory. -func buildEnvironmentTopology(in *Cfg, e *deployment.Environment) *ccipOffchain.EnvironmentTopology { +func BuildEnvironmentTopology(in *Cfg, e *deployment.Environment) *ccipOffchain.EnvironmentTopology { if in.EnvironmentTopology == nil { return nil } @@ -534,22 +534,16 @@ func buildEnvironmentTopology(in *Cfg, e *deployment.Environment) *ccipOffchain. } // generateExecutorJobSpecs generates job specs for all executors using the changeset. -// It returns a map of container name -> job spec for use in CL mode. // For standalone mode, it also sets GeneratedConfig on each executor. // The ds parameter is a mutable datastore that will be updated with the changeset output. func generateExecutorJobSpecs( - ctx context.Context, e *deployment.Environment, in *Cfg, - selectors []uint64, - impls []cciptestinterfaces.CCIP17Configuration, topology *ccipOffchain.EnvironmentTopology, ds datastore.MutableDataStore, -) (map[string]bootstrap.JobSpec, error) { - executorJobSpecs := make(map[string]bootstrap.JobSpec) - +) error { if len(in.Executor) == 0 { - return executorJobSpecs, nil + return nil } // Group executors by qualifier @@ -576,80 +570,87 @@ func generateExecutorJobSpecs( TargetNOPs: shared.ConvertStringToNopAliases(execNOPAliases), }) if err != nil { - return nil, fmt.Errorf("failed to generate executor configs for qualifier %s: %w", qualifier, err) + return fmt.Errorf("failed to generate executor configs for qualifier %s: %w", qualifier, err) } if err := ds.Merge(output.DataStore.Seal()); err != nil { - return nil, fmt.Errorf("failed to merge executor job specs datastore: %w", err) + return fmt.Errorf("failed to merge executor job specs datastore: %w", err) } for _, exec := range qualifierExecutors { jobSpecID := shared.NewExecutorJobID(shared.NOPAlias(exec.NOPAlias), shared.ExecutorJobScope{ExecutorQualifier: qualifier}) job, err := ccipOffchain.GetJob(output.DataStore.Seal(), shared.NOPAlias(exec.NOPAlias), jobSpecID.ToJobID()) if err != nil { - return nil, fmt.Errorf("failed to get executor job spec for %s: %w", exec.ContainerName, err) + return fmt.Errorf("failed to get executor job spec for %s: %w", exec.ContainerName, err) } - // TODO: Use bootstrap.JobSpec in CLD to avoid this conversion here var executorSpec ExecutorJobSpec - { - md, err := toml.Decode(job.Spec, &executorSpec) - if err != nil { - return nil, fmt.Errorf("failed to decode verifier job spec for %s: %w", exec.ContainerName, err) - } - if len(md.Undecoded()) > 0 { - L.Warn(). - Str("spec", job.Spec). - Str("undecoded fields", fmt.Sprintf("%v", md.Undecoded())). - Msg("Undecoded fields in executor job spec") - return nil, fmt.Errorf("unknown fields in executor job spec for %s: %v", exec.ContainerName, md.Undecoded()) - } - executorJobSpecs[exec.ContainerName] = executorSpec.ToBootstrapJobSpec() + md, err := toml.Decode(job.Spec, &executorSpec) + if err != nil { + return fmt.Errorf("failed to decode executor job spec for %s: %w", exec.ContainerName, err) } + if len(md.Undecoded()) > 0 { + L.Warn(). + Str("spec", job.Spec). + Str("undecoded fields", fmt.Sprintf("%v", md.Undecoded())). + Msg("Undecoded fields in executor job spec") + return fmt.Errorf("unknown fields in executor job spec for %s: %v", exec.ContainerName, md.Undecoded()) + } + exec.GeneratedJobSpecs = []bootstrap.JobSpec{executorSpec.ToBootstrapJobSpec()} - // Extract inner config from job spec for standalone mode var cfg executor.Configuration if err := toml.Unmarshal([]byte(executorSpec.ExecutorConfig), &cfg); err != nil { - return nil, fmt.Errorf("failed to parse executor config from job spec: %w", err) + return fmt.Errorf("failed to parse executor config from job spec: %w", err) } // Marshal the inner config back to TOML for standalone mode configBytes, err := toml.Marshal(cfg) if err != nil { - return nil, fmt.Errorf("failed to marshal executor config: %w", err) + return fmt.Errorf("failed to marshal executor config: %w", err) } exec.GeneratedConfig = string(configBytes) } } - // Set transmitter keys for standalone mode using family-specific key generation for _, exec := range in.Executor { + if exec.TransmitterPrivateKey != "" { + continue + } family := exec.ChainFamily if family == "" { family = chainsel.FamilyEVM } fac, facErr := GetImplFactory(family) if facErr != nil { - return nil, fmt.Errorf("no impl factory for executor chain family %q: %w", family, facErr) + return fmt.Errorf("no impl factory for executor chain family %q: %w", family, facErr) } pk, pkErr := fac.GenerateTransmitterKey() if pkErr != nil { - return nil, fmt.Errorf("failed to generate transmitter key for family %q: %w", family, pkErr) + return fmt.Errorf("failed to generate transmitter key for family %q: %w", family, pkErr) } exec.TransmitterPrivateKey = pk } - // Build executor transmitter addresses grouped by chain family so each chain - // only funds addresses in its native format. + return nil +} + +func fundStandaloneExecutorAddresses( + ctx context.Context, + in *Cfg, + impls []cciptestinterfaces.CCIP17Configuration, +) error { addressesByFamily := make(map[string][]protocol.UnknownAddress) for _, exec := range in.Executor { + if exec == nil || exec.Mode != services.Standalone { + continue + } family := exec.ChainFamily if family == "" { family = chainsel.FamilyEVM } fac, facErr := GetImplFactory(family) if facErr != nil { - return nil, fmt.Errorf("no impl factory for executor chain family %q: %w", family, facErr) + return fmt.Errorf("no impl factory for executor chain family %q: %w", family, facErr) } addressesByFamily[family] = append( addressesByFamily[family], @@ -657,6 +658,10 @@ func generateExecutorJobSpecs( ) } + if len(addressesByFamily) == 0 { + return nil + } + Plog.Info().Any("AddressesByFamily", addressesByFamily).Int("ImplsLen", len(impls)).Msg("Funding executors") for i, impl := range impls { family, famErr := blockchain.TypeToFamily(in.Blockchains[i].Type) @@ -674,12 +679,11 @@ func generateExecutorJobSpecs( Plog.Info().Int("ImplIndex", i).Msg("Funding executor") if err := impl.FundAddresses(ctx, in.Blockchains[i], addresses, big.NewInt(5)); err != nil { - return nil, fmt.Errorf("failed to fund addresses for executors: %w", err) + return fmt.Errorf("failed to fund addresses for executors: %w", err) } Plog.Info().Int("ImplIndex", i).Msg("Funded executors") } - - return executorJobSpecs, nil + return nil } // generateVerifierJobSpecs generates job specs for all verifiers using the changeset. @@ -690,7 +694,6 @@ func generateExecutorJobSpecs( func generateVerifierJobSpecs( e *deployment.Environment, in *Cfg, - selectors []uint64, topology *ccipOffchain.EnvironmentTopology, sharedTLSCerts *services.TLSCertPaths, ds datastore.MutableDataStore, @@ -722,11 +725,30 @@ func generateVerifierJobSpecs( } for family := range families { + activeNOPAliases, err := topologyVerifierNOPAliasesForCommitteeFamily(topology, committeeName, family) + if err != nil { + return nil, err + } + + activeFamilyVerifiers := make([]*committeeverifier.Input, 0, len(committeeVerifiers)) verNOPAliases := make([]shared.NOPAlias, 0, len(committeeVerifiers)) for _, ver := range committeeVerifiers { - if ver.ChainFamily == family { - verNOPAliases = append(verNOPAliases, shared.NOPAlias(ver.NOPAlias)) + if ver.ChainFamily != family { + continue + } + if _, ok := activeNOPAliases[ver.NOPAlias]; !ok { + ver.GeneratedJobSpecs = nil + ver.GeneratedConfig = "" + if ver.Out != nil { + ver.Out.VerifierID = "" + } + continue } + activeFamilyVerifiers = append(activeFamilyVerifiers, ver) + verNOPAliases = append(verNOPAliases, shared.NOPAlias(ver.NOPAlias)) + } + if len(activeFamilyVerifiers) == 0 { + continue } disableFinalityCheckers := disableFinalityCheckersPerFamily[family] @@ -755,16 +777,12 @@ func generateVerifierJobSpecs( // 1:1 verifier-to-aggregator mapping. For single-aggregator committees // this constraint doesn't apply — all verifiers share the one aggregator. if len(aggNames) > 1 { - if err := validateStandaloneVerifierNodeIndices(committeeName, committeeVerifiers, len(aggNames)); err != nil { + if err := validateStandaloneVerifierNodeIndices(committeeName, activeFamilyVerifiers, len(aggNames)); err != nil { return nil, err } } - for _, ver := range committeeVerifiers { - if ver.ChainFamily != family { - continue - } - + for _, ver := range activeFamilyVerifiers { allJobSpecs := make([]bootstrap.JobSpec, 0, len(aggNames)) for _, aggName := range aggNames { jobSpecID := shared.NewVerifierJobID(shared.NOPAlias(ver.NOPAlias), aggName, shared.VerifierJobScope{CommitteeQualifier: committeeName}) @@ -783,7 +801,7 @@ func generateVerifierJobSpecs( L.Warn(). Str("spec", job.Spec). Str("undecoded fields", fmt.Sprintf("%v", md.Undecoded())). - Msg("Undecoded fields in executor job spec") + Msg("Undecoded fields in verifier job spec") return nil, fmt.Errorf("unknown fields in verifier job spec for %s aggregator: %v", ver.ContainerName, md.Undecoded()) } @@ -824,6 +842,48 @@ func generateVerifierJobSpecs( return verifierJobSpecs, nil } +func topologyVerifierNOPAliasesForCommitteeFamily( + topology *ccipOffchain.EnvironmentTopology, + committeeName string, + family string, +) (map[string]struct{}, error) { + activeNOPAliases := make(map[string]struct{}) + if topology == nil || topology.NOPTopology == nil { + return activeNOPAliases, nil + } + + var committee *ccipOffchain.CommitteeConfig + for _, candidate := range topology.NOPTopology.Committees { + if strings.EqualFold(candidate.Qualifier, committeeName) { + candidateCopy := candidate + committee = &candidateCopy + break + } + } + if committee == nil { + return nil, fmt.Errorf("committee %s not found in topology", committeeName) + } + + for selectorStr, chainCfg := range committee.ChainConfigs { + selector, err := strconv.ParseUint(selectorStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("parse committee %s chain selector %q: %w", committeeName, selectorStr, err) + } + selectorFamily, err := chainsel.GetSelectorFamily(selector) + if err != nil { + return nil, fmt.Errorf("get chain family for selector %d: %w", selector, err) + } + if selectorFamily != family { + continue + } + for _, nopAlias := range chainCfg.NOPAliases { + activeNOPAliases[nopAlias] = struct{}{} + } + } + + return activeNOPAliases, nil +} + // NewEnvironment creates a new CCIP CCV environment locally in Docker. func NewEnvironment() (in *Cfg, err error) { ctx := context.Background() @@ -1089,7 +1149,7 @@ func NewEnvironment() (in *Cfg, err error) { } L.Info().Any("Selectors", selectors).Msg("Deploying for chain selectors") - topology := buildEnvironmentTopology(in, e) + topology := BuildEnvironmentTopology(in, e) if topology == nil { return nil, fmt.Errorf("failed to build environment topology") } @@ -1238,29 +1298,15 @@ func NewEnvironment() (in *Cfg, err error) { } } - // Generate aggregator configs using changesets (on-chain state as source of truth) - for _, aggregatorInput := range in.Aggregator { - aggregatorInput.SharedTLSCerts = sharedTLSCerts - - // Use changeset to generate committee config from on-chain state - instanceName := aggregatorInput.InstanceName() - cs := ccipChangesets.GenerateAggregatorConfig(ccipAdapters.GetAggregatorConfigRegistry()) - output, err := cs.Apply(*e, ccipChangesets.GenerateAggregatorConfigInput{ - Topology: topology, - ServiceIdentifier: instanceName + "-aggregator", - CommitteeQualifier: aggregatorInput.CommitteeName, - }) - if err != nil { - return nil, fmt.Errorf("failed to generate aggregator config for %s (committee %s): %w", instanceName, aggregatorInput.CommitteeName, err) - } - - // Get generated config from output datastore - aggCfg, err := offchainloader.GetAggregatorConfig(output.DataStore.Seal(), instanceName+"-aggregator") - if err != nil { - return nil, fmt.Errorf("failed to get aggregator config from output: %w", err) - } - aggregatorInput.GeneratedCommittee = aggCfg + verifierJobSpecs, err := configureOffchainFromTopology(e, in, topology, sharedTLSCerts, ds) + if err != nil { + return nil, fmt.Errorf("failed to regenerate offchain config: %w", err) + } + if err := fundStandaloneExecutorAddresses(ctx, in, impls); err != nil { + return nil, fmt.Errorf("failed to fund standalone executor addresses: %w", err) + } + for _, aggregatorInput := range in.Aggregator { out, err := services.NewAggregator(aggregatorInput) if err != nil { return nil, fmt.Errorf("failed to create aggregator service for committee %s: %w", aggregatorInput.CommitteeName, err) @@ -1269,7 +1315,6 @@ func NewEnvironment() (in *Cfg, err error) { if out.TLSCACertFile != "" { in.AggregatorCACertFiles[aggregatorInput.CommitteeName] = out.TLSCACertFile } - e.DataStore = output.DataStore.Seal() } /////////////////////////////// @@ -1281,31 +1326,6 @@ func NewEnvironment() (in *Cfg, err error) { // start up the indexer(s) after the aggregators are up to avoid spamming of errors // in the logs when they start before the aggregators are up. /////////////////////////// - // Generate indexer config using changeset (on-chain state as source of truth). - // One shared config is generated; all indexers use the same config and duplicated secrets/auth. - if len(in.Aggregator) > 0 && len(in.Indexer) > 0 { - firstIdx := in.Indexer[0] - cs := ccipChangesets.GenerateIndexerConfig(ccipAdapters.GetIndexerConfigRegistry()) - output, err := cs.Apply(*e, ccipChangesets.GenerateIndexerConfigInput{ - ServiceIdentifier: "indexer", - CommitteeVerifierNameToQualifier: firstIdx.CommitteeVerifierNameToQualifier, - CCTPVerifierNameToQualifier: firstIdx.CCTPVerifierNameToQualifier, - LombardVerifierNameToQualifier: firstIdx.LombardVerifierNameToQualifier, - }) - if err != nil { - return nil, fmt.Errorf("failed to generate indexer config: %w", err) - } - - idxCfg, err := offchainloader.GetIndexerConfig(output.DataStore.Seal(), "indexer") - if err != nil { - return nil, fmt.Errorf("failed to get indexer config from output: %w", err) - } - e.DataStore = output.DataStore.Seal() - for _, idxIn := range in.Indexer { - idxIn.GeneratedCfg = idxCfg - } - } - if len(in.Indexer) < 1 { return nil, fmt.Errorf("at least one indexer is required") } @@ -1423,11 +1443,6 @@ func NewEnvironment() (in *Cfg, err error) { // START: Launch executors // ///////////////////////////// - executorJobSpecs, err := generateExecutorJobSpecs(ctx, e, in, selectors, impls, topology, ds) - if err != nil { - return nil, err - } - _, err = launchStandaloneExecutors(in.Executor, blockchainOutputs) if err != nil { return nil, fmt.Errorf("failed to create standalone executor: %w", err) @@ -1442,7 +1457,7 @@ func NewEnvironment() (in *Cfg, err error) { if err := registerExecutorsWithJD(ctx, in.Executor, jdInfra.OffchainClient); err != nil { return nil, err } - if err := proposeJobsToExecutors(ctx, in.Executor, executorJobSpecs, blockchainOutputs, jdInfra.OffchainClient); err != nil { + if err := proposeJobsToExecutors(ctx, in.Executor, blockchainOutputs, jdInfra.OffchainClient); err != nil { return nil, err } } @@ -1455,11 +1470,6 @@ func NewEnvironment() (in *Cfg, err error) { // START: Launch verifiers // ///////////////////////////// - verifierJobSpecs, err := generateVerifierJobSpecs(e, in, selectors, topology, sharedTLSCerts, ds) - if err != nil { - return nil, err - } - // Each verifier owns one aggregator (NodeIndex % numAggs). Select the // corresponding job spec so proposeJobsToStandaloneVerifiers gets a // single spec per container. @@ -1564,15 +1574,10 @@ func NewEnvironment() (in *Cfg, err error) { //////////////////////////////////////////////////// e.DataStore = ds.Seal() + in.CLDF.DataStore = e.DataStore - if in.JDInfra != nil { - if err := jobs.AcceptPendingJobs(ctx, in.ClientLookup); err != nil { - return nil, fmt.Errorf("failed to accept pending jobs: %w", err) - } - - if err := jobs.SyncAndVerifyJobProposals(e); err != nil { - return nil, fmt.Errorf("failed to sync/verify job proposals: %w", err) - } + if err := acceptPendingJobsAndSync(ctx, e, in); err != nil { + return nil, fmt.Errorf("failed to accept pending jobs: %w", err) } timeTrack.Print() @@ -1901,7 +1906,6 @@ func registerExecutorsWithJD(ctx context.Context, executors []*executorsvc.Input func proposeJobsToExecutors( ctx context.Context, executors []*executorsvc.Input, - executorJobSpecs map[string]bootstrap.JobSpec, blockchainOutputs []*blockchain.Output, jdClient offchain.Client, ) error { @@ -1935,10 +1939,10 @@ func proposeJobsToExecutors( return fmt.Errorf("failed to load chain config for family %s: %w", exec.ChainFamily, err) } - baseJobSpec, ok := executorJobSpecs[exec.ContainerName] - if !ok { + if len(exec.GeneratedJobSpecs) == 0 { return fmt.Errorf("no job spec found for executor %s", exec.ContainerName) } + baseJobSpec := exec.GeneratedJobSpecs[0] jobSpec, err := exec.RebuildExecutorJobSpecWithBlockchainInfos(baseJobSpec, blockchainInfos) if err != nil { diff --git a/build/devenv/environment_change_requirements.go b/build/devenv/environment_change_requirements.go new file mode 100644 index 000000000..544ca0177 --- /dev/null +++ b/build/devenv/environment_change_requirements.go @@ -0,0 +1,45 @@ +package ccv + +import ( + "fmt" + + "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/offchain/shared" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/services" +) + +// RequireFullCLModeForEnvironmentChangeReconcile returns an error if the loaded env-out is not a +// fully Chainlink-backed devenv (topology NOPs CL, verifier and executor services in CL mode, node sets present). +// Environment-change reconcile E2E tests use this to skip standalone smoke env-outs. +func RequireFullCLModeForEnvironmentChangeReconcile(in *Cfg) error { + if in == nil { + return fmt.Errorf("cfg is nil") + } + if in.EnvironmentTopology == nil || in.EnvironmentTopology.NOPTopology == nil { + return fmt.Errorf("environment_topology.nop_topology is required") + } + for _, nop := range in.EnvironmentTopology.NOPTopology.NOPs { + if nop.GetMode() != shared.NOPModeCL { + ident := nop.Alias + if ident == "" { + ident = nop.Name + } + return fmt.Errorf("NOP %q must use topology mode %q for environment-change reconcile tests (got %q)", + ident, shared.NOPModeCL, nop.GetMode()) + } + } + for _, v := range in.Verifier { + if v.Mode != services.CL { + return fmt.Errorf("verifier for nop_alias %q must use mode %q (got %q)", + v.NOPAlias, services.CL, v.Mode) + } + } + for _, ex := range in.Executor { + if ex.Mode != services.CL { + return fmt.Errorf("executor must use mode %q (got %q)", services.CL, ex.Mode) + } + } + if len(in.NodeSets) == 0 { + return fmt.Errorf("at least one nodeset is required for CL mode") + } + return nil +} diff --git a/build/devenv/evm/cctp.go b/build/devenv/evm/cctp.go index 76a89a68d..9c512050e 100644 --- a/build/devenv/evm/cctp.go +++ b/build/devenv/evm/cctp.go @@ -434,7 +434,7 @@ func filterOnlySupportedSelectors(remoteSelectors []uint64) []uint64 { } func usdcTokenPoolProxies(sourceSelector uint64, remoteSelectors []uint64) map[uint64]datastore.AddressRef { - selectors := make([]uint64, 0) + selectors := make([]uint64, 0, 1+len(remoteSelectors)) selectors = append(selectors, sourceSelector) selectors = append(selectors, remoteSelectors...) diff --git a/build/devenv/evm/impl.go b/build/devenv/evm/impl.go index 49634a389..38c2926cb 100644 --- a/build/devenv/evm/impl.go +++ b/build/devenv/evm/impl.go @@ -298,10 +298,11 @@ func (m *CCIP17EVM) getOrCreateOffRampPoller() (*eventPoller[cciptestinterfaces. event := filter.Event key := eventKey{chainSelector: event.SourceChainSelector, msgNum: event.MessageNumber} events[key] = cciptestinterfaces.ExecutionStateChangedEvent{ - MessageID: event.MessageId, - MessageNumber: event.MessageNumber, - State: cciptestinterfaces.MessageExecutionState(event.State), - ReturnData: event.ReturnData, + SourceChainSelector: protocol.ChainSelector(event.SourceChainSelector), + MessageID: event.MessageId, + MessageNumber: event.MessageNumber, + State: cciptestinterfaces.MessageExecutionState(event.State), + ReturnData: event.ReturnData, } } @@ -1107,6 +1108,7 @@ func (m *CCIP17EVMConfig) GetDeployChainContractsCfg(env *deployment.Environment return ccipChangesets.DeployChainContractsPerChainCfg{ DeployerContract: create2Ref.Address, DeployerKeyOwned: true, + DeployTestRouter: true, RMNRemote: adapters.RMNRemoteDeployParams{ Version: semver.MustParse(rmn_remote.Deploy.Version()), }, @@ -1412,21 +1414,21 @@ func (m *CCIP17EVM) GetMaxDataBytes(ctx context.Context, remoteChainSelector uin return destChainConfig.MaxDataBytes, nil } +func (m *CCIP17EVMConfig) GetChainLaneProfile(_ *deployment.Environment, selector uint64) (cciptestinterfaces.ChainLaneProfile, error) { + return cciptestinterfaces.ChainLaneProfile{ + FeeQuoterDestChainConfig: ccipChangesets.FeeQuoterDestChainConfigOverrides{ + USDPerUnitGas: big.NewInt(1e6), + }, + }, nil +} + func (m *CCIP17EVMConfig) GetConnectionProfile(_ *deployment.Environment, selector uint64) (lanes.ChainDefinition, lanes.CommitteeVerifierRemoteChainInput, error) { + override := evmFeeQuoterDestChainConfigOverride(selector) chainDef := lanes.ChainDefinition{ - Selector: selector, - AddressBytesLength: 20, - BaseExecutionGasCost: 150_000, - FeeQuoterDestChainConfigOverrides: evmFeeQuoterDestChainConfigOverride(selector), + FeeQuoterDestChainConfigOverrides: override, ExecutorDestChainConfig: lanes.ExecutorDestChainConfig{ Enabled: true, }, - DefaultExecutor: datastore.AddressRef{ - Type: datastore.ContractType(sequences.ExecutorProxyType), - Version: semver.MustParse(proxy.Deploy.Version()), - Qualifier: devenvcommon.DefaultExecutorQualifier, - ChainSelector: selector, - }, DefaultInboundCCVs: []datastore.AddressRef{ { Type: datastore.ContractType(versioned_verifier_resolver.CommitteeVerifierResolverType), @@ -1443,12 +1445,12 @@ func (m *CCIP17EVMConfig) GetConnectionProfile(_ *deployment.Environment, select Qualifier: devenvcommon.DefaultCommitteeVerifierQualifier, }, }, + AddressBytesLength: 20, + BaseExecutionGasCost: 150_000, } - cvConfig := lanes.CommitteeVerifierRemoteChainInput{ GasForVerification: CommitteeVerifierGasForVerification, } - return chainDef, cvConfig, nil } @@ -1473,14 +1475,6 @@ func evmFeeQuoterDestChainConfigOverride(selector uint64) *lanes.FeeQuoterDestCh return &override } -func (m *CCIP17EVMConfig) GetChainLaneProfile(_ *deployment.Environment, selector uint64) (cciptestinterfaces.ChainLaneProfile, error) { - return cciptestinterfaces.ChainLaneProfile{ - FeeQuoterDestChainConfig: ccipChangesets.FeeQuoterDestChainConfigOverrides{ - USDPerUnitGas: big.NewInt(1e6), - }, - }, nil -} - func (m *CCIP17EVMConfig) PostConnect(e *deployment.Environment, selector uint64, remoteSelectors []uint64) error { if err := m.ConfigureUSDCAndLombardForTransfer(e, selector, remoteSelectors); err != nil { return fmt.Errorf("configure USDC/Lombard for transfer: %w", err) @@ -1757,10 +1751,11 @@ func (m *CCIP17EVM) ManuallyExecuteMessage( continue } event = cciptestinterfaces.ExecutionStateChangedEvent{ - MessageID: parsedLog.MessageId, - MessageNumber: parsedLog.MessageNumber, - State: cciptestinterfaces.MessageExecutionState(parsedLog.State), - ReturnData: parsedLog.ReturnData, + SourceChainSelector: protocol.ChainSelector(parsedLog.SourceChainSelector), + MessageID: parsedLog.MessageId, + MessageNumber: parsedLog.MessageNumber, + State: cciptestinterfaces.MessageExecutionState(parsedLog.State), + ReturnData: parsedLog.ReturnData, } break } diff --git a/build/devenv/implcommon.go b/build/devenv/implcommon.go index c84e6f616..60b97a264 100644 --- a/build/devenv/implcommon.go +++ b/build/devenv/implcommon.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "maps" - "sort" "github.com/Masterminds/semver/v3" @@ -145,36 +144,20 @@ func connectAllChainsCanonical( topology *ccipOffchain.EnvironmentTopology, ) error { if len(blockchains) != len(impls) { - return fmt.Errorf("connectAllChains: mismatched lengths: %d impls and %d blockchains", len(impls), len(blockchains)) + return fmt.Errorf("connectAllChainsCanonical: mismatched lengths: %d impls and %d blockchains", len(impls), len(blockchains)) } if len(selectors) == 0 { - return fmt.Errorf("connectAllChains: selectors must be non-empty") + return fmt.Errorf("connectAllChainsCanonical: selectors must be non-empty") } - profiles := make(map[uint64]chainProfile, len(impls)) - orderedSelectors := make([]uint64, 0, len(impls)) - for i, impl := range impls { - networkInfo, err := chainsel.GetChainDetailsByChainIDAndFamily(blockchains[i].ChainID, impl.ChainFamily()) - if err != nil { - return fmt.Errorf("chain %d: %w", i, err) - } - sel := networkInfo.ChainSelector - remotes := make([]uint64, 0, len(selectors)) - for _, s := range selectors { - if s != sel { - remotes = append(remotes, s) - } - } - profile, err := impl.GetChainLaneProfile(e, sel) - if err != nil { - return fmt.Errorf("get chain lane profile for chain %d: %w", sel, err) - } - profiles[sel] = chainProfile{ - remotes: remotes, - impl: impl, - profile: profile, - } - orderedSelectors = append(orderedSelectors, sel) + orderedSelectors, profiles, err := buildConnectionProfilesFromImpls(impls, blockchains, selectors, e) + if err != nil { + return fmt.Errorf("connectAllChainsCanonical: %w", err) + } + + partialChains, useTestRouter, err := buildPartialChainConfigsFromProfiles(topology, orderedSelectors, profiles, ReconfigureLanesParams{}) + if err != nil { + return fmt.Errorf("connectAllChainsCanonical: %w", err) } e.OperationsBundle = operations.NewBundle( @@ -189,36 +172,16 @@ func connectAllChainsCanonical( changesetscore.GetRegistry(), ) - for i := 1; i < len(orderedSelectors); i++ { - newSel := orderedSelectors[i] - previousSels := orderedSelectors[:i] - - var configs []ccipChangesets.PartialChainConfig - - newChainCfg, err := buildPartialChainConfig(newSel, previousSels, profiles, topology) - if err != nil { - return fmt.Errorf("round %d: build config for new chain %d: %w", i, newSel, err) - } - configs = append(configs, newChainCfg) - - for _, prevSel := range previousSels { - prevChainCfg, err := buildPartialChainConfig(prevSel, []uint64{newSel}, profiles, topology) - if err != nil { - return fmt.Errorf("round %d: build config for existing chain %d: %w", i, prevSel, err) - } - configs = append(configs, prevChainCfg) - } - - cfg := ccipChangesets.ConfigureChainsForLanesFromTopologyConfig{ - Topology: topology, - Chains: configs, - } - if err := cs.VerifyPreconditions(*e, cfg); err != nil { - return fmt.Errorf("round %d (adding chain %d): precondition check failed: %w", i, newSel, err) - } - if _, err := cs.Apply(*e, cfg); err != nil { - return fmt.Errorf("round %d (adding chain %d): configure chains for lanes: %w", i, newSel, err) - } + cfg := ccipChangesets.ConfigureChainsForLanesFromTopologyConfig{ + Topology: topology, + Chains: partialChains, + UseTestRouter: useTestRouter, + } + if err := cs.VerifyPreconditions(*e, cfg); err != nil { + return fmt.Errorf("connectAllChainsCanonical: precondition check failed: %w", err) + } + if _, err := cs.Apply(*e, cfg); err != nil { + return fmt.Errorf("connectAllChainsCanonical: configure chains for lanes: %w", err) } for _, sel := range orderedSelectors { @@ -231,69 +194,9 @@ func connectAllChainsCanonical( return nil } -func buildPartialChainConfig( - localSel uint64, - remoteSels []uint64, - profiles map[uint64]chainProfile, - topology *ccipOffchain.EnvironmentTopology, -) (ccipChangesets.PartialChainConfig, error) { - localEntry, ok := profiles[localSel] - if !ok { - return ccipChangesets.PartialChainConfig{}, fmt.Errorf("no profile for local chain %d", localSel) - } - local := localEntry.profile - - remoteChains := make(map[uint64]ccipChangesets.PartialRemoteChainConfig, len(remoteSels)) - for _, rs := range remoteSels { - remoteEntry, ok := profiles[rs] - if !ok { - return ccipChangesets.PartialChainConfig{}, fmt.Errorf("no profile for remote chain %d", rs) - } - remote := remoteEntry.profile - allowTrafficFrom := true - remoteChains[rs] = ccipChangesets.PartialRemoteChainConfig{ - AllowTrafficFrom: &allowTrafficFrom, - DefaultInboundCCVs: local.DefaultInboundCCVs, - DefaultOutboundCCVs: local.DefaultOutboundCCVs, - DefaultExecutorQualifier: local.DefaultExecutorQualifier, - FeeQuoterDestChainConfig: remote.FeeQuoterDestChainConfig, - ExecutorDestChainConfig: local.ExecutorDestChainConfig, - BaseExecutionGasCost: remote.BaseExecutionGasCost, - } - } - - qualifiers := make([]string, 0, len(topology.NOPTopology.Committees)) - for qualifier := range topology.NOPTopology.Committees { - qualifiers = append(qualifiers, qualifier) - } - sort.Strings(qualifiers) - - cvConfigs := make([]ccipChangesets.CommitteeVerifierInputConfig, 0, len(qualifiers)) - for _, qualifier := range qualifiers { - remoteCV := make(map[uint64]ccipChangesets.CommitteeVerifierRemoteChainConfig, len(remoteSels)) - for _, rs := range remoteSels { - remoteCV[rs] = ccipChangesets.CommitteeVerifierRemoteChainConfig{ - GasForVerification: profiles[rs].profile.GasForVerification, - } - } - cvConfigs = append(cvConfigs, ccipChangesets.CommitteeVerifierInputConfig{ - CommitteeQualifier: qualifier, - RemoteChains: remoteCV, - AllowedFinalityConfig: local.AllowedFinalityConfig, - }) - } - - return ccipChangesets.PartialChainConfig{ - ChainSelector: localSel, - CommitteeVerifiers: cvConfigs, - RemoteChains: remoteChains, - }, nil -} - // --------------------------------------------------------------------------- // Legacy path: lanes.ConnectChains -// --------------------------------------------------------------------------- - +// ---------------------------------------------------------------------------. type chainEntry struct { remoteSelectors []uint64 impl cciptestinterfaces.CCIP17Configuration @@ -381,15 +284,15 @@ func connectAllChainsLegacy( mcmsReaderRegistry := changesetscore.GetRegistry() connectChainsCS := lanes.ConnectChains(laneAdapterRegistry, mcmsReaderRegistry) - cfg := lanes.ConnectChainsConfig{ + connectCfg := lanes.ConnectChainsConfig{ Lanes: laneConfigs, CommitteePopulator: populator, } - if err := connectChainsCS.VerifyPreconditions(*e, cfg); err != nil { - return fmt.Errorf("connect chains precondition check failed: %w", err) + if err := connectChainsCS.VerifyPreconditions(*e, connectCfg); err != nil { + return fmt.Errorf("connectAllChainsLegacy: precondition check failed: %w", err) } - if _, err := connectChainsCS.Apply(*e, cfg); err != nil { - return fmt.Errorf("configure chains for lanes: %w", err) + if _, err := connectChainsCS.Apply(*e, connectCfg); err != nil { + return fmt.Errorf("connectAllChainsLegacy: connect chains: %w", err) } for _, sel := range orderedSelectors { diff --git a/build/devenv/lane_topology.go b/build/devenv/lane_topology.go new file mode 100644 index 000000000..025666d91 --- /dev/null +++ b/build/devenv/lane_topology.go @@ -0,0 +1,251 @@ +package ccv + +import ( + "fmt" + "maps" + "sort" + + chainsel "github.com/smartcontractkit/chain-selectors" + + ccipChangesets "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/changesets" + ccipOffchain "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/offchain" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" +) + +// LanePartialConfigOverrides are per-local-chain deltas layered on GetChainLaneProfile baselines. +type LanePartialConfigOverrides struct { + CommitteeRemotePatches map[uint64]ccipChangesets.CommitteeVerifierRemoteChainConfig + UseTestRouter bool +} + +func mapsCloneCV(m map[uint64]ccipChangesets.CommitteeVerifierRemoteChainConfig) map[uint64]ccipChangesets.CommitteeVerifierRemoteChainConfig { + out := make(map[uint64]ccipChangesets.CommitteeVerifierRemoteChainConfig, len(m)) + maps.Copy(out, m) + return out +} + +func mergeCommitteeVerifierRemoteChainConfigForReconcile( + base, patch ccipChangesets.CommitteeVerifierRemoteChainConfig, +) ccipChangesets.CommitteeVerifierRemoteChainConfig { + out := base + out.AllowlistEnabled = patch.AllowlistEnabled + if len(patch.AddedAllowlistedSenders) > 0 { + out.AddedAllowlistedSenders = patch.AddedAllowlistedSenders + } + if len(patch.RemovedAllowlistedSenders) > 0 { + out.RemovedAllowlistedSenders = patch.RemovedAllowlistedSenders + } + if patch.GasForVerification != nil { + out.GasForVerification = patch.GasForVerification + } + if patch.FeeUSDCents != nil { + out.FeeUSDCents = patch.FeeUSDCents + } + if patch.PayloadSizeBytes != nil { + out.PayloadSizeBytes = patch.PayloadSizeBytes + } + return out +} + +func committeeVerifiersForTopology( + topology *ccipOffchain.EnvironmentTopology, + remoteSelectors []uint64, + profiles map[uint64]chainProfile, +) ([]ccipChangesets.CommitteeVerifierInputConfig, error) { + if topology == nil || topology.NOPTopology == nil { + return nil, fmt.Errorf("topology with NOPTopology is required") + } + + qualifiers := make([]string, 0, len(topology.NOPTopology.Committees)) + for qualifier := range topology.NOPTopology.Committees { + qualifiers = append(qualifiers, qualifier) + } + sort.Strings(qualifiers) + + verifiers := make([]ccipChangesets.CommitteeVerifierInputConfig, 0, len(qualifiers)) + for _, qualifier := range qualifiers { + remoteCV := make(map[uint64]ccipChangesets.CommitteeVerifierRemoteChainConfig, len(remoteSelectors)) + for _, rs := range remoteSelectors { + remoteCV[rs] = ccipChangesets.CommitteeVerifierRemoteChainConfig{ + GasForVerification: profiles[rs].profile.GasForVerification, + } + } + verifiers = append(verifiers, ccipChangesets.CommitteeVerifierInputConfig{ + CommitteeQualifier: qualifier, + RemoteChains: mapsCloneCV(remoteCV), + }) + } + return verifiers, nil +} + +// partialChainConfigFromProfile builds one PartialChainConfig for localSelector +// using ChainLaneProfile data. Committee verifiers use each peer's +// GasForVerification; remote chain configs use the remote's FeeQuoterDestChainConfig +// and the local chain's executor/CCV defaults. LanePartialConfigOverrides are merged. +func partialChainConfigFromProfile( + localSelector uint64, + remoteSelectors []uint64, + topology *ccipOffchain.EnvironmentTopology, + local cciptestinterfaces.ChainLaneProfile, + profiles map[uint64]chainProfile, + o LanePartialConfigOverrides, +) (ccipChangesets.PartialChainConfig, error) { + committeeVerifiers, err := committeeVerifiersForTopology(topology, remoteSelectors, profiles) + if err != nil { + return ccipChangesets.PartialChainConfig{}, err + } + if len(o.CommitteeRemotePatches) > 0 { + for i := range committeeVerifiers { + merged := make(map[uint64]ccipChangesets.CommitteeVerifierRemoteChainConfig, len(committeeVerifiers[i].RemoteChains)) + for rs, cfg := range committeeVerifiers[i].RemoteChains { + merged[rs] = cfg + if patch, ok := o.CommitteeRemotePatches[rs]; ok { + merged[rs] = mergeCommitteeVerifierRemoteChainConfigForReconcile(merged[rs], patch) + } + } + committeeVerifiers[i].RemoteChains = merged + } + } + + remoteChains := make(map[uint64]ccipChangesets.PartialRemoteChainConfig, len(remoteSelectors)) + for _, rs := range remoteSelectors { + remoteProfile, ok := profiles[rs] + if !ok { + return ccipChangesets.PartialChainConfig{}, fmt.Errorf("missing profile for remote selector %d", rs) + } + remote := remoteProfile.profile + allowTrafficFrom := true + remoteChains[rs] = ccipChangesets.PartialRemoteChainConfig{ + AllowTrafficFrom: &allowTrafficFrom, + DefaultInboundCCVs: local.DefaultInboundCCVs, + DefaultOutboundCCVs: local.DefaultOutboundCCVs, + DefaultExecutorQualifier: local.DefaultExecutorQualifier, + FeeQuoterDestChainConfig: remote.FeeQuoterDestChainConfig, + ExecutorDestChainConfig: local.ExecutorDestChainConfig, + BaseExecutionGasCost: remote.BaseExecutionGasCost, + } + } + + return ccipChangesets.PartialChainConfig{ + ChainSelector: localSelector, + CommitteeVerifiers: committeeVerifiers, + RemoteChains: remoteChains, + }, nil +} + +// buildConnectionProfilesFromImpls resolves selectors from blockchains and impls, +// builds the peer mesh remote selector lists, and loads GetChainLaneProfile per chain. +func buildConnectionProfilesFromImpls( + impls []cciptestinterfaces.CCIP17Configuration, + blockchains []*blockchain.Input, + selectors []uint64, + e *deployment.Environment, +) ([]uint64, map[uint64]chainProfile, error) { + if len(blockchains) != len(impls) { + return nil, nil, fmt.Errorf("connection profiles: mismatched lengths: %d impls and %d blockchains", len(impls), len(blockchains)) + } + profiles := make(map[uint64]chainProfile, len(impls)) + orderedSelectors := make([]uint64, 0, len(impls)) + for i, impl := range impls { + networkInfo, err := chainsel.GetChainDetailsByChainIDAndFamily(blockchains[i].ChainID, impl.ChainFamily()) + if err != nil { + return nil, nil, fmt.Errorf("chain %d: %w", i, err) + } + sel := networkInfo.ChainSelector + remotes := make([]uint64, 0, len(selectors)) + for _, s := range selectors { + if s != sel { + remotes = append(remotes, s) + } + } + profile, err := impl.GetChainLaneProfile(e, sel) + if err != nil { + return nil, nil, fmt.Errorf("get chain lane profile for chain %d: %w", sel, err) + } + profiles[sel] = chainProfile{ + remotes: remotes, + impl: impl, + profile: profile, + } + orderedSelectors = append(orderedSelectors, sel) + } + return orderedSelectors, profiles, nil +} + +func assertConnectionProfilesCoverSelectors(orderedSelectors, selectors []uint64) error { + want := make(map[uint64]struct{}, len(selectors)) + for _, s := range selectors { + want[s] = struct{}{} + } + got := make(map[uint64]struct{}, len(orderedSelectors)) + for _, s := range orderedSelectors { + got[s] = struct{}{} + } + if len(want) != len(got) { + return fmt.Errorf("reconfigure lanes: selectors set does not match configured chains") + } + for s := range want { + if _, ok := got[s]; !ok { + return fmt.Errorf("reconfigure lanes: selector %d not found in blockchains/impls", s) + } + } + return nil +} + +func lanePartialOverridesFromReconfigureParams(params ReconfigureLanesParams, localSel uint64) LanePartialConfigOverrides { + var o LanePartialConfigOverrides + if params.CommitteePatches != nil { + if inner, ok := params.CommitteePatches[localSel]; ok && len(inner) > 0 { + o.CommitteeRemotePatches = inner + } + } + if params.TestRouterByLane != nil { + if inner, ok := params.TestRouterByLane[localSel]; ok { + for _, v := range inner { + if v { + o.UseTestRouter = true + break + } + } + } + } + return o +} + +// buildPartialChainConfigsFromProfiles builds ConfigureChainsForLanesFromTopology +// partial configs for every chain in orderedSelectors. Zero ReconfigureLanesParams +// matches fresh deploy (production Router lanes). +func buildPartialChainConfigsFromProfiles( + topology *ccipOffchain.EnvironmentTopology, + orderedSelectors []uint64, + profiles map[uint64]chainProfile, + params ReconfigureLanesParams, +) ([]ccipChangesets.PartialChainConfig, bool, error) { + useTestRouter := false + chains := make([]ccipChangesets.PartialChainConfig, 0, len(orderedSelectors)) + for _, localSel := range orderedSelectors { + entry, ok := profiles[localSel] + if !ok { + return nil, false, fmt.Errorf("no profile for local chain %d", localSel) + } + o := lanePartialOverridesFromReconfigureParams(params, localSel) + if o.UseTestRouter { + useTestRouter = true + } + pc, err := partialChainConfigFromProfile( + localSel, + entry.remotes, + topology, + entry.profile, + profiles, + o, + ) + if err != nil { + return nil, false, fmt.Errorf("partial chain config for selector %d: %w", localSel, err) + } + chains = append(chains, pc) + } + return chains, useTestRouter, nil +} diff --git a/build/devenv/reconcile_offchain.go b/build/devenv/reconcile_offchain.go new file mode 100644 index 000000000..62e5a5927 --- /dev/null +++ b/build/devenv/reconcile_offchain.go @@ -0,0 +1,367 @@ +package ccv + +import ( + "context" + "fmt" + + "google.golang.org/grpc/credentials/insecure" + + ccipAdapters "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/adapters" + ccipChangesets "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/changesets" + ccipOffchain "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/offchain" + "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/offchain/shared" + "github.com/smartcontractkit/chainlink-ccv/bootstrap" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/jobs" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/offchainloader" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/services" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/offchain" + cldfjd "github.com/smartcontractkit/chainlink-deployments-framework/offchain/jd" + nodev1 "github.com/smartcontractkit/chainlink-protos/job-distributor/v1/node" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" +) + +// ConfigureOffchainOptions controls off-chain configuration after an environment change (on-chain or topology). +// +// By default (RestartTomlConsumers nil), after TOML is regenerated this function updates aggregator +// generated TOML in running containers, writes fresh standalone config files to disk, then restarts +// only TOML-bound Docker services: aggregators, +// indexers, standalone executors, and standalone verifiers. +// Set RestartTomlConsumers to a non-nil false when Docker is unavailable or to avoid restarting consumers +// (e.g. out-of-band reload or diagnostics). +type ConfigureOffchainOptions struct { + FundExecutors bool + + // RestartTomlConsumers when nil means true: docker restart TOML-bound service containers after a successful reconcile. + RestartTomlConsumers *bool +} + +func (o ConfigureOffchainOptions) effectiveRestartTomlConsumers() bool { + if o.RestartTomlConsumers == nil { + return true + } + return *o.RestartTomlConsumers +} + +// ConfigureTopologyLanesAndOffchain applies on-chain lane changes from topology and params, then runs off-chain +// regeneration (aggregator, indexer, executor, verifier) and restarts TOML-bound Docker services by default. +func ConfigureTopologyLanesAndOffchain( + ctx context.Context, + e *deployment.Environment, + in *Cfg, + topology *ccipOffchain.EnvironmentTopology, + selectors []uint64, + blockchains []*blockchain.Input, + impls []cciptestinterfaces.CCIP17Configuration, + laneParams ReconfigureLanesParams, + sharedTLSCerts *services.TLSCertPaths, + offchainOpts ConfigureOffchainOptions, +) error { + if err := reconfigureLanesFromTopology(ctx, e, topology, selectors, blockchains, impls, laneParams); err != nil { + return fmt.Errorf("configure topology lanes and offchain: on-chain: %w", err) + } + if err := configureOffchainAfterOnChainChange(ctx, e, in, impls, topology, sharedTLSCerts, offchainOpts); err != nil { + return fmt.Errorf("configure topology lanes and offchain: off-chain: %w", err) + } + return nil +} + +// configureOffchainAfterOnChainChange re-runs GenerateAggregatorConfig, GenerateIndexerConfig, +// executor and verifier job-spec changesets, merges outputs into e.DataStore, and by default restarts TOML-bound Docker services. +func configureOffchainAfterOnChainChange( + ctx context.Context, + e *deployment.Environment, + in *Cfg, + impls []cciptestinterfaces.CCIP17Configuration, + topology *ccipOffchain.EnvironmentTopology, + sharedTLSCerts *services.TLSCertPaths, + opts ConfigureOffchainOptions, +) error { + if e == nil || in == nil || topology == nil { + return fmt.Errorf("reconcile: environment, config, and topology are required") + } + mds := datastore.NewMemoryDataStore() + if err := mds.Merge(e.DataStore); err != nil { + return fmt.Errorf("reconcile: merge initial datastore: %w", err) + } + if _, err := configureOffchainFromTopology(e, in, topology, sharedTLSCerts, mds); err != nil { + return fmt.Errorf("reconcile: configure offchain: %w", err) + } + if opts.FundExecutors { + if err := fundStandaloneExecutorAddresses(ctx, in, impls); err != nil { + return fmt.Errorf("reconcile: fund standalone executors: %w", err) + } + } + if err := acceptPendingJobsAndSync(ctx, e, in); err != nil { + return fmt.Errorf("reconcile: accept pending jobs: %w", err) + } + + if opts.effectiveRestartTomlConsumers() { + if err := configureTomlBoundServiceFiles(ctx, in); err != nil { + return fmt.Errorf("reconcile: configure TOML-bound services: %w", err) + } + if err := restartTomlBoundServices(ctx, in); err != nil { + return fmt.Errorf("reconcile: restart toml-bound services: %w", err) + } + } + return nil +} + +type restartable interface { + Restart(context.Context) error +} + +type refreshable interface { + RefreshConfig(context.Context) error +} + +func restartTomlBoundServices(ctx context.Context, in *Cfg) error { + if in == nil { + return nil + } + restartables := make([]restartable, 0, len(in.Aggregator)+len(in.Indexer)+len(in.Executor)+len(in.Verifier)) + for _, service := range in.Aggregator { + if service != nil { + restartables = append(restartables, service) + } + } + for _, service := range in.Indexer { + if service != nil { + restartables = append(restartables, service) + } + } + for _, service := range in.Executor { + if service != nil { + restartables = append(restartables, service) + } + } + for _, service := range in.Verifier { + if service != nil { + restartables = append(restartables, service) + } + } + for _, service := range restartables { + if err := service.Restart(ctx); err != nil { + return err + } + } + return nil +} + +// OpenDeploymentEnvironmentFromCfg builds selectors and a CLDF operations environment from a loaded env-out Cfg. +func OpenDeploymentEnvironmentFromCfg(cfg *Cfg) ([]uint64, *deployment.Environment, error) { + if cfg == nil { + return nil, nil, fmt.Errorf("open deployment environment: cfg is nil") + } + var ( + offchainClient offchain.Client + nodeIDs []string + ) + jdClient, err := jdClientFromCfg(cfg) + if err != nil { + return nil, nil, fmt.Errorf("open deployment environment: %w", err) + } + if jdClient != nil { + offchainClient = jdClient + nodeIDs, err = jdNodeIDs(jdClient) + if err != nil { + return nil, nil, fmt.Errorf("open deployment environment: list JD nodes: %w", err) + } + } + if cfg.ClientLookup == nil { + clAliases := clModeNOPAliases(cfg.EnvironmentTopology) + if len(clAliases) > 0 { + clientLookup, err := jobs.NewNodeSetClientLookup(cfg.NodeSets, clAliases) + if err != nil { + return nil, nil, fmt.Errorf("open deployment environment: build CL client lookup: %w", err) + } + cfg.ClientLookup = clientLookup + } + } + return NewCLDFOperationsEnvironmentWithOffchain(CLDFEnvironmentConfig{ + Blockchains: cfg.Blockchains, + DataStore: cfg.CLDF.DataStore, + OffchainClient: offchainClient, + NodeIDs: nodeIDs, + }) +} + +// ImplConfigurationsFromCfg returns one CCIP17Configuration per blockchain (same order as connectAllChains / NewEnvironment). +func ImplConfigurationsFromCfg(in *Cfg) ([]cciptestinterfaces.CCIP17Configuration, error) { + if in == nil { + return nil, fmt.Errorf("impl configurations: cfg is nil") + } + impls := make([]cciptestinterfaces.CCIP17Configuration, 0, len(in.Blockchains)) + for _, bc := range in.Blockchains { + impl, err := NewProductConfigurationFromNetwork(bc.Type) + if err != nil { + return nil, err + } + impls = append(impls, impl) + } + return impls, nil +} + +func configureOffchainFromTopology( + e *deployment.Environment, + in *Cfg, + topology *ccipOffchain.EnvironmentTopology, + sharedTLSCerts *services.TLSCertPaths, + ds datastore.MutableDataStore, +) (map[string][]bootstrap.JobSpec, error) { + if e == nil || in == nil || topology == nil { + return nil, fmt.Errorf("environment, config, and topology are required") + } + if ds == nil { + ds = datastore.NewMemoryDataStore() + if err := ds.Merge(e.DataStore); err != nil { + return nil, fmt.Errorf("merge initial datastore: %w", err) + } + } + + ResetMemoryOperationsBundle(e) + + for _, aggregatorInput := range in.Aggregator { + if sharedTLSCerts != nil { + aggregatorInput.SharedTLSCerts = sharedTLSCerts + } + e.DataStore = ds.Seal() + instanceName := aggregatorInput.InstanceName() + output, err := ccipChangesets.GenerateAggregatorConfig(ccipAdapters.GetAggregatorConfigRegistry()).Apply(*e, ccipChangesets.GenerateAggregatorConfigInput{ + ServiceIdentifier: instanceName + "-aggregator", + CommitteeQualifier: aggregatorInput.CommitteeName, + Topology: topology, + }) + if err != nil { + return nil, fmt.Errorf("generate aggregator config %s: %w", instanceName, err) + } + aggCfg, err := offchainloader.GetAggregatorConfig(output.DataStore.Seal(), instanceName+"-aggregator") + if err != nil { + return nil, fmt.Errorf("get aggregator config %s: %w", instanceName, err) + } + aggregatorInput.GeneratedCommittee = aggCfg + if err := ds.Merge(output.DataStore.Seal()); err != nil { + return nil, fmt.Errorf("merge aggregator datastore: %w", err) + } + } + + if len(in.Aggregator) > 0 && len(in.Indexer) > 0 { + e.DataStore = ds.Seal() + firstIdx := in.Indexer[0] + output, err := ccipChangesets.GenerateIndexerConfig(ccipAdapters.GetIndexerConfigRegistry()).Apply(*e, ccipChangesets.GenerateIndexerConfigInput{ + ServiceIdentifier: "indexer", + CommitteeVerifierNameToQualifier: firstIdx.CommitteeVerifierNameToQualifier, + CCTPVerifierNameToQualifier: firstIdx.CCTPVerifierNameToQualifier, + LombardVerifierNameToQualifier: firstIdx.LombardVerifierNameToQualifier, + }) + if err != nil { + return nil, fmt.Errorf("generate indexer config: %w", err) + } + idxCfg, err := offchainloader.GetIndexerConfig(output.DataStore.Seal(), "indexer") + if err != nil { + return nil, fmt.Errorf("get indexer config: %w", err) + } + for _, idxIn := range in.Indexer { + idxIn.GeneratedCfg = idxCfg + } + if err := ds.Merge(output.DataStore.Seal()); err != nil { + return nil, fmt.Errorf("merge indexer datastore: %w", err) + } + } + + e.DataStore = ds.Seal() + if err := generateExecutorJobSpecs(e, in, topology, ds); err != nil { + return nil, fmt.Errorf("executor job specs: %w", err) + } + e.DataStore = ds.Seal() + + verifierJobSpecs, err := generateVerifierJobSpecs(e, in, topology, sharedTLSCerts, ds) + if err != nil { + return nil, fmt.Errorf("verifier job specs: %w", err) + } + e.DataStore = ds.Seal() + in.CLDF.DataStore = e.DataStore + + return verifierJobSpecs, nil +} + +func configureTomlBoundServiceFiles(ctx context.Context, in *Cfg) error { + if in == nil { + return nil + } + refreshables := make([]refreshable, 0, len(in.Aggregator)+len(in.Indexer)) + for _, service := range in.Aggregator { + if service != nil { + refreshables = append(refreshables, service) + } + } + for _, service := range in.Indexer { + if service != nil { + refreshables = append(refreshables, service) + } + } + for _, service := range refreshables { + if err := service.RefreshConfig(ctx); err != nil { + return fmt.Errorf("refresh TOML-bound service config: %w", err) + } + } + return nil +} + +func acceptPendingJobsAndSync(ctx context.Context, e *deployment.Environment, in *Cfg) error { + if err := jobs.AcceptPendingJobs(ctx, in.ClientLookup); err != nil { + return err + } + return nil +} + +func clModeNOPAliases(topology *ccipOffchain.EnvironmentTopology) []string { + if topology == nil || topology.NOPTopology == nil { + return nil + } + aliases := make([]string, 0, len(topology.NOPTopology.NOPs)) + for _, nop := range topology.NOPTopology.NOPs { + if nop.GetMode() == shared.NOPModeCL { + alias := nop.Alias + if alias == "" { + alias = nop.Name + } + aliases = append(aliases, alias) + } + } + return aliases +} + +func jdClientFromCfg(cfg *Cfg) (offchain.Client, error) { + if cfg == nil || cfg.JD == nil || cfg.JD.Out == nil { + return nil, nil + } + client, err := cldfjd.NewJDClient(cldfjd.JDConfig{ + GRPC: cfg.JD.Out.ExternalGRPCUrl, + WSRPC: cfg.JD.Out.ExternalWSRPCUrl, + Creds: insecure.NewCredentials(), + }) + if err != nil { + return nil, fmt.Errorf("create JD client from env-out: %w", err) + } + return client, nil +} + +func jdNodeIDs(client offchain.Client) ([]string, error) { + if client == nil { + return nil, nil + } + resp, err := client.ListNodes(context.Background(), &nodev1.ListNodesRequest{}) + if err != nil { + return nil, err + } + nodeIDs := make([]string, 0, len(resp.Nodes)) + for _, node := range resp.Nodes { + if node != nil && node.Id != "" { + nodeIDs = append(nodeIDs, node.Id) + } + } + return nodeIDs, nil +} diff --git a/build/devenv/reconcile_onchain.go b/build/devenv/reconcile_onchain.go new file mode 100644 index 000000000..04555a618 --- /dev/null +++ b/build/devenv/reconcile_onchain.go @@ -0,0 +1,148 @@ +package ccv + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/committee_verifier" + changesetscore "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" + ccipAdapters "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/adapters" + ccipChangesets "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/changesets" + ccipOffchain "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/offchain" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" +) + +// CommitteeRemotePatches maps local chain selector -> remote chain selector -> committee verifier remote patch. +type CommitteeRemotePatches map[uint64]map[uint64]ccipChangesets.CommitteeVerifierRemoteChainConfig + +// ReconfigureLanesParams configures reconfigureLanesFromTopology. Zero value is valid: production Router lanes +// (UseTestRouter false), no committee patches. To use TestRouter on a chain, set +// TestRouterByLane[localSelector][remoteSelector] = true; the deployment must include a TestRouter contract +// (DeployChainContractsPerChainCfg.DeployTestRouter) or the changeset apply can fail. +type ReconfigureLanesParams struct { + CommitteePatches CommitteeRemotePatches + TestRouterByLane map[uint64]map[uint64]bool +} + +// CommitteeRemotePatchesFromAllowlistArgs builds patches for one local chain from operation-style allowlist args. +func CommitteeRemotePatchesFromAllowlistArgs( + localSelector uint64, + args []committee_verifier.AllowlistConfigArgs, +) CommitteeRemotePatches { + if len(args) == 0 { + return nil + } + inner := make(map[uint64]ccipChangesets.CommitteeVerifierRemoteChainConfig, len(args)) + for _, a := range args { + added := make([]string, len(a.AddedAllowlistedSenders)) + for i, addr := range a.AddedAllowlistedSenders { + added[i] = addr.Hex() + } + removed := make([]string, len(a.RemovedAllowlistedSenders)) + for i, addr := range a.RemovedAllowlistedSenders { + removed[i] = addr.Hex() + } + allow := a.AllowlistEnabled + inner[a.DestChainSelector] = ccipChangesets.CommitteeVerifierRemoteChainConfig{ + AllowlistEnabled: &allow, + AddedAllowlistedSenders: added, + RemovedAllowlistedSenders: removed, + } + } + return CommitteeRemotePatches{localSelector: inner} +} + +// ResetMemoryOperationsBundle replaces the environment operations bundle with a fresh in-memory reporter. +func ResetMemoryOperationsBundle(e *deployment.Environment) { + if e == nil { + return + } + e.OperationsBundle = operations.NewBundle( + e.GetContext, + e.Logger, + operations.NewMemoryReporter(), + ) +} + +// reconfigureLanesFromTopology runs ConfigureChainsForLanesFromTopology once with a PartialChainConfig per chain +// (full peer mesh). Selector-to-impl mapping matches connectAllChains: impls[i] with blockchains[i], +// chain selector from chain ID + ChainFamily(), GetChainLaneProfile(env, sel) on that impl. +func reconfigureLanesFromTopology( + ctx context.Context, + e *deployment.Environment, + topology *ccipOffchain.EnvironmentTopology, + selectors []uint64, + blockchains []*blockchain.Input, + impls []cciptestinterfaces.CCIP17Configuration, + params ReconfigureLanesParams, +) error { + if e == nil || topology == nil { + return fmt.Errorf("reconfigure lanes: environment and topology are required") + } + if len(selectors) == 0 { + return fmt.Errorf("reconfigure lanes: selectors is required") + } + if len(blockchains) != len(impls) { + return fmt.Errorf("reconfigure lanes: %d blockchains and %d impls", len(blockchains), len(impls)) + } + for _, sel := range selectors { + if !e.BlockChains.Exists(sel) { + return fmt.Errorf("reconfigure lanes: chain selector %d not in environment", sel) + } + } + + orderedSelectors, profiles, err := buildConnectionProfilesFromImpls(impls, blockchains, selectors, e) + if err != nil { + return fmt.Errorf("reconfigure lanes: %w", err) + } + if err := assertConnectionProfilesCoverSelectors(orderedSelectors, selectors); err != nil { + return err + } + + chains, useTestRouter, err := buildPartialChainConfigsFromProfiles(topology, orderedSelectors, profiles, params) + if err != nil { + return fmt.Errorf("reconfigure lanes: %w", err) + } + + ResetMemoryOperationsBundle(e) + e.OperationsBundle = operations.NewBundle( + func() context.Context { return ctx }, + e.Logger, + operations.NewMemoryReporter(), + ) + + out, err := ccipChangesets.ConfigureChainsForLanesFromTopology( + ccipAdapters.GetCommitteeVerifierContractRegistry(), + ccipAdapters.GetChainFamilyRegistry(), + changesetscore.GetRegistry(), + ).Apply(*e, ccipChangesets.ConfigureChainsForLanesFromTopologyConfig{ + Topology: topology, + Chains: chains, + UseTestRouter: useTestRouter, + }) + if err != nil { + return err + } + if out.DataStore != nil { + mds := datastore.NewMemoryDataStore() + if err := mds.Merge(e.DataStore); err != nil { + return fmt.Errorf("reconfigure lanes: merge env datastore: %w", err) + } + if err := mds.Merge(out.DataStore.Seal()); err != nil { + return fmt.Errorf("reconfigure lanes: merge lane changeset datastore: %w", err) + } + e.DataStore = mds.Seal() + } + + for _, sel := range orderedSelectors { + entry := profiles[sel] + if err := entry.impl.PostConnect(e, sel, entry.remotes); err != nil { + return fmt.Errorf("reconfigure lanes: post-connect for chain %d: %w", sel, err) + } + } + return nil +} diff --git a/build/devenv/services/aggregator.go b/build/devenv/services/aggregator.go index a14525e5a..0f07a521d 100644 --- a/build/devenv/services/aggregator.go +++ b/build/devenv/services/aggregator.go @@ -47,6 +47,11 @@ const ( DefaultAggregatorGRPCPort = 50051 ) +// AggregatorGeneratedConfigContainerPath is the committee TOML path inside the aggregator container (matches NewAggregator ContainerFile). +func AggregatorGeneratedConfigContainerPath(instanceName string) string { + return "/etc/aggregator-" + instanceName + "-generated.toml" +} + type AggregatorDBInput struct { Image string `toml:"image"` // HostPort is the port on the host machine that the database will be exposed on. @@ -303,6 +308,54 @@ func (a *AggregatorInput) GenerateConfigs(generatedConfigFileName string) (*Gene }, nil } +// ConfigureGeneratedConfigFile persists only aggregator-*-generated.toml (committee). +// Main config stays the file from NewAggregator. +func (a *AggregatorInput) ConfigureGeneratedConfigFile() error { + if a == nil { + return nil + } + if a.GeneratedCommittee == nil { + return fmt.Errorf("ConfigureGeneratedConfigFile: GeneratedCommittee is required") + } + confDir := util.CCVConfigDir() + instanceName := a.InstanceName() + generatedConfigFileName := fmt.Sprintf("aggregator-%s-generated.toml", instanceName) + configResult, err := a.GenerateConfigs(generatedConfigFileName) + if err != nil { + return fmt.Errorf("write aggregator config files: generate: %w", err) + } + generatedConfigFilePath := filepath.Join(confDir, generatedConfigFileName) + if err := os.WriteFile(generatedConfigFilePath, configResult.GeneratedConfig, 0o644); err != nil { + return fmt.Errorf("write aggregator generated config: %w", err) + } + return nil +} + +// RefreshConfig refreshes the generated committee TOML on disk and syncs it into the running container. +func (a *AggregatorInput) RefreshConfig(ctx context.Context) error { + if a == nil { + return nil + } + if err := a.ConfigureGeneratedConfigFile(); err != nil { + return err + } + instanceName := a.InstanceName() + hostPath := filepath.Join(util.CCVConfigDir(), fmt.Sprintf("aggregator-%s-generated.toml", instanceName)) + containerName := fmt.Sprintf("%s-%s", instanceName, AggregatorContainerNameSuffix) + if err := DockerCopyFileToContainer(ctx, hostPath, containerName, AggregatorGeneratedConfigContainerPath(instanceName)); err != nil { + return fmt.Errorf("refresh aggregator generated config: %w", err) + } + return nil +} + +// Restart restarts the running aggregator container. +func (a *AggregatorInput) Restart(ctx context.Context) error { + if a == nil { + return nil + } + return RestartContainer(ctx, fmt.Sprintf("%s-%s", a.InstanceName(), AggregatorContainerNameSuffix)) +} + func (a *AggregatorInput) GetAPIKeys() ([]AggregatorClientConfig, error) { apiKeyConfigs := make([]AggregatorClientConfig, 0, len(a.APIClients)) for _, client := range a.APIClients { diff --git a/build/devenv/services/committeeverifier/base.go b/build/devenv/services/committeeverifier/base.go index a2256c691..de7923e18 100644 --- a/build/devenv/services/committeeverifier/base.go +++ b/build/devenv/services/committeeverifier/base.go @@ -149,6 +149,27 @@ type Output struct { JDNodeID string `toml:"jd_node_id"` } +func (in *Input) DockerContainerName() string { + if in == nil { + return "" + } + if in.Out != nil && in.Out.ContainerName != "" { + return services.NormalizeDockerContainerName(in.Out.ContainerName) + } + if in.ChainFamily == chainsel.FamilyEVM { + return fmt.Sprintf("evm-%s", in.ContainerName) + } + return services.NormalizeDockerContainerName(in.ContainerName) +} + +// Restart restarts the running verifier container. +func (in *Input) Restart(ctx context.Context) error { + if in == nil || in.Mode != services.Standalone { + return nil + } + return services.RestartContainer(ctx, in.DockerContainerName()) +} + func ApplyDefaults(in Input) Input { if in.Image == "" { in.Image = DefaultVerifierImage diff --git a/build/devenv/services/docker.go b/build/devenv/services/docker.go new file mode 100644 index 000000000..433a99e3a --- /dev/null +++ b/build/devenv/services/docker.go @@ -0,0 +1,42 @@ +package services + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// NormalizeDockerContainerName strips leading slash from Docker inspect names so CLI calls match the container. +func NormalizeDockerContainerName(name string) string { + return strings.TrimPrefix(strings.TrimSpace(name), "/") +} + +// DockerCopyFileToContainer copies a host file into a running container path. +func DockerCopyFileToContainer(ctx context.Context, hostPath, containerName, containerPath string) error { + containerName = NormalizeDockerContainerName(containerName) + if hostPath == "" || containerName == "" || containerPath == "" { + return nil + } + dest := containerName + ":" + containerPath + cmd := exec.CommandContext(ctx, "docker", "cp", hostPath, dest) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("docker cp %s -> %s: %w: %s", hostPath, dest, err, out) + } + return nil +} + +// RestartContainer restarts a running Docker container by name. +func RestartContainer(ctx context.Context, name string) error { + name = NormalizeDockerContainerName(name) + if name == "" { + return nil + } + cmd := exec.CommandContext(ctx, "docker", "restart", name) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("docker restart %s: %w: %s", name, err, out) + } + return nil +} diff --git a/build/devenv/services/executor.go b/build/devenv/services/executor.go index 7c556e6db..97d91ca3c 100644 --- a/build/devenv/services/executor.go +++ b/build/devenv/services/executor.go @@ -85,6 +85,39 @@ func (v *ExecutorInput) GenerateConfigWithBlockchainInfos(blockchainInfos chaina return cfg, nil } +// ConfigureConfigFile persists the current standalone executor config and returns the host path. +func (v *ExecutorInput) ConfigureConfigFile(blockchainOutputs []*ctfblockchain.Output) (string, error) { + if v == nil { + return "", nil + } + ApplyExecutorDefaults(v) + + blockchainInfos, err := ConvertBlockchainOutputsToInfo(blockchainOutputs) + if err != nil { + return "", fmt.Errorf("failed to generate blockchain infos from blockchain outputs: %w", err) + } + blockchainInfos = filterOutUnsupportedChains(blockchainInfos) + + config, err := v.GenerateConfigWithBlockchainInfos(blockchainInfos) + if err != nil { + return "", fmt.Errorf("failed to generate config for executor: %w", err) + } + confDir := util.CCVConfigDir() + configFilePath := filepath.Join(confDir, fmt.Sprintf("executor-%s-config.toml", v.ContainerName)) + if err := os.WriteFile(configFilePath, config, 0o644); err != nil { + return "", fmt.Errorf("failed to write executor config to file: %w", err) + } + return configFilePath, nil +} + +// Restart restarts the running executor container. +func (v *ExecutorInput) Restart(ctx context.Context) error { + if v == nil || v.Mode != Standalone { + return nil + } + return RestartContainer(ctx, v.ContainerName) +} + // TransmitterAddressResolver derives an on-chain address from a hex-encoded private key. type TransmitterAddressResolver func(privateKeyHex string) (protocol.UnknownAddress, error) @@ -140,24 +173,9 @@ func NewExecutor(in *ExecutorInput, blockchainOutputs []*ctfblockchain.Output) ( if err != nil { return in.Out, err } - - // Generate blockchain infos for standalone mode - blockchainInfos, err := ConvertBlockchainOutputsToInfo(blockchainOutputs) - if err != nil { - return nil, fmt.Errorf("failed to generate blockchain infos from blockchain outputs: %w", err) - } - - blockchainInfos = filterOutUnsupportedChains(blockchainInfos) - - // Generate and store config file with blockchain infos for standalone mode - config, err := in.GenerateConfigWithBlockchainInfos(blockchainInfos) + configFilePath, err := in.ConfigureConfigFile(blockchainOutputs) if err != nil { - return nil, fmt.Errorf("failed to generate config for executor: %w", err) - } - confDir := util.CCVConfigDir() - configFilePath := filepath.Join(confDir, fmt.Sprintf("executor-%s-config.toml", in.ContainerName)) - if err := os.WriteFile(configFilePath, config, 0o644); err != nil { - return nil, fmt.Errorf("failed to write executor config to file: %w", err) + return nil, err } /* Service */ @@ -224,6 +242,9 @@ type TransmitterKeyGenerator func() (string, error) // using the given key generator. func SetTransmitterPrivateKey(execs []*ExecutorInput, keyGen TransmitterKeyGenerator) ([]*ExecutorInput, error) { for _, exec := range execs { + if exec.TransmitterPrivateKey != "" { + continue + } pk, err := keyGen() if err != nil { return nil, fmt.Errorf("failed to generate transmitter private key: %w", err) diff --git a/build/devenv/services/executor/base.go b/build/devenv/services/executor/base.go index 6e7393605..29dcbf005 100644 --- a/build/devenv/services/executor/base.go +++ b/build/devenv/services/executor/base.go @@ -67,7 +67,7 @@ type Input struct { GeneratedConfig string `toml:"-"` // GeneratedJobSpecs contains all job specs for this executor. - GeneratedJobSpecs []string `toml:"-"` + GeneratedJobSpecs []bootstrap.JobSpec `toml:"-"` // Bootstrap is the bootstrap configuration for bootstrapped mode. Bootstrap *services.BootstrapInput `toml:"bootstrap"` @@ -97,9 +97,13 @@ type Output struct { JDNodeID string `toml:"jd_node_id"` } -// RebuildExecutorJobSpecWithBlockchainInfos takes a job spec and rebuilds it with blockchain infos -// added to the inner config. This is needed for standalone executors which require blockchain -// connection information (CL nodes get this from their own chain config). +func (v *Input) Restart(ctx context.Context) error { + if v == nil || v.Mode != services.Standalone { + return nil + } + return services.RestartContainer(ctx, v.ContainerName) +} + func (v *Input) RebuildExecutorJobSpecWithBlockchainInfos(spec bootstrap.JobSpec, blockchainInfos map[string]any) (string, error) { var cfg executorpkg.Configuration if _, err := toml.Decode(spec.AppConfig, &cfg); err != nil { @@ -566,6 +570,9 @@ type TransmitterAddressResolver func(privateKeyHex string) (protocol.UnknownAddr // using the given key generator. Pass a family-specific generator from ImplFactory. func SetTransmitterPrivateKey(execs []*Input, keyGen TransmitterKeyGenerator) ([]*Input, error) { for _, exec := range execs { + if exec.TransmitterPrivateKey != "" { + continue + } pk, err := keyGen() if err != nil { return nil, fmt.Errorf("failed to generate transmitter private key: %w", err) diff --git a/build/devenv/services/indexer.go b/build/devenv/services/indexer.go index 3795b8a91..cf9f5d0d2 100644 --- a/build/devenv/services/indexer.go +++ b/build/devenv/services/indexer.go @@ -118,32 +118,21 @@ func injectPostgresURI(cfg *config.Config, uri string) { cfg.Storage.Single.Postgres.URI = uri } -// NewIndexer creates and starts a new Service container using testcontainers. -// Will be called once per indexer instance. -func NewIndexer(in *IndexerInput) (*IndexerOutput, error) { +// ConfigureConfigFiles persists the current indexer config, generated config, and secrets +// using the same paths NewIndexer mounts into the running container. +func (in *IndexerInput) ConfigureConfigFiles() (string, string, string, error) { if in == nil { - return nil, nil - } - if in.Out != nil && in.Out.UseCache { - return in.Out, nil + return "", "", "", nil } - ctx := context.Background() defaults(in) if in.GeneratedCfg == nil { - return nil, fmt.Errorf("GeneratedCfg is required for indexer") + return "", "", "", fmt.Errorf("GeneratedCfg is required for indexer") } - if in.IndexerConfig == nil { - return nil, fmt.Errorf("IndexerConfig is required for indexer") + return "", "", "", fmt.Errorf("IndexerConfig is required for indexer") } - p, err := CwdSourcePath(in.SourceCodePath) - if err != nil { - return in.Out, err - } - - // Per-instance config dir and filenames (supports multiple indexers, like NewAggregator per committee). confDir := util.CCVConfigDir() configFileName := fmt.Sprintf("indexer-%s-config.toml", in.ContainerName) generatedConfigFileName := "generated.toml" @@ -155,7 +144,6 @@ func NewIndexer(in *IndexerInput) (*IndexerOutput, error) { in.IndexerConfig.GeneratedConfigPath = generatedConfigFileName - // Per-instance DB credentials (from config or derived from container name for multi-instance isolation). dbName := in.DB.Database if dbName == "" { dbName = in.ContainerName @@ -169,7 +157,6 @@ func NewIndexer(in *IndexerInput) (*IndexerOutput, error) { dbPass = in.ContainerName } - // DB connection string: from config (StorageConnectionURL) when set, else build from DB/container (aligned with aggregator). dbContainerName := in.ContainerName + IndexerDBContainerSuffix var dbConnectionString string if in.StorageConnectionURL != "" { @@ -183,22 +170,21 @@ func NewIndexer(in *IndexerInput) (*IndexerOutput, error) { buff := new(bytes.Buffer) encoder := toml.NewEncoder(buff) encoder.Indent = "" - err = encoder.Encode(in.IndexerConfig) - if err != nil { - return nil, fmt.Errorf("failed to encode config: %w", err) + if err := encoder.Encode(in.IndexerConfig); err != nil { + return "", "", "", fmt.Errorf("failed to encode config: %w", err) } if err := os.WriteFile(configPath, buff.Bytes(), 0o644); err != nil { - return nil, fmt.Errorf("failed to write config: %w", err) + return "", "", "", fmt.Errorf("failed to write config: %w", err) } genBuff := new(bytes.Buffer) genEncoder := toml.NewEncoder(genBuff) genEncoder.Indent = "" if err := genEncoder.Encode(in.GeneratedCfg); err != nil { - return nil, fmt.Errorf("failed to encode generated config: %w", err) + return "", "", "", fmt.Errorf("failed to encode generated config: %w", err) } if err := os.WriteFile(generatedConfigPath, genBuff.Bytes(), 0o644); err != nil { - return nil, fmt.Errorf("failed to write generated config: %w", err) + return "", "", "", fmt.Errorf("failed to write generated config: %w", err) } secretsToEncode := in.Secrets @@ -209,14 +195,87 @@ func NewIndexer(in *IndexerInput) (*IndexerOutput, error) { secEncoder := toml.NewEncoder(secretsBuffer) secEncoder.Indent = "" if err := secEncoder.Encode(secretsToEncode); err != nil { - return nil, fmt.Errorf("failed to encode secrets: %w", err) + return "", "", "", fmt.Errorf("failed to encode secrets: %w", err) } if err := os.WriteFile(secretsPath, secretsBuffer.Bytes(), 0o644); err != nil { - return nil, fmt.Errorf("failed to write secrets file: %w", err) + return "", "", "", fmt.Errorf("failed to write secrets file: %w", err) + } + + return configPath, generatedConfigPath, secretsPath, nil +} + +// RefreshConfig rewrites the mounted indexer config files from the current in-memory config. +func (in *IndexerInput) RefreshConfig(context.Context) error { + if in == nil { + return nil + } + _, _, _, err := in.ConfigureConfigFiles() + return err +} + +// Restart restarts the running indexer container. +func (in *IndexerInput) Restart(ctx context.Context) error { + if in == nil { + return nil + } + name := in.ContainerName + if in.Out != nil && in.Out.ContainerName != "" { + name = in.Out.ContainerName + } + return RestartContainer(ctx, name) +} + +// NewIndexer creates and starts a new Service container using testcontainers. +// Will be called once per indexer instance. +func NewIndexer(in *IndexerInput) (*IndexerOutput, error) { + if in == nil { + return nil, nil + } + if in.Out != nil && in.Out.UseCache { + return in.Out, nil + } + ctx := context.Background() + defaults(in) + + if in.GeneratedCfg == nil { + return nil, fmt.Errorf("GeneratedCfg is required for indexer") + } + + if in.IndexerConfig == nil { + return nil, fmt.Errorf("IndexerConfig is required for indexer") + } + + p, err := CwdSourcePath(in.SourceCodePath) + if err != nil { + return in.Out, err + } + configPath, generatedConfigPath, secretsPath, err := in.ConfigureConfigFiles() + if err != nil { + return nil, err } // Database: unique name and host port per instance (like aggregator DB per committee). // one db instance per indexer. + dbName := in.DB.Database + if dbName == "" { + dbName = in.ContainerName + } + dbUser := in.DB.Username + if dbUser == "" { + dbUser = in.ContainerName + } + dbPass := in.DB.Password + if dbPass == "" { + dbPass = in.ContainerName + } + dbContainerName := in.ContainerName + IndexerDBContainerSuffix + var dbConnectionString string + if in.StorageConnectionURL != "" { + dbConnectionString = in.StorageConnectionURL + } else { + dbConnectionString = fmt.Sprintf("postgresql://%s:%s@%s:5432/%s?sslmode=disable", + dbUser, dbPass, dbContainerName, dbName) + } _, err = postgres.Run(ctx, in.DB.Image, testcontainers.WithName(dbContainerName), @@ -252,6 +311,7 @@ func NewIndexer(in *IndexerInput) (*IndexerOutput, error) { internalPortStr := strconv.Itoa(internalPort) // Container paths for mounted config (same path in every container; each has its own files). + generatedConfigFileName := "generated.toml" containerConfigPath := filepath.Join(IndexerConfigDirContainer, "config.toml") containerGeneratedPath := filepath.Join(IndexerConfigDirContainer, generatedConfigFileName) containerSecretsPath := filepath.Join(IndexerConfigDirContainer, "secrets.toml") diff --git a/build/devenv/tests/e2e/environment_change_reconcile_test.go b/build/devenv/tests/e2e/environment_change_reconcile_test.go new file mode 100644 index 000000000..f48d19f1c --- /dev/null +++ b/build/devenv/tests/e2e/environment_change_reconcile_test.go @@ -0,0 +1,566 @@ +package e2e + +import ( + "context" + "errors" + "fmt" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/BurntSushi/toml" + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + chain_selectors "github.com/smartcontractkit/chain-selectors" + routeroperations "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_2_0/operations/router" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/committee_verifier" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/proxy" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/versioned_verifier_resolver" + ccipOffchain "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/offchain" + "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/offchain/operations/fetch_signing_keys" + ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" + devenvcommon "github.com/smartcontractkit/chainlink-ccv/build/devenv/common" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/tests/e2e/tcapi" + ccvcomm "github.com/smartcontractkit/chainlink-ccv/common" + "github.com/smartcontractkit/chainlink-ccv/common/committee" + "github.com/smartcontractkit/chainlink-ccv/protocol" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +func environmentChangeSmokeConfigPath() string { + if p := os.Getenv("ENVIRONMENT_CHANGE_SMOKE_TEST_CONFIG"); p != "" { + return p + } + return GetSmokeTestConfig() +} + +func requireEnvironmentChangeEnvFile(t *testing.T) string { + t.Helper() + path := environmentChangeSmokeConfigPath() + if _, err := os.Stat(path); err != nil { + t.Skipf("environment change E2E requires env-out (missing %q: %v); run ccv up and set ENVIRONMENT_CHANGE_SMOKE_TEST_CONFIG or use SMOKE_TEST_CONFIG via GetSmokeTestConfig", path, err) + } + return path +} + +type environmentChangeReconcileHarness struct { + Cfg *ccv.Cfg + Selectors []uint64 + Env *deployment.Environment + Topology *ccipOffchain.EnvironmentTopology + Impls []cciptestinterfaces.CCIP17Configuration +} + +const ( + environmentChangeAssertMessageTimeout = 4 * time.Minute + environmentChangePostMessageExecTimeout = 2 * time.Minute +) + +var errEnvironmentChangeEOADefaultVerifierPrerequisites = errors.New("EOA default verifier message prerequisites not met") + +func newEnvironmentChangeReconcileHarness(t *testing.T) *environmentChangeReconcileHarness { + t.Helper() + path := requireEnvironmentChangeEnvFile(t) + cfg, err := ccv.LoadOutput[ccv.Cfg](path) + require.NoError(t, err) + if err := ccv.RequireFullCLModeForEnvironmentChangeReconcile(cfg); err != nil { + t.Skipf("environment change reconcile E2E requires full CL mode (topology NOPs cl, verifier/executor mode cl, nodesets): %v; use a CL env-out or set ENVIRONMENT_CHANGE_SMOKE_TEST_CONFIG", err) + } + selectors, env, err := ccv.OpenDeploymentEnvironmentFromCfg(cfg) + require.NoError(t, err) + require.NotEmpty(t, selectors) + topology := ccv.BuildEnvironmentTopology(cfg, env) + require.NotNil(t, topology) + impls, err := ccv.ImplConfigurationsFromCfg(cfg) + require.NoError(t, err) + require.Len(t, impls, len(cfg.Blockchains)) + return &environmentChangeReconcileHarness{ + Cfg: cfg, + Selectors: selectors, + Env: env, + Topology: topology, + Impls: impls, + } +} + +func testRouterDeployedOnSelector(t *testing.T, env *deployment.Environment, selector uint64) bool { + t.Helper() + _, err := env.DataStore.Addresses().Get(datastore.NewAddressRefKey( + selector, + datastore.ContractType(routeroperations.TestRouterContractType), + semver.MustParse(routeroperations.DeployTestRouter.Version()), + "", + )) + return err == nil +} + +func environmentChangeLinkedLongContext(t *testing.T) context.Context { + t.Helper() + base := t.Context() + longCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + go func() { + <-base.Done() + cancel() + }() + t.Cleanup(cancel) + return longCtx +} + +func environmentChangeReconcileOpts() ccv.ConfigureOffchainOptions { + return ccv.ConfigureOffchainOptions{FundExecutors: false} +} + +func requireEnvironmentChangeReconcile(t *testing.T, ctx context.Context, h *environmentChangeReconcileHarness, lane ccv.ReconfigureLanesParams) { + t.Helper() + require.NoError(t, ccv.ConfigureTopologyLanesAndOffchain( + ctx, h.Env, h.Cfg, h.Topology, h.Selectors, h.Cfg.Blockchains, h.Impls, lane, nil, environmentChangeReconcileOpts(), + )) +} + +func requireNOPSigningKeyFromJD(t *testing.T, h *environmentChangeReconcileHarness, nopAlias string) string { + t.Helper() + require.NotNil(t, h.Env) + require.NotNil(t, h.Env.Offchain) + + report, err := operations.ExecuteOperation( + h.Env.OperationsBundle, + fetch_signing_keys.FetchNOPSigningKeys, + fetch_signing_keys.FetchSigningKeysDeps{ + JDClient: h.Env.Offchain, + Logger: h.Env.Logger, + NodeIDs: h.Env.NodeIDs, + }, + fetch_signing_keys.FetchSigningKeysInput{NOPAliases: []string{nopAlias}}, + ) + require.NoError(t, err) + + signersByFamily, ok := report.Output.SigningKeysByNOP[nopAlias] + require.True(t, ok, "NOP %q needs signing keys from JD", nopAlias) + + evmSigner := signersByFamily[chain_selectors.FamilyEVM] + require.NotEmpty(t, evmSigner, "NOP %q needs EVM signer from JD", nopAlias) + return evmSigner +} + +type environmentChangeEOADefaultVerifierInputs struct { + receiver protocol.UnknownAddress + ccvs []protocol.CCV + executor protocol.UnknownAddress +} + +func environmentChangeEOADefaultVerifierConfig(cfg *ccv.Cfg, src, dest cciptestinterfaces.CCIP17) (*environmentChangeEOADefaultVerifierInputs, error) { + receiver, err := dest.GetEOAReceiverAddress() + if err != nil { + return nil, err + } + ccvAddr, err := tcapi.GetContractAddress( + cfg, + src.ChainSelector(), + datastore.ContractType(versioned_verifier_resolver.CommitteeVerifierResolverType), + versioned_verifier_resolver.Version.String(), + devenvcommon.DefaultCommitteeVerifierQualifier, + "committee verifier proxy", + ) + if err != nil { + return nil, err + } + executorAddr, err := tcapi.GetContractAddress( + cfg, + src.ChainSelector(), + datastore.ContractType(sequences.ExecutorProxyType), + proxy.Deploy.Version(), + devenvcommon.DefaultExecutorQualifier, + "executor", + ) + if err != nil { + return nil, err + } + return &environmentChangeEOADefaultVerifierInputs{ + receiver: receiver, + ccvs: []protocol.CCV{{CCVAddress: ccvAddr, Args: []byte{}, ArgsLen: 0}}, + executor: executorAddr, + }, nil +} + +func runEnvironmentChangeEOADefaultVerifierWithIndexedResult( + ctx context.Context, + harness tcapi.TestHarness, + cfg *ccv.Cfg, + src, dest cciptestinterfaces.CCIP17, + useTestRouter bool, +) (tcapi.AssertionResult, error) { + inputs, err := environmentChangeEOADefaultVerifierConfig(cfg, src, dest) + if err != nil { + return tcapi.AssertionResult{}, fmt.Errorf("%w: %w", errEnvironmentChangeEOADefaultVerifierPrerequisites, err) + } + + seqNo, err := src.GetExpectedNextSequenceNumber(ctx, dest.ChainSelector()) + if err != nil { + return tcapi.AssertionResult{}, fmt.Errorf("failed to get expected next sequence number: %w", err) + } + sendMessageResult, err := src.SendMessage( + ctx, dest.ChainSelector(), cciptestinterfaces.MessageFields{ + Receiver: inputs.receiver, + Data: []byte("multi-verifier test"), + }, cciptestinterfaces.MessageOptions{ + Version: 3, + ExecutionGasLimit: 200_000, + FinalityConfig: 1, + Executor: inputs.executor, + CCVs: inputs.ccvs, + UseTestRouter: useTestRouter, + }, + ) + if err != nil { + return tcapi.AssertionResult{}, fmt.Errorf("failed to send message: %w", err) + } + if len(sendMessageResult.ReceiptIssuers) != 3 { + return tcapi.AssertionResult{}, fmt.Errorf("expected 3 receipt issuers, got %d", len(sendMessageResult.ReceiptIssuers)) + } + sentEvent, err := src.WaitOneSentEventBySeqNo(ctx, dest.ChainSelector(), seqNo, tcapi.DefaultSentTimeout) + if err != nil { + return tcapi.AssertionResult{}, fmt.Errorf("failed to wait for sent event: %w", err) + } + aggregatorClient := harness.AggregatorClients[devenvcommon.DefaultCommitteeVerifierQualifier] + chainMap, err := harness.Lib.ChainsMap(ctx) + if err != nil { + return tcapi.AssertionResult{}, fmt.Errorf("failed to get chains map: %w", err) + } + testCtx, cleanup := tcapi.NewTestingContext(ctx, chainMap, aggregatorClient, harness.IndexerMonitor) + defer cleanup() + + result, err := testCtx.AssertMessage(sentEvent.MessageID, tcapi.AssertMessageOptions{ + TickInterval: time.Second, + ExpectedVerifierResults: 1, + Timeout: environmentChangeAssertMessageTimeout, + AssertVerifierLogs: false, + AssertExecutorLogs: false, + }) + if err != nil { + return result, fmt.Errorf("failed to assert message: %w", err) + } + if result.AggregatedResult == nil { + return result, fmt.Errorf("aggregated result is nil") + } + if len(result.IndexedVerifications.Results) != 1 { + return result, fmt.Errorf("expected 1 indexed verification, got %d", len(result.IndexedVerifications.Results)) + } + e, err := chainMap[dest.ChainSelector()].WaitOneExecEventBySeqNo(ctx, src.ChainSelector(), seqNo, environmentChangePostMessageExecTimeout) + if err != nil { + return result, fmt.Errorf("failed to wait for exec event: %w", err) + } + if e.State != cciptestinterfaces.ExecutionStateSuccess { + return result, fmt.Errorf("expected execution state success, got %s", e.State) + } + return result, nil +} + +func twoDistinctEVMSelectorsFromHarness(t *testing.T, h *environmentChangeReconcileHarness) (srcSel, destSel uint64) { + t.Helper() + require.GreaterOrEqual(t, len(h.Cfg.Blockchains), 2, "need at least two chains") + for _, bc := range h.Cfg.Blockchains { + if bc.Out == nil || bc.Out.Family != chain_selectors.FamilyEVM { + continue + } + d, err := chain_selectors.GetChainDetailsByChainIDAndFamily(bc.ChainID, bc.Out.Family) + require.NoError(t, err) + if srcSel == 0 { + srcSel = d.ChainSelector + continue + } + if d.ChainSelector != srcSel { + destSel = d.ChainSelector + break + } + } + require.NotZero(t, destSel, "need two distinct EVM chain selectors") + return srcSel, destSel +} + +func reconfigureEnvironmentChangeCommitteeAllowlist(t *testing.T, h *environmentChangeReconcileHarness, srcSel uint64, args []committee_verifier.AllowlistConfigArgs) { + t.Helper() + ctx := ccv.Plog.WithContext(t.Context()) + patches := ccv.CommitteeRemotePatchesFromAllowlistArgs(srcSel, args) + requireEnvironmentChangeReconcile(t, ctx, h, ccv.ReconfigureLanesParams{CommitteePatches: patches}) +} + +func reconfigureEnvironmentChangeAllowlistEnabled(t *testing.T, h *environmentChangeReconcileHarness, srcSel, destSel uint64, sender common.Address) { + t.Helper() + reconfigureEnvironmentChangeCommitteeAllowlist(t, h, srcSel, []committee_verifier.AllowlistConfigArgs{ + { + DestChainSelector: destSel, + AllowlistEnabled: true, + AddedAllowlistedSenders: []common.Address{sender}, + }, + }) +} + +func reconfigureEnvironmentChangeAllowlistDisabled(t *testing.T, h *environmentChangeReconcileHarness, srcSel, destSel uint64, sender common.Address) { + t.Helper() + reconfigureEnvironmentChangeCommitteeAllowlist(t, h, srcSel, []committee_verifier.AllowlistConfigArgs{ + { + DestChainSelector: destSel, + AllowlistEnabled: false, + RemovedAllowlistedSenders: []common.Address{sender}, + }, + }) +} + +func ccip17PairForSelectors(t *testing.T, ctx context.Context, lib *ccv.Lib, srcSel, destSel uint64) (src, dest cciptestinterfaces.CCIP17) { + t.Helper() + chains, err := lib.Chains(ctx) + require.NoError(t, err) + for _, c := range chains { + if c.Details.ChainSelector == srcSel { + src = c.CCIP17 + } + if c.Details.ChainSelector == destSel { + dest = c.CCIP17 + } + } + require.NotNil(t, src, "no CCIP17 impl for source selector %d", srcSel) + require.NotNil(t, dest, "no CCIP17 impl for dest selector %d", destSel) + return src, dest +} + +func requireEnvironmentChangeEOADefaultVerifierMessage(t *testing.T, ctx context.Context, th tcapi.TestHarness, cfg *ccv.Cfg, src, dest cciptestinterfaces.CCIP17) { + t.Helper() + _, err := runEnvironmentChangeEOADefaultVerifierWithIndexedResult(ctx, th, cfg, src, dest, false) + if errors.Is(err, errEnvironmentChangeEOADefaultVerifierPrerequisites) { + t.Skip("EOA default verifier message prerequisites not met for this env") + } + require.NoError(t, err) +} + +func requireEnvironmentChangeEOADefaultVerifierMessageExpectError(t *testing.T, ctx context.Context, th tcapi.TestHarness, cfg *ccv.Cfg, src, dest cciptestinterfaces.CCIP17) { + t.Helper() + _, err := runEnvironmentChangeEOADefaultVerifierWithIndexedResult(ctx, th, cfg, src, dest, false) + if errors.Is(err, errEnvironmentChangeEOADefaultVerifierPrerequisites) { + t.Skip("EOA default verifier message prerequisites not met for this env") + } + require.Error(t, err, "EOA message must not complete end-to-end when committee allowlist excludes deployer") +} + +func requireEnvironmentChangeEOADefaultVerifierMessageWithTestRouter(t *testing.T, ctx context.Context, th tcapi.TestHarness, cfg *ccv.Cfg, src, dest cciptestinterfaces.CCIP17, useTestRouter bool) { + t.Helper() + _, err := runEnvironmentChangeEOADefaultVerifierWithIndexedResult(ctx, th, cfg, src, dest, useTestRouter) + if errors.Is(err, errEnvironmentChangeEOADefaultVerifierPrerequisites) { + t.Skip("EOA default verifier message prerequisites not met for this env") + } + require.NoError(t, err) +} + +func TestEnvironmentChangeReconcile_CommitteeVerifierAllowlistDecoyExpectErrorThenDeployerHappyPath(t *testing.T) { + h := newEnvironmentChangeReconcileHarness(t) + ctx := ccv.Plog.WithContext(t.Context()) + path := environmentChangeSmokeConfigPath() + th, err := tcapi.NewTestHarness(ctx, path, h.Cfg, chain_selectors.FamilyEVM) + if err != nil { + t.Skipf("message verification needs tcapi harness (aggregators/indexer): %v", err) + } + srcSel, destSel := twoDistinctEVMSelectorsFromHarness(t, h) + src, dest := ccip17PairForSelectors(t, ctx, th.Lib, srcSel, destSel) + deployer := h.Env.BlockChains.EVMChains()[srcSel].DeployerKey.From + decoy := common.HexToAddress("0x0000000000000000000000000000000000000001") + require.NotEqual(t, deployer, decoy, "decoy allowlist entry must not be the chain deployer used to send messages") + + requireEnvironmentChangeEOADefaultVerifierMessage(t, ctx, th, h.Cfg, src, dest) + + reconfigureEnvironmentChangeAllowlistEnabled(t, h, srcSel, destSel, decoy) + requireEnvironmentChangeEOADefaultVerifierMessageExpectError(t, ctx, th, h.Cfg, src, dest) + + reconfigureEnvironmentChangeAllowlistDisabled(t, h, srcSel, destSel, decoy) + requireEnvironmentChangeEOADefaultVerifierMessage(t, ctx, th, h.Cfg, src, dest) + + reconfigureEnvironmentChangeAllowlistEnabled(t, h, srcSel, destSel, deployer) + requireEnvironmentChangeEOADefaultVerifierMessage(t, ctx, th, h.Cfg, src, dest) + + reconfigureEnvironmentChangeAllowlistDisabled(t, h, srcSel, destSel, deployer) + requireEnvironmentChangeEOADefaultVerifierMessage(t, ctx, th, h.Cfg, src, dest) +} + +func testRouterSourceAndDestSelectors(t *testing.T, h *environmentChangeReconcileHarness) (srcSel, destSel uint64) { + t.Helper() + require.GreaterOrEqual(t, len(h.Selectors), 2, "need at least two chains for a directed lane") + for _, sel := range h.Selectors { + if !testRouterDeployedOnSelector(t, h.Env, sel) { + continue + } + for _, other := range h.Selectors { + if other != sel { + return sel, other + } + } + } + return 0, 0 +} + +func TestEnvironmentChangeReconcile_TestRouterLaneThenProductionRouterExpectMessagesSucceedEachStage(t *testing.T) { + h := newEnvironmentChangeReconcileHarness(t) + ctx := ccv.Plog.WithContext(t.Context()) + srcSel, destSel := testRouterSourceAndDestSelectors(t, h) + if srcSel == 0 { + t.Skip("no chain in topology has TestRouter in datastore; redeploy with current devenv (TestRouter is deployed by default)") + } + path := environmentChangeSmokeConfigPath() + th, err := tcapi.NewTestHarness(ctx, path, h.Cfg, chain_selectors.FamilyEVM) + if err != nil { + t.Skipf("message verification needs tcapi harness (aggregators/indexer): %v", err) + } + src, dest := ccip17PairForSelectors(t, ctx, th.Lib, srcSel, destSel) + testRouterLanes := map[uint64]map[uint64]bool{ + srcSel: {destSel: true}, + } + + requireEnvironmentChangeEOADefaultVerifierMessageWithTestRouter(t, ctx, th, h.Cfg, src, dest, false) + + requireEnvironmentChangeReconcile(t, ctx, h, ccv.ReconfigureLanesParams{TestRouterByLane: testRouterLanes}) + requireEnvironmentChangeEOADefaultVerifierMessageWithTestRouter(t, ctx, th, h.Cfg, src, dest, true) + + requireEnvironmentChangeReconcile(t, ctx, h, ccv.ReconfigureLanesParams{}) + requireEnvironmentChangeEOADefaultVerifierMessageWithTestRouter(t, ctx, th, h.Cfg, src, dest, false) +} + +func pickRemovableNOPAliasFromDefaultCommittee(t *testing.T, topo *ccipOffchain.EnvironmentTopology) string { + t.Helper() + require.NotNil(t, topo.NOPTopology) + comm, ok := topo.NOPTopology.Committees[devenvcommon.DefaultCommitteeVerifierQualifier] + require.True(t, ok, "default committee not in topology") + var refAliases []string + for sel, cc := range comm.ChainConfigs { + require.GreaterOrEqual(t, len(cc.NOPAliases), 2, "chain %s needs at least 2 NOPs in default committee", sel) + if refAliases == nil { + refAliases = append([]string(nil), cc.NOPAliases...) + continue + } + require.ElementsMatch(t, refAliases, cc.NOPAliases, "default committee NOP aliases must match across chains for this test") + } + return refAliases[len(refAliases)-1] +} + +func removeNOPAliasFromEveryCommitteeChainConfigs(t *testing.T, topo *ccipOffchain.EnvironmentTopology, removeAlias string) { + t.Helper() + require.NotNil(t, topo.NOPTopology) + for qual, comm := range topo.NOPTopology.Committees { + for sel, cc := range comm.ChainConfigs { + if !slices.Contains(cc.NOPAliases, removeAlias) { + continue + } + next := slices.Clone(cc.NOPAliases) + next = slices.DeleteFunc(next, func(a string) bool { return a == removeAlias }) + require.NotEmpty(t, next, "committee %q chain %s would have no NOPs", qual, sel) + cc.NOPAliases = next + th := cc.Threshold + if int(th) > len(cc.NOPAliases) { + th = uint8(len(cc.NOPAliases)) + } + if th < 1 { + th = 1 + } + cc.Threshold = th + comm.ChainConfigs[sel] = cc + } + topo.NOPTopology.Committees[qual] = comm + } + require.NoError(t, topo.Validate()) +} + +func requireVerifierResultsQuorumExcludesRecoveredSigner(t *testing.T, ar tcapi.AssertionResult, excludedSignerHex string) { + t.Helper() + excluded := common.HexToAddress(strings.TrimSpace(excludedSignerHex)) + require.NotEqual(t, common.Address{}, excluded, "excluded NOP signer must parse as an address") + + for i, row := range ar.IndexedVerifications.Results { + vr := row.VerifierResult + ccvData := vr.CCVData + require.Greater(t, len(ccvData), committee.VerifierVersionLength, "indexed verification %d: ccv data too short", i) + + hash, err := committee.NewSignableHash(vr.MessageID, ccvData) + require.NoError(t, err, "indexed verification %d: signable hash", i) + + rs, ss, err := protocol.DecodeSignatures(ccvData[committee.VerifierVersionLength:]) + require.NoError(t, err, "indexed verification %d: decode quorum signatures from ccv data", i) + + signers, err := protocol.RecoverECDSASigners(hash, rs, ss) + require.NoError(t, err, "indexed verification %d: recover signers from quorum signatures", i) + + for _, sgn := range signers { + require.NotEqualf(t, excluded, sgn, + "indexed verification %d: recovered quorum signer must not be removed NOP %s (got %s)", + i, excludedSignerHex, sgn.Hex()) + } + } + + if ar.AggregatedResult != nil && len(ar.AggregatedResult.CcvData) > committee.VerifierVersionLength && ar.AggregatedResult.Message != nil { + pm, err := ccvcomm.MapProtoMessageToProtocolMessage(ar.AggregatedResult.Message) + require.NoError(t, err) + mid, err := pm.MessageID() + require.NoError(t, err) + ccvData := ar.AggregatedResult.CcvData + hash, err := committee.NewSignableHash(mid, ccvData) + require.NoError(t, err) + rs, ss, err := protocol.DecodeSignatures(ccvData[committee.VerifierVersionLength:]) + require.NoError(t, err) + signers, err := protocol.RecoverECDSASigners(hash, rs, ss) + require.NoError(t, err) + for _, sgn := range signers { + require.NotEqualf(t, excluded, sgn, + "aggregated verifier result: recovered quorum signer must not be removed NOP %s (got %s)", + excludedSignerHex, sgn.Hex()) + } + } +} + +func TestEnvironmentChangeReconcile_RemoveDefaultCommitteeNOPAndLowerThresholdExpectMessageSuccessWithoutRemovedNOPVerification(t *testing.T) { + h := newEnvironmentChangeReconcileHarness(t) + topoSnap, err := toml.Marshal(*h.Cfg.EnvironmentTopology) + require.NoError(t, err) + t.Cleanup(func() { + var restored ccipOffchain.EnvironmentTopology + if err := toml.Unmarshal(topoSnap, &restored); err != nil { + t.Logf("reconcile test cleanup: restore topology: %v", err) + return + } + *h.Cfg.EnvironmentTopology = restored + h.Topology = ccv.BuildEnvironmentTopology(h.Cfg, h.Env) + cleanupCtx := ccv.Plog.WithContext(context.Background()) + if err := ccv.ConfigureTopologyLanesAndOffchain( + cleanupCtx, h.Env, h.Cfg, h.Topology, h.Selectors, h.Cfg.Blockchains, h.Impls, ccv.ReconfigureLanesParams{}, nil, environmentChangeReconcileOpts(), + ); err != nil { + t.Logf("reconcile test cleanup: reconcile: %v", err) + } + }) + + ctx := ccv.Plog.WithContext(environmentChangeLinkedLongContext(t)) + path := environmentChangeSmokeConfigPath() + th, err := tcapi.NewTestHarness(ctx, path, h.Cfg, chain_selectors.FamilyEVM) + if err != nil { + t.Skipf("message verification needs tcapi harness (aggregators/indexer): %v", err) + } + srcSel, destSel := twoDistinctEVMSelectorsFromHarness(t, h) + src, dest := ccip17PairForSelectors(t, ctx, th.Lib, srcSel, destSel) + + built := ccv.BuildEnvironmentTopology(h.Cfg, h.Env) + removeAlias := pickRemovableNOPAliasFromDefaultCommittee(t, built) + evmSigner := requireNOPSigningKeyFromJD(t, h, removeAlias) + + removeNOPAliasFromEveryCommitteeChainConfigs(t, h.Cfg.EnvironmentTopology, removeAlias) + h.Topology = ccv.BuildEnvironmentTopology(h.Cfg, h.Env) + + requireEnvironmentChangeReconcile(t, ctx, h, ccv.ReconfigureLanesParams{}) + + ar, err := runEnvironmentChangeEOADefaultVerifierWithIndexedResult(ctx, th, h.Cfg, src, dest, false) + if errors.Is(err, errEnvironmentChangeEOADefaultVerifierPrerequisites) { + t.Skip("EOA default verifier prerequisites not met for this env") + } + require.NoError(t, err) + requireVerifierResultsQuorumExcludesRecoveredSigner(t, ar, evmSigner) +} diff --git a/build/devenv/tests/e2e/tcapi/basic/v3.go b/build/devenv/tests/e2e/tcapi/basic/v3.go index ca72b30a7..aa76b53bb 100644 --- a/build/devenv/tests/e2e/tcapi/basic/v3.go +++ b/build/devenv/tests/e2e/tcapi/basic/v3.go @@ -37,10 +37,11 @@ type v3TestCaseBase struct { // v3TestCase is for tests that use ExtraArgsV3. type v3TestCase struct { v3TestCaseBase - receiver protocol.UnknownAddress - ccvs []protocol.CCV - executor protocol.UnknownAddress - hydrate func(ctx context.Context, tc *v3TestCase, cfg *ccv.Cfg) bool + receiver protocol.UnknownAddress + ccvs []protocol.CCV + useTestRouter bool + executor protocol.UnknownAddress + hydrate func(ctx context.Context, tc *v3TestCase, cfg *ccv.Cfg) bool } func (tc *v3TestCase) Name() string { @@ -64,6 +65,7 @@ func (tc *v3TestCase) Run(ctx context.Context, harness tcapi.TestHarness, cfg *c FinalityConfig: tc.finality, Executor: tc.executor, CCVs: tc.ccvs, + UseTestRouter: tc.useTestRouter, }) if err != nil { return fmt.Errorf("failed to send message: %w", err)