diff --git a/.changeset/chilly-crews-retire.md b/.changeset/chilly-crews-retire.md new file mode 100644 index 00000000000..28b531a9ddb --- /dev/null +++ b/.changeset/chilly-crews-retire.md @@ -0,0 +1,5 @@ +--- +"chainlink": minor +--- + +#added log-event-trigger LOOPP capability, using ChainReader diff --git a/.changeset/honest-cameras-cross.md b/.changeset/honest-cameras-cross.md new file mode 100644 index 00000000000..1da50c97bd5 --- /dev/null +++ b/.changeset/honest-cameras-cross.md @@ -0,0 +1,5 @@ +--- +"chainlink": patch +--- + +Implementing evm specific token data encoder for CCIP #internal diff --git a/.changeset/moody-rules-agree.md b/.changeset/moody-rules-agree.md new file mode 100644 index 00000000000..ef1f3bcaf62 --- /dev/null +++ b/.changeset/moody-rules-agree.md @@ -0,0 +1,8 @@ +--- +"chainlink": patch +--- + +- register polling subscription to avoid subscription leaking when rpc client gets closed. +- add a temporary special treatment for SubscribeNewHead before we replace it with SubscribeToHeads. Add a goroutine that forwards new head from poller to caller channel. +- fix a deadlock in poller, by using a new lock for subs slice in rpc client. +#bugfix diff --git a/.changeset/tidy-apricots-care.md b/.changeset/tidy-apricots-care.md new file mode 100644 index 00000000000..60429107f11 --- /dev/null +++ b/.changeset/tidy-apricots-care.md @@ -0,0 +1,5 @@ +--- +"chainlink": minor +--- + +#added Pass the home chain selector to the commit plugin factory diff --git a/core/capabilities/ccip/ccipevm/tokendata.go b/core/capabilities/ccip/ccipevm/tokendata.go new file mode 100644 index 00000000000..5c205a8739c --- /dev/null +++ b/core/capabilities/ccip/ccipevm/tokendata.go @@ -0,0 +1,38 @@ +package ccipevm + +import ( + "context" + + cciptypes "github.com/smartcontractkit/chainlink-common/pkg/types/ccipocr3" + + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/abihelpers" +) + +type usdcAttestationPayload struct { + Message []byte + Attestation []byte +} + +func (m usdcAttestationPayload) AbiString() string { + return ` + [{ + "components": [ + {"name": "message", "type": "bytes"}, + {"name": "attestation", "type": "bytes"} + ], + "type": "tuple" + }]` +} + +type EVMTokenDataEncoder struct{} + +func NewEVMTokenDataEncoder() EVMTokenDataEncoder { + return EVMTokenDataEncoder{} +} + +func (e EVMTokenDataEncoder) EncodeUSDC(_ context.Context, message cciptypes.Bytes, attestation cciptypes.Bytes) (cciptypes.Bytes, error) { + return abihelpers.EncodeAbiStruct(usdcAttestationPayload{ + Message: message, + Attestation: attestation, + }) +} diff --git a/core/capabilities/ccip/ccipevm/tokendata_test.go b/core/capabilities/ccip/ccipevm/tokendata_test.go new file mode 100644 index 00000000000..7479d764071 --- /dev/null +++ b/core/capabilities/ccip/ccipevm/tokendata_test.go @@ -0,0 +1,73 @@ +package ccipevm + +import ( + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/stretchr/testify/require" + + cciptypes "github.com/smartcontractkit/chainlink-common/pkg/types/ccipocr3" + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" + + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/abihelpers" +) + +func Test_EVMTokenDataEncoder(t *testing.T) { + var empty usdcAttestationPayload + encoder := NewEVMTokenDataEncoder() + + //https://testnet.snowtrace.io/tx/0xeeb0ad6b26bacd1570a9361724a36e338f4aacf1170dec64399220b7483b7eed/eventlog?chainid=43113 + //https://iris-api-sandbox.circle.com/v1/attestations/0x69fb1b419d648cf6c9512acad303746dc85af3b864af81985c76764aba60bf6b + realMessage, err := cciptypes.NewBytesFromString("0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000f8000000000000000100000006000000000004ac0d000000000000000000000000eb08f243e5d3fcff26a9e38ae5520a669f4019d00000000000000000000000009f3b8679c73c2fef8b59b4f3444d4e156fb70aa5000000000000000000000000c08835adf4884e51ff076066706e407506826d9d000000000000000000000000000000005425890298aed601595a70ab815c96711a31bc650000000000000000000000004f32ae7f112c26b109357785e5c66dc5d747fbce00000000000000000000000000000000000000000000000000000000000000640000000000000000000000007a4d8f8c18762d362e64b411d7490fba112811cd0000000000000000") + require.NoError(t, err) + realAttestation, err := cciptypes.NewBytesFromString("0xee466fbd340596aa56e3e40d249869573e4008d84d795b4f2c3cba8649083d08653d38190d0df7e0ee12ae685df2f806d100a03b3716ab1ff2013c7201f1c2d01c9af959b55a4b52dbd0319eed69ce9ace25259830e0b1bff79faf0c9c5d1b5e6d6304e824d657db38f802bcff3e97d0bd30f2ffc62b62381f52c1668ceaa5a73a1b") + require.NoError(t, err) + + tt := []struct { + name string + message []byte + attestation []byte + }{ + { + name: "empty both fields", + message: nil, + attestation: []byte{}, + }, + { + name: "empty attestation", + message: []byte("message"), + attestation: nil, + }, + { + name: "both attestation and message are set", + message: realMessage, + attestation: realAttestation, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + got, err := encoder.EncodeUSDC(tests.Context(t), tc.message, tc.attestation) + require.NoError(t, err) + + decoded, err := abihelpers.ABIDecode(empty.AbiString(), got) + require.NoError(t, err) + + converted := abi.ConvertType(decoded[0], &empty) + casted, ok := converted.(*usdcAttestationPayload) + require.True(t, ok) + + if tc.message == nil { + require.Empty(t, casted.Message) + } else { + require.Equal(t, tc.message, casted.Message) + } + + if tc.attestation == nil { + require.Empty(t, casted.Attestation) + } else { + require.Equal(t, tc.attestation, casted.Attestation) + } + }) + } +} diff --git a/core/capabilities/ccip/configs/evm/contract_reader.go b/core/capabilities/ccip/configs/evm/contract_reader.go index fb4a8b9ef66..fbfbf260b8a 100644 --- a/core/capabilities/ccip/configs/evm/contract_reader.go +++ b/core/capabilities/ccip/configs/evm/contract_reader.go @@ -28,8 +28,12 @@ var ( nonceManagerABI = evmtypes.MustGetABI(nonce_manager.NonceManagerABI) priceFeedABI = evmtypes.MustGetABI(aggregator_v3_interface.AggregatorV3InterfaceABI) rmnRemoteABI = evmtypes.MustGetABI(rmn_remote.RMNRemoteABI) + rmnHomeABI = evmtypes.MustGetABI(rmnHomeString) ) +// TODO: replace with generated ABI when the contract will be defined +var rmnHomeString = "[{\"inputs\":[],\"name\":\"getAllConfigs\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"num\",\"type\":\"uint256\"}],\"name\":\"store\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]" + // MustSourceReaderConfig returns a ChainReaderConfig that can be used to read from the onramp. // The configuration is marshaled into JSON so that it can be passed to the relayer NewContractReader() method. func MustSourceReaderConfig() []byte { @@ -249,6 +253,14 @@ var HomeChainReaderConfigRaw = evmrelaytypes.ChainReaderConfig{ }, }, }, + consts.ContractNameRMNHome: { + ContractABI: rmnHomeString, + Configs: map[string]*evmrelaytypes.ChainReaderDefinition{ + consts.MethodNameGetAllConfigs: { + ChainSpecificName: mustGetMethodName("getAllConfigs", rmnHomeABI), + }, + }, + }, }, } diff --git a/core/capabilities/ccip/delegate.go b/core/capabilities/ccip/delegate.go index 84f3e7c1b76..30007a7b2cf 100644 --- a/core/capabilities/ccip/delegate.go +++ b/core/capabilities/ccip/delegate.go @@ -3,6 +3,7 @@ package ccip import ( "context" "fmt" + "strconv" "time" "github.com/smartcontractkit/chainlink-common/pkg/loop" @@ -23,7 +24,11 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/types" "github.com/smartcontractkit/chainlink-common/pkg/types/query/primitives" + chainsel "github.com/smartcontractkit/chain-selectors" + + cciptypes "github.com/smartcontractkit/chainlink-common/pkg/types/ccipocr3" cctypes "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/types" + "github.com/smartcontractkit/chainlink/v2/core/chains/legacyevm" "github.com/smartcontractkit/chainlink/v2/core/config" kcr "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/keystone/generated/capabilities_registry" @@ -162,6 +167,16 @@ func (d *Delegate) ServicesForSpec(ctx context.Context, spec job.Job) (services ccipConfigBinding, ) + // get the chain selector for the home chain + homeChainChainID, err := strconv.ParseUint(d.capabilityConfig.ExternalRegistry().RelayID().ChainID, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse chain ID %s: %w", d.capabilityConfig.ExternalRegistry().RelayID().ChainID, err) + } + homeChainChainSelector, err := chainsel.SelectorFromChainId(homeChainChainID) + if err != nil { + return nil, fmt.Errorf("failed to get chain selector from chain ID %d", homeChainChainID) + } + // if bootstrappers are provided we assume that the node is a plugin oracle. // the reason for this is that bootstrap oracles do not need to be aware // of other bootstrap oracles. however, plugin oracles, at least initially, @@ -182,6 +197,7 @@ func (d *Delegate) ServicesForSpec(ctx context.Context, spec job.Job) (services d.monitoringEndpointGen, bootstrapperLocators, hcr, + cciptypes.ChainSelector(homeChainChainSelector), ) } else { oracleCreator = oraclecreator.NewBootstrapOracleCreator( diff --git a/core/capabilities/ccip/oraclecreator/plugin.go b/core/capabilities/ccip/oraclecreator/plugin.go index d9efe5a58c0..07682ba60e9 100644 --- a/core/capabilities/ccip/oraclecreator/plugin.go +++ b/core/capabilities/ccip/oraclecreator/plugin.go @@ -11,7 +11,6 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3confighelper" - "github.com/smartcontractkit/chainlink-ccip/execute/tokendata" "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipevm" evmconfig "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/configs/evm" "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ocrimpls" @@ -33,6 +32,7 @@ import ( "github.com/smartcontractkit/chainlink-ccip/pluginconfig" "github.com/smartcontractkit/chainlink-common/pkg/types" + "github.com/smartcontractkit/chainlink/v2/core/chains/legacyevm" "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/services/job" @@ -67,6 +67,7 @@ type pluginOracleCreator struct { monitoringEndpointGen telemetry.MonitoringEndpointGenerator bootstrapperLocators []commontypes.BootstrapperLocator homeChainReader ccipreaderpkg.HomeChain + homeChainSelector cciptypes.ChainSelector } func NewPluginOracleCreator( @@ -83,6 +84,7 @@ func NewPluginOracleCreator( monitoringEndpointGen telemetry.MonitoringEndpointGenerator, bootstrapperLocators []commontypes.BootstrapperLocator, homeChainReader ccipreaderpkg.HomeChain, + homeChainSelector cciptypes.ChainSelector, ) cctypes.OracleCreator { return &pluginOracleCreator{ ocrKeyBundles: ocrKeyBundles, @@ -98,6 +100,7 @@ func NewPluginOracleCreator( monitoringEndpointGen: monitoringEndpointGen, bootstrapperLocators: bootstrapperLocators, homeChainReader: homeChainReader, + homeChainSelector: homeChainSelector, } } @@ -220,6 +223,7 @@ func (i *pluginOracleCreator) createFactoryAndTransmitter( ccipevm.NewCommitPluginCodecV1(), ccipevm.NewMessageHasherV1(), i.homeChainReader, + i.homeChainSelector, contractReaders, chainWriters, ) @@ -238,7 +242,7 @@ func (i *pluginOracleCreator) createFactoryAndTransmitter( ccipevm.NewExecutePluginCodecV1(), ccipevm.NewMessageHasherV1(), i.homeChainReader, - &tokendata.NoopTokenDataObserver{}, + ccipevm.NewEVMTokenDataEncoder(), ccipevm.NewGasEstimateProvider(), contractReaders, chainWriters, @@ -273,6 +277,11 @@ func (i *pluginOracleCreator) createReadersAndWriters( execBatchGasLimit = ofc.exec().BatchGasLimit } + homeChainID, err := i.getChainID(i.homeChainSelector) + if err != nil { + return nil, nil, err + } + contractReaders := make(map[cciptypes.ChainSelector]types.ContractReader) chainWriters := make(map[cciptypes.ChainSelector]types.ChainWriter) for _, chain := range i.chains.Slice() { @@ -281,7 +290,7 @@ func (i *pluginOracleCreator) createReadersAndWriters( return nil, nil, err1 } - chainReaderConfig := getChainReaderConfig(chain.ID().Uint64(), destChainID, ofc, chainSelector) + chainReaderConfig := getChainReaderConfig(chain.ID().Uint64(), destChainID, homeChainID, ofc, chainSelector) cr, err1 := createChainReader(i.lggr, chain, chainReaderConfig, pluginType) if err1 != nil { return nil, nil, err1 @@ -348,9 +357,18 @@ func (i *pluginOracleCreator) getChainSelector(chainID uint64) (cciptypes.ChainS return cciptypes.ChainSelector(chainSelector), nil } +func (i *pluginOracleCreator) getChainID(chainSelector cciptypes.ChainSelector) (uint64, error) { + chainID, err := chainsel.ChainIdFromSelector(uint64(chainSelector)) + if err != nil { + return 0, fmt.Errorf("failed to get chain ID from chain selector %d: %w", chainSelector, err) + } + return chainID, nil +} + func getChainReaderConfig( chainID uint64, destChainID uint64, + homeChainID uint64, ofc offChainConfig, chainSelector cciptypes.ChainSelector, ) evmrelaytypes.ChainReaderConfig { @@ -364,6 +382,10 @@ func getChainReaderConfig( if !ofc.commitEmpty() && ofc.commit().PriceFeedChainSelector == chainSelector { chainReaderConfig = evmconfig.MergeReaderConfigs(chainReaderConfig, evmconfig.FeedReaderConfig) } + + if chainID == homeChainID { + chainReaderConfig = evmconfig.MergeReaderConfigs(chainReaderConfig, evmconfig.HomeChainReaderConfigRaw) + } return chainReaderConfig } diff --git a/core/capabilities/triggers/logevent/service.go b/core/capabilities/triggers/logevent/service.go new file mode 100644 index 00000000000..7ed4855e097 --- /dev/null +++ b/core/capabilities/triggers/logevent/service.go @@ -0,0 +1,157 @@ +package logevent + +import ( + "context" + "errors" + "fmt" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/services" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" +) + +const ID = "log-event-trigger-%s-%s@1.0.0" + +const defaultSendChannelBufferSize = 1000 + +// Log Event Trigger Capability Input +type Input struct { +} + +// Log Event Trigger Capabilities Manager +// Manages different log event triggers using an underlying triggerStore +type TriggerService struct { + services.StateMachine + capabilities.CapabilityInfo + capabilities.Validator[RequestConfig, Input, capabilities.TriggerResponse] + lggr logger.Logger + triggers CapabilitiesStore[logEventTrigger, capabilities.TriggerResponse] + relayer core.Relayer + logEventConfig Config + stopCh services.StopChan +} + +// Common capability level config across all workflows +type Config struct { + ChainID string `json:"chainId"` + Network string `json:"network"` + LookbackBlocks uint64 `json:"lookbakBlocks"` + PollPeriod uint32 `json:"pollPeriod"` +} + +func (config Config) Version(capabilityVersion string) string { + return fmt.Sprintf(capabilityVersion, config.Network, config.ChainID) +} + +var _ capabilities.TriggerCapability = (*TriggerService)(nil) +var _ services.Service = &TriggerService{} + +// Creates a new Cron Trigger Service. +// Scheduling will commence on calling .Start() +func NewTriggerService(ctx context.Context, + lggr logger.Logger, + relayer core.Relayer, + logEventConfig Config) (*TriggerService, error) { + l := logger.Named(lggr, "LogEventTriggerCapabilityService") + + logEventStore := NewCapabilitiesStore[logEventTrigger, capabilities.TriggerResponse]() + + s := &TriggerService{ + lggr: l, + triggers: logEventStore, + relayer: relayer, + logEventConfig: logEventConfig, + stopCh: make(services.StopChan), + } + var err error + s.CapabilityInfo, err = s.Info(ctx) + if err != nil { + return s, err + } + s.Validator = capabilities.NewValidator[RequestConfig, Input, capabilities.TriggerResponse](capabilities.ValidatorArgs{Info: s.CapabilityInfo}) + return s, nil +} + +func (s *TriggerService) Info(ctx context.Context) (capabilities.CapabilityInfo, error) { + return capabilities.NewCapabilityInfo( + s.logEventConfig.Version(ID), + capabilities.CapabilityTypeTrigger, + "A trigger that listens for specific contract log events and starts a workflow run.", + ) +} + +// Register a new trigger +// Can register triggers before the service is actively scheduling +func (s *TriggerService) RegisterTrigger(ctx context.Context, req capabilities.TriggerRegistrationRequest) (<-chan capabilities.TriggerResponse, error) { + if req.Config == nil { + return nil, errors.New("config is required to register a log event trigger") + } + reqConfig, err := s.ValidateConfig(req.Config) + if err != nil { + return nil, err + } + // Add log event trigger with Contract details to CapabilitiesStore + var respCh chan capabilities.TriggerResponse + ok := s.IfNotStopped(func() { + respCh, err = s.triggers.InsertIfNotExists(req.TriggerID, func() (*logEventTrigger, chan capabilities.TriggerResponse, error) { + l, ch, tErr := newLogEventTrigger(ctx, s.lggr, req.Metadata.WorkflowID, reqConfig, s.logEventConfig, s.relayer) + if tErr != nil { + return l, ch, tErr + } + tErr = l.Start(ctx) + return l, ch, tErr + }) + }) + if !ok { + return nil, fmt.Errorf("cannot create new trigger since LogEventTriggerService has been stopped") + } + if err != nil { + return nil, fmt.Errorf("create new trigger failed %w", err) + } + s.lggr.Infow("RegisterTrigger", "triggerId", req.TriggerID, "WorkflowID", req.Metadata.WorkflowID) + return respCh, nil +} + +func (s *TriggerService) UnregisterTrigger(ctx context.Context, req capabilities.TriggerRegistrationRequest) error { + trigger, ok := s.triggers.Read(req.TriggerID) + if !ok { + return fmt.Errorf("triggerId %s not found", req.TriggerID) + } + // Close callback channel and stop log event trigger listener + err := trigger.Close() + if err != nil { + return fmt.Errorf("error closing trigger %s (chainID %s): %w", req.TriggerID, s.logEventConfig.ChainID, err) + } + // Remove from triggers context + s.triggers.Delete(req.TriggerID) + s.lggr.Infow("UnregisterTrigger", "triggerId", req.TriggerID, "WorkflowID", req.Metadata.WorkflowID) + return nil +} + +// Start the service. +func (s *TriggerService) Start(ctx context.Context) error { + return s.StartOnce("LogEventTriggerCapabilityService", func() error { + s.lggr.Info("Starting LogEventTriggerCapabilityService") + return nil + }) +} + +// Close stops the Service. +// After this call the Service cannot be started again, +// The service will need to be re-built to start scheduling again. +func (s *TriggerService) Close() error { + return s.StopOnce("LogEventTriggerCapabilityService", func() error { + s.lggr.Infow("Stopping LogEventTriggerCapabilityService") + triggers := s.triggers.ReadAll() + return services.MultiCloser(triggers).Close() + }) +} + +func (s *TriggerService) HealthReport() map[string]error { + return map[string]error{s.Name(): s.Healthy()} +} + +func (s *TriggerService) Name() string { + return s.lggr.Name() +} diff --git a/core/capabilities/triggers/logevent/store.go b/core/capabilities/triggers/logevent/store.go new file mode 100644 index 00000000000..ac9d3741cd1 --- /dev/null +++ b/core/capabilities/triggers/logevent/store.go @@ -0,0 +1,82 @@ +package logevent + +import ( + "fmt" + "sync" +) + +type RegisterCapabilityFn[T any, Resp any] func() (*T, chan Resp, error) + +// Interface of the capabilities store +type CapabilitiesStore[T any, Resp any] interface { + Read(capabilityID string) (value *T, ok bool) + ReadAll() (values []*T) + Write(capabilityID string, value *T) + InsertIfNotExists(capabilityID string, fn RegisterCapabilityFn[T, Resp]) (chan Resp, error) + Delete(capabilityID string) +} + +// Implementation for the CapabilitiesStore interface +type capabilitiesStore[T any, Resp any] struct { + mu sync.RWMutex + capabilities map[string]*T +} + +var _ CapabilitiesStore[string, string] = (CapabilitiesStore[string, string])(nil) + +// Constructor for capabilitiesStore struct implementing CapabilitiesStore interface +func NewCapabilitiesStore[T any, Resp any]() CapabilitiesStore[T, Resp] { + return &capabilitiesStore[T, Resp]{ + capabilities: map[string]*T{}, + } +} + +func (cs *capabilitiesStore[T, Resp]) Read(capabilityID string) (value *T, ok bool) { + cs.mu.RLock() + defer cs.mu.RUnlock() + trigger, ok := cs.capabilities[capabilityID] + return trigger, ok +} + +func (cs *capabilitiesStore[T, Resp]) ReadAll() (values []*T) { + cs.mu.RLock() + defer cs.mu.RUnlock() + vals := make([]*T, 0) + for _, v := range cs.capabilities { + vals = append(vals, v) + } + return vals +} + +func (cs *capabilitiesStore[T, Resp]) Write(capabilityID string, value *T) { + cs.mu.Lock() + defer cs.mu.Unlock() + cs.capabilities[capabilityID] = value +} + +func (cs *capabilitiesStore[T, Resp]) InsertIfNotExists(capabilityID string, fn RegisterCapabilityFn[T, Resp]) (chan Resp, error) { + cs.mu.RLock() + _, ok := cs.capabilities[capabilityID] + cs.mu.RUnlock() + if ok { + return nil, fmt.Errorf("capabilityID %v already exists", capabilityID) + } + cs.mu.Lock() + defer cs.mu.Unlock() + _, ok = cs.capabilities[capabilityID] + if ok { + return nil, fmt.Errorf("capabilityID %v already exists", capabilityID) + } + value, respCh, err := fn() + if err != nil { + return nil, fmt.Errorf("error registering capability: %v", err) + } + cs.capabilities[capabilityID] = value + return respCh, nil +} + +func (cs *capabilitiesStore[T, Resp]) Delete(capabilityID string) { + cs.mu.Lock() + defer cs.mu.Unlock() + delete(cs.capabilities, capabilityID) +} diff --git a/core/capabilities/triggers/logevent/trigger.go b/core/capabilities/triggers/logevent/trigger.go new file mode 100644 index 00000000000..9a0e1d036c7 --- /dev/null +++ b/core/capabilities/triggers/logevent/trigger.go @@ -0,0 +1,210 @@ +package logevent + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/services" + "github.com/smartcontractkit/chainlink-common/pkg/types" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + "github.com/smartcontractkit/chainlink-common/pkg/types/query" + "github.com/smartcontractkit/chainlink-common/pkg/types/query/primitives" + "github.com/smartcontractkit/chainlink-common/pkg/values" +) + +// Log Event Trigger Capability Request Config Details +type RequestConfig struct { + ContractName string `json:"contractName"` + ContractAddress string `json:"contractAddress"` + ContractEventName string `json:"contractEventName"` + // Log Event Trigger capability takes in a []byte as ContractReaderConfig + // to not depend on evm ChainReaderConfig type and be chain agnostic + ContractReaderConfig map[string]any `json:"contractReaderConfig"` +} + +// LogEventTrigger struct to listen for Contract events using ContractReader gRPC client +// in a loop with a periodic delay of pollPeriod milliseconds, which is specified in +// the job spec +type logEventTrigger struct { + ch chan<- capabilities.TriggerResponse + lggr logger.Logger + + // Contract address and Event Signature to monitor for + reqConfig *RequestConfig + contractReader types.ContractReader + relayer core.Relayer + startBlockNum uint64 + + // Log Event Trigger config with pollPeriod and lookbackBlocks + logEventConfig Config + ticker *time.Ticker + stopChan services.StopChan + done chan bool +} + +// Construct for logEventTrigger struct +func newLogEventTrigger(ctx context.Context, + lggr logger.Logger, + workflowID string, + reqConfig *RequestConfig, + logEventConfig Config, + relayer core.Relayer) (*logEventTrigger, chan capabilities.TriggerResponse, error) { + jsonBytes, err := json.Marshal(reqConfig.ContractReaderConfig) + if err != nil { + return nil, nil, err + } + + // Create a New Contract Reader client, which brings a corresponding ContractReader gRPC service + // in Chainlink Core service + contractReader, err := relayer.NewContractReader(ctx, jsonBytes) + if err != nil { + return nil, nil, + fmt.Errorf("error fetching contractReader for chainID %s from relayerSet: %w", logEventConfig.ChainID, err) + } + + // Bind Contract in ContractReader + boundContracts := []types.BoundContract{{Name: reqConfig.ContractName, Address: reqConfig.ContractAddress}} + err = contractReader.Bind(ctx, boundContracts) + if err != nil { + return nil, nil, err + } + + // Get current block HEAD/tip of the blockchain to start polling from + latestHead, err := relayer.LatestHead(ctx) + if err != nil { + return nil, nil, fmt.Errorf("error getting latestHead from relayer client: %w", err) + } + height, err := strconv.ParseUint(latestHead.Height, 10, 64) + if err != nil { + return nil, nil, fmt.Errorf("invalid height in latestHead from relayer client: %w", err) + } + startBlockNum := uint64(0) + if height > logEventConfig.LookbackBlocks { + startBlockNum = height - logEventConfig.LookbackBlocks + } + + // Setup callback channel, logger and ticker to poll ContractReader + callbackCh := make(chan capabilities.TriggerResponse, defaultSendChannelBufferSize) + ticker := time.NewTicker(time.Duration(logEventConfig.PollPeriod) * time.Millisecond) + + // Initialise a Log Event Trigger + l := &logEventTrigger{ + ch: callbackCh, + lggr: logger.Named(lggr, fmt.Sprintf("LogEventTrigger.%s", workflowID)), + + reqConfig: reqConfig, + contractReader: contractReader, + relayer: relayer, + startBlockNum: startBlockNum, + + logEventConfig: logEventConfig, + ticker: ticker, + stopChan: make(services.StopChan), + done: make(chan bool), + } + return l, callbackCh, nil +} + +func (l *logEventTrigger) Start(ctx context.Context) error { + go l.listen() + return nil +} + +// Start to contract events and trigger workflow runs +func (l *logEventTrigger) listen() { + ctx, cancel := l.stopChan.NewCtx() + defer cancel() + defer close(l.done) + + // Listen for events from lookbackPeriod + var logs []types.Sequence + var err error + logData := make(map[string]any) + cursor := "" + limitAndSort := query.LimitAndSort{ + SortBy: []query.SortBy{query.NewSortByTimestamp(query.Asc)}, + } + for { + select { + case <-ctx.Done(): + l.lggr.Infow("Closing trigger server for (waiting for waitGroup)", "ChainID", l.logEventConfig.ChainID, + "ContractName", l.reqConfig.ContractName, + "ContractAddress", l.reqConfig.ContractAddress, + "ContractEventName", l.reqConfig.ContractEventName) + return + case t := <-l.ticker.C: + l.lggr.Infow("Polling event logs from ContractReader using QueryKey at", "time", t, + "startBlockNum", l.startBlockNum, + "cursor", cursor) + if cursor != "" { + limitAndSort.Limit = query.Limit{Cursor: cursor} + } + logs, err = l.contractReader.QueryKey( + ctx, + types.BoundContract{Name: l.reqConfig.ContractName, Address: l.reqConfig.ContractAddress}, + query.KeyFilter{ + Key: l.reqConfig.ContractEventName, + Expressions: []query.Expression{ + query.Confidence(primitives.Finalized), + query.Block(fmt.Sprintf("%d", l.startBlockNum), primitives.Gte), + }, + }, + limitAndSort, + &logData, + ) + if err != nil { + l.lggr.Errorw("QueryKey failure", "err", err) + continue + } + // ChainReader QueryKey API provides logs including the cursor value and not + // after the cursor value. If the response only consists of the log corresponding + // to the cursor and no log after it, then we understand that there are no new + // logs + if len(logs) == 1 && logs[0].Cursor == cursor { + l.lggr.Infow("No new logs since", "cursor", cursor) + continue + } + for _, log := range logs { + if log.Cursor == cursor { + continue + } + triggerResp := createTriggerResponse(log, l.logEventConfig.Version(ID)) + l.ch <- triggerResp + cursor = log.Cursor + } + } + } +} + +// Create log event trigger capability response +func createTriggerResponse(log types.Sequence, version string) capabilities.TriggerResponse { + wrappedPayload, err := values.WrapMap(log) + if err != nil { + return capabilities.TriggerResponse{ + Err: fmt.Errorf("error wrapping trigger event: %s", err), + } + } + return capabilities.TriggerResponse{ + Event: capabilities.TriggerEvent{ + TriggerType: version, + ID: log.Cursor, + Outputs: wrappedPayload, + }, + } +} + +// Close contract event listener for the current contract +// This function is called when UnregisterTrigger is called individually +// for a specific ContractAddress and EventName +// When the whole capability service is stopped, stopChan of the service +// is closed, which would stop all triggers +func (l *logEventTrigger) Close() error { + close(l.stopChan) + <-l.done + return nil +} diff --git a/core/capabilities/webapi/trigger.go b/core/capabilities/webapi/trigger.go index db0df7d1410..611879c7a0a 100644 --- a/core/capabilities/webapi/trigger.go +++ b/core/capabilities/webapi/trigger.go @@ -1,18 +1,279 @@ -package webapi +package trigger import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + + ethCommon "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/services" "github.com/smartcontractkit/chainlink-common/pkg/types/core" + "github.com/smartcontractkit/chainlink-common/pkg/values" "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/gateway/api" "github.com/smartcontractkit/chainlink/v2/core/services/gateway/connector" - "github.com/smartcontractkit/chainlink/v2/core/services/job" + "github.com/smartcontractkit/chainlink/v2/core/services/gateway/handlers/common" + "github.com/smartcontractkit/chainlink/v2/core/services/gateway/handlers/webapicapabilities" ) -func NewTrigger(config string, registry core.CapabilitiesRegistry, connector connector.GatewayConnector, lggr logger.Logger) (job.ServiceCtx, error) { - // TODO (CAPPL-22, CAPPL-24): - // - decode config - // - create an implementation of the capability API and add it to the Registry - // - create a handler and register it with Gateway Connector - // - manage trigger subscriptions - // - process incoming trigger events and related metadata - return nil, nil +const defaultSendChannelBufferSize = 1000 + +const TriggerType = "web-trigger@1.0.0" + +var webapiTriggerInfo = capabilities.MustNewCapabilityInfo( + TriggerType, + capabilities.CapabilityTypeTrigger, + "A trigger to start workflow execution from a web api call", +) + +type Input struct { +} +type Config struct { + AllowedSenders []string `toml:"allowedSenders"` + AllowedTopics []string `toml:"allowedTopics"` + RateLimiter common.RateLimiterConfig `toml:"rateLimiter"` + // RequiredParams is advisory to the web trigger message sender it is not enforced. + RequiredParams []string `toml:"requiredParams"` +} + +type webapiTrigger struct { + allowedSenders map[string]bool + allowedTopics map[string]bool + ch chan<- capabilities.TriggerResponse + config Config + rateLimiter *common.RateLimiter +} + +type triggerConnectorHandler struct { + services.StateMachine + + capabilities.CapabilityInfo + capabilities.Validator[Config, Input, capabilities.TriggerResponse] + connector connector.GatewayConnector + lggr logger.Logger + mu sync.Mutex + registeredWorkflows map[string]webapiTrigger +} + +var _ capabilities.TriggerCapability = (*triggerConnectorHandler)(nil) +var _ services.Service = &triggerConnectorHandler{} + +func NewTrigger(config string, registry core.CapabilitiesRegistry, connector connector.GatewayConnector, lggr logger.Logger) (*triggerConnectorHandler, error) { + if connector == nil { + return nil, errors.New("missing connector") + } + handler := &triggerConnectorHandler{ + Validator: capabilities.NewValidator[Config, Input, capabilities.TriggerResponse](capabilities.ValidatorArgs{Info: webapiTriggerInfo}), + connector: connector, + registeredWorkflows: map[string]webapiTrigger{}, + lggr: lggr.Named("WorkflowConnectorHandler"), + } + + return handler, nil +} + +// processTrigger iterates over each topic, checking against senders and rateLimits, then starting event processing and responding +func (h *triggerConnectorHandler) processTrigger(ctx context.Context, gatewayID string, body *api.MessageBody, sender ethCommon.Address, payload webapicapabilities.TriggerRequestPayload) error { + // Pass on the payload with the expectation that it's in an acceptable format for the executor + wrappedPayload, err := values.WrapMap(payload) + if err != nil { + return fmt.Errorf("error wrapping payload %s", err) + } + topics := payload.Topics + + // empty topics is error for V1 + if len(topics) == 0 { + return fmt.Errorf("empty Workflow Topics") + } + + // workflows that have matched topics + matchedWorkflows := 0 + // workflows that have matched topic and passed all checks + fullyMatchedWorkflows := 0 + for _, trigger := range h.registeredWorkflows { + for _, topic := range topics { + if trigger.allowedTopics[topic] { + matchedWorkflows++ + if !trigger.allowedSenders[sender.String()] { + err = fmt.Errorf("unauthorized Sender %s, messageID %s", sender.String(), body.MessageId) + h.lggr.Debugw(err.Error()) + continue + } + if !trigger.rateLimiter.Allow(body.Sender) { + err = fmt.Errorf("request rate-limited for sender %s, messageID %s", sender.String(), body.MessageId) + continue + } + fullyMatchedWorkflows++ + TriggerEventID := body.Sender + payload.TriggerEventID + tr := capabilities.TriggerResponse{ + Event: capabilities.TriggerEvent{ + TriggerType: TriggerType, + ID: TriggerEventID, + Outputs: wrappedPayload, + }, + } + select { + case <-ctx.Done(): + return nil + case trigger.ch <- tr: + // Sending n topics that match a workflow with n allowedTopics, can only be triggered once. + break + } + } + } + } + if matchedWorkflows == 0 { + return fmt.Errorf("no Matching Workflow Topics") + } + + if fullyMatchedWorkflows > 0 { + return nil + } + return err +} + +func (h *triggerConnectorHandler) HandleGatewayMessage(ctx context.Context, gatewayID string, msg *api.Message) { + // TODO: Validate Signature + body := &msg.Body + sender := ethCommon.HexToAddress(body.Sender) + var payload webapicapabilities.TriggerRequestPayload + err := json.Unmarshal(body.Payload, &payload) + if err != nil { + h.lggr.Errorw("error decoding payload", "err", err) + err = h.sendResponse(ctx, gatewayID, body, webapicapabilities.TriggerResponsePayload{Status: "ERROR", ErrorMessage: fmt.Errorf("error %s decoding payload", err.Error()).Error()}) + if err != nil { + h.lggr.Errorw("error sending response", "err", err) + } + return + } + + switch body.Method { + case webapicapabilities.MethodWebAPITrigger: + resp := h.processTrigger(ctx, gatewayID, body, sender, payload) + var response webapicapabilities.TriggerResponsePayload + if resp == nil { + response = webapicapabilities.TriggerResponsePayload{Status: "ACCEPTED"} + } else { + response = webapicapabilities.TriggerResponsePayload{Status: "ERROR", ErrorMessage: resp.Error()} + h.lggr.Errorw("Error processing trigger", "gatewayID", gatewayID, "body", body, "response", resp) + } + err = h.sendResponse(ctx, gatewayID, body, response) + if err != nil { + h.lggr.Errorw("Error sending response", "body", body, "response", response, "err", err) + } + return + + default: + h.lggr.Errorw("unsupported method", "id", gatewayID, "method", body.Method) + err = h.sendResponse(ctx, gatewayID, body, webapicapabilities.TriggerResponsePayload{Status: "ERROR", ErrorMessage: fmt.Errorf("unsupported method %s", body.Method).Error()}) + if err != nil { + h.lggr.Errorw("error sending response", "err", err) + } + } +} + +func (h *triggerConnectorHandler) RegisterTrigger(ctx context.Context, req capabilities.TriggerRegistrationRequest) (<-chan capabilities.TriggerResponse, error) { + cfg := req.Config + if cfg == nil { + return nil, errors.New("config is required to register a web api trigger") + } + + reqConfig, err := h.ValidateConfig(cfg) + if err != nil { + return nil, err + } + + if len(reqConfig.AllowedSenders) == 0 { + return nil, errors.New("allowedSenders must have at least 1 entry") + } + + h.mu.Lock() + defer h.mu.Unlock() + _, errBool := h.registeredWorkflows[req.TriggerID] + if errBool { + return nil, fmt.Errorf("triggerId %s already registered", req.TriggerID) + } + + rateLimiter, err := common.NewRateLimiter(reqConfig.RateLimiter) + if err != nil { + return nil, err + } + + allowedSendersMap := map[string]bool{} + for _, k := range reqConfig.AllowedSenders { + allowedSendersMap[k] = true + } + + allowedTopicsMap := map[string]bool{} + for _, k := range reqConfig.AllowedTopics { + allowedTopicsMap[k] = true + } + + ch := make(chan capabilities.TriggerResponse, defaultSendChannelBufferSize) + + h.registeredWorkflows[req.TriggerID] = webapiTrigger{ + allowedTopics: allowedTopicsMap, + allowedSenders: allowedSendersMap, + ch: ch, + config: *reqConfig, + rateLimiter: rateLimiter, + } + + return ch, nil +} + +func (h *triggerConnectorHandler) UnregisterTrigger(ctx context.Context, req capabilities.TriggerRegistrationRequest) error { + h.mu.Lock() + defer h.mu.Unlock() + workflow, ok := h.registeredWorkflows[req.TriggerID] + if !ok { + return fmt.Errorf("triggerId %s not registered", req.TriggerID) + } + + close(workflow.ch) + delete(h.registeredWorkflows, req.TriggerID) + return nil +} + +func (h *triggerConnectorHandler) Start(ctx context.Context) error { + return h.StartOnce("GatewayConnectorServiceWrapper", func() error { + return h.connector.AddHandler([]string{"web_trigger"}, h) + }) +} +func (h *triggerConnectorHandler) Close() error { + return h.StopOnce("GatewayConnectorServiceWrapper", func() error { + return nil + }) +} + +func (h *triggerConnectorHandler) HealthReport() map[string]error { + return map[string]error{h.Name(): h.Healthy()} +} + +func (h *triggerConnectorHandler) Name() string { + return "WebAPITrigger" +} + +func (h *triggerConnectorHandler) sendResponse(ctx context.Context, gatewayID string, requestBody *api.MessageBody, payload any) error { + payloadJSON, err := json.Marshal(payload) + if err != nil { + h.lggr.Errorw("error marshalling payload", "err", err) + payloadJSON, _ = json.Marshal(webapicapabilities.TriggerResponsePayload{Status: "ERROR", ErrorMessage: fmt.Errorf("error %s marshalling payload", err.Error()).Error()}) + } + + msg := &api.Message{ + Body: api.MessageBody{ + MessageId: requestBody.MessageId, + DonId: requestBody.DonId, + Method: requestBody.Method, + Receiver: requestBody.Sender, + Payload: payloadJSON, + }, + } + + return h.connector.SendToGateway(ctx, gatewayID, msg) } diff --git a/core/capabilities/webapi/trigger_test.go b/core/capabilities/webapi/trigger_test.go new file mode 100644 index 00000000000..d370b1ec7ac --- /dev/null +++ b/core/capabilities/webapi/trigger_test.go @@ -0,0 +1,383 @@ +package trigger + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + registrymock "github.com/smartcontractkit/chainlink-common/pkg/types/core/mocks" + "github.com/smartcontractkit/chainlink-common/pkg/values" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + corelogger "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/gateway/api" + gcmocks "github.com/smartcontractkit/chainlink/v2/core/services/gateway/connector/mocks" + "github.com/smartcontractkit/chainlink/v2/core/services/gateway/handlers/webapicapabilities" +) + +const ( + privateKey1 = "65456ffb8af4a2b93959256a8e04f6f2fe0943579fb3c9c3350593aabb89023f" + privateKey2 = "65456ffb8af4a2b93959256a8e04f6f2fe0943579fb3c9c3350593aabb89023e" + triggerID1 = "5" + triggerID2 = "6" + workflowID1 = "15c631d295ef5e32deb99a10ee6804bc4af13855687559d7ff6552ac6dbb2ce0" + workflowExecutionID1 = "95ef5e32deb99a10ee6804bc4af13855687559d7ff6552ac6dbb2ce0abbadeed" + owner1 = "0x00000000000000000000000000000000000000aa" + address1 = "0x853d51d5d9935964267a5050aC53aa63ECA39bc5" + address2 = "0x853d51d5d9935964267a5050aC53aa63ECA39bc6" +) + +type testHarness struct { + registry *registrymock.CapabilitiesRegistry + connector *gcmocks.GatewayConnector + lggr logger.Logger + config string + trigger *triggerConnectorHandler +} + +func workflowTriggerConfig(_ testHarness, addresses []string, topics []string) (*values.Map, error) { + var rateLimitConfig, err = values.NewMap(map[string]any{ + "GlobalRPS": 100.0, + "GlobalBurst": 101, + "PerSenderRPS": 102.0, + "PerSenderBurst": 103, + }) + if err != nil { + return nil, err + } + + triggerRegistrationConfig, err := values.NewMap(map[string]interface{}{ + "RateLimiter": rateLimitConfig, + "AllowedSenders": addresses, + "AllowedTopics": topics, + "RequiredParams": []string{"bid", "ask"}, + }) + return triggerRegistrationConfig, err +} + +func setup(t *testing.T) testHarness { + registry := registrymock.NewCapabilitiesRegistry(t) + connector := gcmocks.NewGatewayConnector(t) + lggr := corelogger.TestLogger(t) + config := "" + + trigger, err := NewTrigger(config, registry, connector, lggr) + require.NoError(t, err) + + return testHarness{ + registry: registry, + connector: connector, + lggr: lggr, + config: config, + trigger: trigger, + } +} + +func gatewayRequest(t *testing.T, privateKey string, topics string, methodName string) *api.Message { + messageID := "12345" + if methodName == "" { + methodName = webapicapabilities.MethodWebAPITrigger + } + donID := "workflow_don_1" + + key, err := crypto.HexToECDSA(privateKey) + require.NoError(t, err) + + payload := `{ + "trigger_id": "` + TriggerType + `", + "trigger_event_id": "action_1234567890", + "timestamp": 1234567890, + "topics": ` + topics + `, + "params": { + "bid": "101", + "ask": "102" + } + } +` + payloadJSON := []byte(payload) + msg := &api.Message{ + Body: api.MessageBody{ + MessageId: messageID, + Method: methodName, + DonId: donID, + Payload: json.RawMessage(payloadJSON), + }, + } + err = msg.Sign(key) + require.NoError(t, err) + return msg +} + +func getResponseFromArg(arg interface{}) (webapicapabilities.TriggerResponsePayload, error) { + var response webapicapabilities.TriggerResponsePayload + err := json.Unmarshal((&(arg.(*api.Message)).Body).Payload, &response) + return response, err +} + +func requireNoChanMsg[T any](t *testing.T, ch <-chan T) { + timedOut := false + select { + case <-ch: + case <-time.After(100 * time.Millisecond): + timedOut = true + } + require.True(t, timedOut) +} + +func requireChanMsg[T capabilities.TriggerResponse](t *testing.T, ch <-chan capabilities.TriggerResponse) (capabilities.TriggerResponse, error) { + timedOut := false + select { + case resp := <-ch: + return resp, nil + case <-time.After(100 * time.Millisecond): + timedOut = true + } + require.False(t, timedOut) + return capabilities.TriggerResponse{}, errors.New("channel timeout") +} + +func TestTriggerExecute(t *testing.T) { + th := setup(t) + ctx := testutils.Context(t) + ctx, cancelContext := context.WithDeadline(ctx, time.Now().Add(10*time.Second)) + Config, _ := workflowTriggerConfig(th, []string{address1}, []string{"daily_price_update", "ad_hoc_price_update"}) + triggerReq := capabilities.TriggerRegistrationRequest{ + TriggerID: triggerID1, + Metadata: capabilities.RequestMetadata{ + WorkflowID: workflowID1, + WorkflowOwner: owner1, + }, + Config: Config, + } + channel, err := th.trigger.RegisterTrigger(ctx, triggerReq) + require.NoError(t, err) + + Config2, err := workflowTriggerConfig(th, []string{address1}, []string{"daily_price_update2", "ad_hoc_price_update"}) + require.NoError(t, err) + + triggerReq2 := capabilities.TriggerRegistrationRequest{ + TriggerID: triggerID2, + Metadata: capabilities.RequestMetadata{ + WorkflowID: workflowID1, + WorkflowOwner: owner1, + }, + Config: Config2, + } + channel2, err := th.trigger.RegisterTrigger(ctx, triggerReq2) + require.NoError(t, err) + + t.Run("happy case single topic to single workflow", func(t *testing.T) { + gatewayRequest := gatewayRequest(t, privateKey1, `["daily_price_update"]`, "") + + th.connector.On("SendToGateway", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + resp, _ := getResponseFromArg(args.Get(2)) + require.Equal(t, webapicapabilities.TriggerResponsePayload{Status: "ACCEPTED"}, resp) + }).Return(nil).Once() + + th.trigger.HandleGatewayMessage(ctx, "gateway1", gatewayRequest) + + received, chanErr := requireChanMsg(t, channel) + require.Equal(t, received.Event.TriggerType, TriggerType) + require.NoError(t, chanErr) + + requireNoChanMsg(t, channel2) + data := received.Event.Outputs + var payload webapicapabilities.TriggerRequestPayload + unwrapErr := data.UnwrapTo(&payload) + require.NoError(t, unwrapErr) + require.Equal(t, payload.Topics, []string{"daily_price_update"}) + }) + + t.Run("happy case single different topic 2 workflows.", func(t *testing.T) { + gatewayRequest := gatewayRequest(t, privateKey1, `["ad_hoc_price_update"]`, "") + + th.connector.On("SendToGateway", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + resp, _ := getResponseFromArg(args.Get(2)) + require.Equal(t, webapicapabilities.TriggerResponsePayload{Status: "ACCEPTED"}, resp) + }).Return(nil).Once() + + th.trigger.HandleGatewayMessage(ctx, "gateway1", gatewayRequest) + + sent := <-channel + require.Equal(t, sent.Event.TriggerType, TriggerType) + data := sent.Event.Outputs + var payload webapicapabilities.TriggerRequestPayload + unwrapErr := data.UnwrapTo(&payload) + require.NoError(t, unwrapErr) + require.Equal(t, payload.Topics, []string{"ad_hoc_price_update"}) + + sent2 := <-channel2 + require.Equal(t, sent2.Event.TriggerType, TriggerType) + data2 := sent2.Event.Outputs + var payload2 webapicapabilities.TriggerRequestPayload + err2 := data2.UnwrapTo(&payload2) + require.NoError(t, err2) + require.Equal(t, payload2.Topics, []string{"ad_hoc_price_update"}) + }) + + t.Run("sad case empty topic 2 workflows", func(t *testing.T) { + gatewayRequest := gatewayRequest(t, privateKey1, `[]`, "") + + th.connector.On("SendToGateway", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + resp, _ := getResponseFromArg(args.Get(2)) + require.Equal(t, webapicapabilities.TriggerResponsePayload{Status: "ERROR", ErrorMessage: "empty Workflow Topics"}, resp) + }).Return(nil).Once() + + th.trigger.HandleGatewayMessage(ctx, "gateway1", gatewayRequest) + + requireNoChanMsg(t, channel) + requireNoChanMsg(t, channel2) + }) + + t.Run("sad case topic with no workflows", func(t *testing.T) { + gatewayRequest := gatewayRequest(t, privateKey1, `["foo"]`, "") + th.connector.On("SendToGateway", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + resp, _ := getResponseFromArg(args.Get(2)) + require.Equal(t, webapicapabilities.TriggerResponsePayload{Status: "ERROR", ErrorMessage: "no Matching Workflow Topics"}, resp) + }).Return(nil).Once() + + th.trigger.HandleGatewayMessage(ctx, "gateway1", gatewayRequest) + requireNoChanMsg(t, channel) + requireNoChanMsg(t, channel2) + }) + + t.Run("sad case Not Allowed Sender", func(t *testing.T) { + gatewayRequest := gatewayRequest(t, privateKey2, `["ad_hoc_price_update"]`, "") + th.connector.On("SendToGateway", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + resp, _ := getResponseFromArg(args.Get(2)) + + require.Equal(t, webapicapabilities.TriggerResponsePayload{Status: "ERROR", ErrorMessage: "unauthorized Sender 0x2dAC9f74Ee66e2D55ea1B8BE284caFedE048dB3A, messageID 12345"}, resp) + }).Return(nil).Once() + + th.trigger.HandleGatewayMessage(ctx, "gateway1", gatewayRequest) + requireNoChanMsg(t, channel) + requireNoChanMsg(t, channel2) + }) + + t.Run("sad case Invalid Method", func(t *testing.T) { + gatewayRequest := gatewayRequest(t, privateKey2, `["ad_hoc_price_update"]`, "boo") + th.connector.On("SendToGateway", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + resp, _ := getResponseFromArg(args.Get(2)) + require.Equal(t, webapicapabilities.TriggerResponsePayload{Status: "ERROR", ErrorMessage: "unsupported method boo"}, resp) + }).Return(nil).Once() + + th.trigger.HandleGatewayMessage(ctx, "gateway1", gatewayRequest) + requireNoChanMsg(t, channel) + requireNoChanMsg(t, channel2) + }) + + err = th.trigger.UnregisterTrigger(ctx, triggerReq) + require.NoError(t, err) + err = th.trigger.UnregisterTrigger(ctx, triggerReq2) + require.NoError(t, err) + cancelContext() +} + +func TestRegisterNoAllowedSenders(t *testing.T) { + th := setup(t) + ctx := testutils.Context(t) + Config, _ := workflowTriggerConfig(th, []string{}, []string{"daily_price_update"}) + + triggerReq := capabilities.TriggerRegistrationRequest{ + TriggerID: triggerID1, + Metadata: capabilities.RequestMetadata{ + WorkflowID: workflowID1, + WorkflowOwner: owner1, + }, + Config: Config, + } + _, err := th.trigger.RegisterTrigger(ctx, triggerReq) + require.Error(t, err) + + gatewayRequest(t, privateKey1, `["daily_price_update"]`, "") +} + +func TestTriggerExecute2WorkflowsSameTopicDifferentAllowLists(t *testing.T) { + th := setup(t) + ctx := testutils.Context(t) + ctx, cancelContext := context.WithDeadline(ctx, time.Now().Add(10*time.Second)) + Config, _ := workflowTriggerConfig(th, []string{address2}, []string{"daily_price_update"}) + triggerReq := capabilities.TriggerRegistrationRequest{ + TriggerID: triggerID1, + Metadata: capabilities.RequestMetadata{ + WorkflowID: workflowID1, + WorkflowOwner: owner1, + }, + Config: Config, + } + channel, err := th.trigger.RegisterTrigger(ctx, triggerReq) + require.NoError(t, err) + + Config2, err := workflowTriggerConfig(th, []string{address1}, []string{"daily_price_update"}) + require.NoError(t, err) + + triggerReq2 := capabilities.TriggerRegistrationRequest{ + TriggerID: triggerID2, + Metadata: capabilities.RequestMetadata{ + WorkflowID: workflowID1, + WorkflowOwner: owner1, + }, + Config: Config2, + } + channel2, err := th.trigger.RegisterTrigger(ctx, triggerReq2) + require.NoError(t, err) + + t.Run("happy case single topic to single workflow", func(t *testing.T) { + gatewayRequest := gatewayRequest(t, privateKey1, `["daily_price_update"]`, "") + + th.connector.On("SendToGateway", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + resp, _ := getResponseFromArg(args.Get(2)) + require.Equal(t, webapicapabilities.TriggerResponsePayload{Status: "ACCEPTED"}, resp) + }).Return(nil).Once() + + th.trigger.HandleGatewayMessage(ctx, "gateway1", gatewayRequest) + + requireNoChanMsg(t, channel) + received, chanErr := requireChanMsg(t, channel2) + require.Equal(t, received.Event.TriggerType, TriggerType) + require.NoError(t, chanErr) + data := received.Event.Outputs + var payload webapicapabilities.TriggerRequestPayload + unwrapErr := data.UnwrapTo(&payload) + require.NoError(t, unwrapErr) + require.Equal(t, payload.Topics, []string{"daily_price_update"}) + }) + err = th.trigger.UnregisterTrigger(ctx, triggerReq) + require.NoError(t, err) + err = th.trigger.UnregisterTrigger(ctx, triggerReq2) + require.NoError(t, err) + cancelContext() +} + +func TestRegisterUnregister(t *testing.T) { + th := setup(t) + ctx := testutils.Context(t) + Config, err := workflowTriggerConfig(th, []string{address1}, []string{"daily_price_update"}) + require.NoError(t, err) + + triggerReq := capabilities.TriggerRegistrationRequest{ + TriggerID: triggerID1, + Metadata: capabilities.RequestMetadata{ + WorkflowID: workflowID1, + WorkflowOwner: owner1, + }, + Config: Config, + } + + channel, err := th.trigger.RegisterTrigger(ctx, triggerReq) + require.NoError(t, err) + require.NotEmpty(t, th.trigger.registeredWorkflows[triggerID1]) + + err = th.trigger.UnregisterTrigger(ctx, triggerReq) + require.NoError(t, err) + _, open := <-channel + require.Equal(t, open, false) +} diff --git a/core/chains/evm/client/rpc_client.go b/core/chains/evm/client/rpc_client.go index 763348173aa..a29ed5e118c 100644 --- a/core/chains/evm/client/rpc_client.go +++ b/core/chains/evm/client/rpc_client.go @@ -129,7 +129,8 @@ type rpcClient struct { ws rawclient http *rawclient - stateMu sync.RWMutex // protects state* fields + stateMu sync.RWMutex // protects state* fields + subsSliceMu sync.RWMutex // protects subscription slice // Need to track subscriptions because closing the RPC does not (always?) // close the underlying subscription @@ -317,8 +318,8 @@ func (r *rpcClient) getRPCDomain() string { // registerSub adds the sub to the rpcClient list func (r *rpcClient) registerSub(sub ethereum.Subscription, stopInFLightCh chan struct{}) error { - r.stateMu.Lock() - defer r.stateMu.Unlock() + r.subsSliceMu.Lock() + defer r.subsSliceMu.Unlock() // ensure that the `sub` belongs to current life cycle of the `rpcClient` and it should not be killed due to // previous `DisconnectAll` call. select { @@ -335,12 +336,16 @@ func (r *rpcClient) registerSub(sub ethereum.Subscription, stopInFLightCh chan s // DisconnectAll disconnects all clients connected to the rpcClient func (r *rpcClient) DisconnectAll() { r.stateMu.Lock() - defer r.stateMu.Unlock() if r.ws.rpc != nil { r.ws.rpc.Close() } r.cancelInflightRequests() + r.stateMu.Unlock() + + r.subsSliceMu.Lock() r.unsubscribeAll() + r.subsSliceMu.Unlock() + r.chainInfoLock.Lock() r.latestChainInfo = commonclient.ChainInfo{} r.chainInfoLock.Unlock() @@ -496,11 +501,30 @@ func (r *rpcClient) SubscribeNewHead(ctx context.Context, channel chan<- *evmtyp if r.newHeadsPollInterval > 0 { interval := r.newHeadsPollInterval timeout := interval - poller, _ := commonclient.NewPoller[*evmtypes.Head](interval, r.latestBlock, timeout, r.rpcLog) + poller, pollerCh := commonclient.NewPoller[*evmtypes.Head](interval, r.latestBlock, timeout, r.rpcLog) if err = poller.Start(ctx); err != nil { return nil, err } + // NOTE this is a temporary special treatment for SubscribeNewHead before we refactor head tracker to use SubscribeToHeads + // as we need to forward new head from the poller channel to the channel passed from caller. + go func() { + for head := range pollerCh { + select { + case channel <- head: + // forwarding new head to the channel passed from caller + case <-poller.Err(): + // return as poller returns error + return + } + } + }() + + err = r.registerSub(&poller, chStopInFlight) + if err != nil { + return nil, err + } + lggr.Debugf("Polling new heads over http") return &poller, nil } @@ -547,6 +571,11 @@ func (r *rpcClient) SubscribeToHeads(ctx context.Context) (ch <-chan *evmtypes.H return nil, nil, err } + err = r.registerSub(&poller, chStopInFlight) + if err != nil { + return nil, nil, err + } + lggr.Debugf("Polling new heads over http") return channel, &poller, nil } @@ -579,6 +608,8 @@ func (r *rpcClient) SubscribeToHeads(ctx context.Context) (ch <-chan *evmtypes.H } func (r *rpcClient) SubscribeToFinalizedHeads(ctx context.Context) (<-chan *evmtypes.Head, commontypes.Subscription, error) { + ctx, cancel, chStopInFlight, _, _ := r.acquireQueryCtx(ctx, r.rpcTimeout) + defer cancel() interval := r.finalizedBlockPollInterval if interval == 0 { return nil, nil, errors.New("FinalizedBlockPollInterval is 0") @@ -588,6 +619,12 @@ func (r *rpcClient) SubscribeToFinalizedHeads(ctx context.Context) (<-chan *evmt if err := poller.Start(ctx); err != nil { return nil, nil, err } + + err := r.registerSub(&poller, chStopInFlight) + if err != nil { + return nil, nil, err + } + return channel, &poller, nil } diff --git a/core/chains/evm/client/rpc_client_test.go b/core/chains/evm/client/rpc_client_test.go index b594a0ca166..d959f8d1115 100644 --- a/core/chains/evm/client/rpc_client_test.go +++ b/core/chains/evm/client/rpc_client_test.go @@ -19,6 +19,8 @@ import ( "github.com/tidwall/gjson" "go.uber.org/zap" + commontypes "github.com/smartcontractkit/chainlink/v2/common/types" + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/smartcontractkit/chainlink-common/pkg/logger" @@ -57,6 +59,25 @@ func TestRPCClient_SubscribeNewHead(t *testing.T) { } return } + + checkClosedRPCClientShouldRemoveExistingSub := func(t tests.TestingT, ctx context.Context, sub commontypes.Subscription, rpcClient client.RPCClient) { + errCh := sub.Err() + + // ensure sub exists + require.Equal(t, int32(1), rpcClient.SubscribersCount()) + rpcClient.DisconnectAll() + + // ensure sub is closed + select { + case <-errCh: // ok + default: + assert.Fail(t, "channel should be closed") + } + + require.NoError(t, rpcClient.Dial(ctx)) + require.Equal(t, int32(0), rpcClient.SubscribersCount()) + } + t.Run("Updates chain info on new blocks", func(t *testing.T) { server := testutils.NewWSServer(t, chainId, serverCallBack) wsURL := server.WSURL() @@ -131,6 +152,50 @@ func TestRPCClient_SubscribeNewHead(t *testing.T) { assert.Equal(t, int64(0), highestUserObservations.FinalizedBlockNumber) assert.Equal(t, (*big.Int)(nil), highestUserObservations.TotalDifficulty) }) + t.Run("SubscribeToHeads with http polling enabled will update new heads", func(t *testing.T) { + type rpcServer struct { + Head *evmtypes.Head + URL *url.URL + } + createRPCServer := func() *rpcServer { + server := &rpcServer{} + server.Head = &evmtypes.Head{Number: 127} + server.URL = testutils.NewWSServer(t, chainId, func(method string, params gjson.Result) (resp testutils.JSONRPCResponse) { + assert.Equal(t, "eth_getBlockByNumber", method) + if assert.True(t, params.IsArray()) && assert.Equal(t, "latest", params.Array()[0].String()) { + head := server.Head + jsonHead, err := json.Marshal(head) + if err != nil { + panic(fmt.Errorf("failed to marshal head: %w", err)) + } + resp.Result = string(jsonHead) + } + + return + }).WSURL() + return server + } + + server := createRPCServer() + rpc := client.NewRPCClient(lggr, *server.URL, nil, "rpc", 1, chainId, commonclient.Primary, 0, tests.TestInterval, commonclient.QueryTimeout, commonclient.QueryTimeout, "") + defer rpc.Close() + require.NoError(t, rpc.Dial(ctx)) + latest, highestUserObservations := rpc.GetInterceptedChainInfo() + // latest chain info hasn't been initialized + assert.Equal(t, int64(0), latest.BlockNumber) + assert.Equal(t, int64(0), highestUserObservations.BlockNumber) + + headCh, sub, err := rpc.SubscribeToHeads(commonclient.CtxAddHealthCheckFlag(tests.Context(t))) + require.NoError(t, err) + defer sub.Unsubscribe() + + head := <-headCh + assert.Equal(t, server.Head.Number, head.BlockNumber()) + // the http polling subscription should update the head block + latest, highestUserObservations = rpc.GetInterceptedChainInfo() + assert.Equal(t, server.Head.Number, latest.BlockNumber) + assert.Equal(t, server.Head.Number, highestUserObservations.BlockNumber) + }) t.Run("Concurrent Unsubscribe and onNewHead calls do not lead to a deadlock", func(t *testing.T) { const numberOfAttempts = 1000 // need a large number to increase the odds of reproducing the issue server := testutils.NewWSServer(t, chainId, serverCallBack) @@ -184,6 +249,68 @@ func TestRPCClient_SubscribeNewHead(t *testing.T) { require.ErrorContains(t, err, "RPCClient returned error (rpc)") tests.AssertLogEventually(t, observed, "evmclient.Client#EthSubscribe RPC call failure") }) + t.Run("Closed rpc client should remove existing SubscribeNewHead subscription with WS", func(t *testing.T) { + server := testutils.NewWSServer(t, chainId, serverCallBack) + wsURL := server.WSURL() + + rpc := client.NewRPCClient(lggr, *wsURL, nil, "rpc", 1, chainId, commonclient.Primary, 0, 0, commonclient.QueryTimeout, commonclient.QueryTimeout, "") + defer rpc.Close() + require.NoError(t, rpc.Dial(ctx)) + + ch := make(chan *evmtypes.Head) + sub, err := rpc.SubscribeNewHead(tests.Context(t), ch) + require.NoError(t, err) + checkClosedRPCClientShouldRemoveExistingSub(t, ctx, sub, rpc) + }) + t.Run("Closed rpc client should remove existing SubscribeNewHead subscription with HTTP polling", func(t *testing.T) { + server := testutils.NewWSServer(t, chainId, serverCallBack) + wsURL := server.WSURL() + + rpc := client.NewRPCClient(lggr, *wsURL, &url.URL{}, "rpc", 1, chainId, commonclient.Primary, 0, 1, commonclient.QueryTimeout, commonclient.QueryTimeout, "") + defer rpc.Close() + require.NoError(t, rpc.Dial(ctx)) + + ch := make(chan *evmtypes.Head) + sub, err := rpc.SubscribeNewHead(tests.Context(t), ch) + require.NoError(t, err) + checkClosedRPCClientShouldRemoveExistingSub(t, ctx, sub, rpc) + }) + t.Run("Closed rpc client should remove existing SubscribeToHeads subscription with WS", func(t *testing.T) { + server := testutils.NewWSServer(t, chainId, serverCallBack) + wsURL := server.WSURL() + + rpc := client.NewRPCClient(lggr, *wsURL, nil, "rpc", 1, chainId, commonclient.Primary, 0, 0, commonclient.QueryTimeout, commonclient.QueryTimeout, "") + defer rpc.Close() + require.NoError(t, rpc.Dial(ctx)) + + _, sub, err := rpc.SubscribeToHeads(tests.Context(t)) + require.NoError(t, err) + checkClosedRPCClientShouldRemoveExistingSub(t, ctx, sub, rpc) + }) + t.Run("Closed rpc client should remove existing SubscribeToHeads subscription with HTTP polling", func(t *testing.T) { + server := testutils.NewWSServer(t, chainId, serverCallBack) + wsURL := server.WSURL() + + rpc := client.NewRPCClient(lggr, *wsURL, &url.URL{}, "rpc", 1, chainId, commonclient.Primary, 0, 1, commonclient.QueryTimeout, commonclient.QueryTimeout, "") + defer rpc.Close() + require.NoError(t, rpc.Dial(ctx)) + + _, sub, err := rpc.SubscribeToHeads(tests.Context(t)) + require.NoError(t, err) + checkClosedRPCClientShouldRemoveExistingSub(t, ctx, sub, rpc) + }) + t.Run("Closed rpc client should remove existing SubscribeToFinalizedHeads subscription", func(t *testing.T) { + server := testutils.NewWSServer(t, chainId, serverCallBack) + wsURL := server.WSURL() + + rpc := client.NewRPCClient(lggr, *wsURL, &url.URL{}, "rpc", 1, chainId, commonclient.Primary, 1, 0, commonclient.QueryTimeout, commonclient.QueryTimeout, "") + defer rpc.Close() + require.NoError(t, rpc.Dial(ctx)) + + _, sub, err := rpc.SubscribeToFinalizedHeads(tests.Context(t)) + require.NoError(t, err) + checkClosedRPCClientShouldRemoveExistingSub(t, ctx, sub, rpc) + }) t.Run("Subscription error is properly wrapper", func(t *testing.T) { server := testutils.NewWSServer(t, chainId, serverCallBack) wsURL := server.WSURL() diff --git a/core/scripts/gateway/web_api_trigger/invoke_trigger.go b/core/scripts/gateway/web_api_trigger/invoke_trigger.go new file mode 100644 index 00000000000..00bc08b3489 --- /dev/null +++ b/core/scripts/gateway/web_api_trigger/invoke_trigger.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/joho/godotenv" + + "github.com/smartcontractkit/chainlink/v2/core/services/gateway/api" +) + +// https://gateway-us-1.chain.link/web-trigger +// { +// jsonrpc: "2.0", +// id: "...", +// method: "web-trigger", +// params: { +// signature: "...", +// body: { +// don_id: "workflow_123", +// payload: { +// trigger_id: "web-trigger@1.0.0", +// trigger_event_id: "action_1234567890", +// timestamp: 1234567890, +// sub-events: [ +// { +// topics: ["daily_price_update"], +// params: { +// bid: "101", +// ask: "102" +// } +// }, +// { +// topics: ["daily_message", "summary"], +// params: { +// message: "all good!", +// } +// }, +// ] +// } +// } +// } +// } + +func main() { + gatewayURL := flag.String("gateway_url", "http://localhost:5002", "Gateway URL") + privateKey := flag.String("private_key", "65456ffb8af4a2b93959256a8e04f6f2fe0943579fb3c9c3350593aabb89023f", "Private key to sign the message with") + messageID := flag.String("id", "12345", "Request ID") + methodName := flag.String("method", "web_trigger", "Method name") + donID := flag.String("don_id", "workflow_don_1", "DON ID") + + flag.Parse() + + if privateKey == nil || *privateKey == "" { + if err := godotenv.Load(); err != nil { + panic(err) + } + + privateKeyEnvVar := os.Getenv("PRIVATE_KEY") + privateKey = &privateKeyEnvVar + fmt.Println("Loaded private key from .env") + } + + // validate key and extract address + key, err := crypto.HexToECDSA(*privateKey) + if err != nil { + fmt.Println("error parsing private key", err) + return + } + + payload := `{ + "trigger_id": "web-trigger@1.0.0", + "trigger_event_id": "action_1234567890", + "timestamp": 1234567890, + "topics": ["daily_price_update"], + "params": { + "bid": "101", + "ask": "102" + } + } +` + payloadJSON := []byte(payload) + msg := &api.Message{ + Body: api.MessageBody{ + MessageId: *messageID, + Method: *methodName, + DonId: *donID, + Payload: json.RawMessage(payloadJSON), + }, + } + if err = msg.Sign(key); err != nil { + fmt.Println("error signing message", err) + return + } + codec := api.JsonRPCCodec{} + rawMsg, err := codec.EncodeRequest(msg) + if err != nil { + fmt.Println("error JSON-RPC encoding", err) + return + } + + createRequest := func() (req *http.Request, err error) { + req, err = http.NewRequestWithContext(context.Background(), "POST", *gatewayURL, bytes.NewBuffer(rawMsg)) + if err == nil { + req.Header.Set("Content-Type", "application/json") + } + return + } + + client := &http.Client{} + + sendRequest := func() { + req, err2 := createRequest() + if err2 != nil { + fmt.Println("error creating a request", err2) + return + } + + resp, err2 := client.Do(req) + if err2 != nil { + fmt.Println("error sending a request", err2) + return + } + defer resp.Body.Close() + + body, err2 := io.ReadAll(resp.Body) + if err2 != nil { + fmt.Println("error sending a request", err2) + return + } + + var prettyJSON bytes.Buffer + if err2 = json.Indent(&prettyJSON, body, "", " "); err2 != nil { + fmt.Println(string(body)) + } else { + fmt.Println(prettyJSON.String()) + } + } + sendRequest() +} diff --git a/core/scripts/go.mod b/core/scripts/go.mod index bfa6fb88fac..b32ae445022 100644 --- a/core/scripts/go.mod +++ b/core/scripts/go.mod @@ -22,7 +22,7 @@ require ( github.com/prometheus/client_golang v1.20.0 github.com/shopspring/decimal v1.4.0 github.com/smartcontractkit/chainlink-automation v1.0.4 - github.com/smartcontractkit/chainlink-common v0.2.3-0.20240927162447-20630b333f57 + github.com/smartcontractkit/chainlink-common v0.2.3-0.20240930142117-ef04dd443670 github.com/smartcontractkit/chainlink/v2 v2.0.0-00010101000000-000000000000 github.com/smartcontractkit/libocr v0.0.0-20240717100443-f6226e09bee7 github.com/spf13/cobra v1.8.1 @@ -271,7 +271,7 @@ require ( github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/shirou/gopsutil/v3 v3.24.3 // indirect github.com/smartcontractkit/chain-selectors v1.0.23 // indirect - github.com/smartcontractkit/chainlink-ccip v0.0.0-20240927145425-578a50b4d7c5 // indirect + github.com/smartcontractkit/chainlink-ccip v0.0.0-20240930150148-1c731b9602dd // indirect github.com/smartcontractkit/chainlink-cosmos v0.4.1-0.20240911175228-daf2600bb7b7 // indirect github.com/smartcontractkit/chainlink-data-streams v0.0.0-20240916152957-433914114bd2 // indirect github.com/smartcontractkit/chainlink-feeds v0.0.0-20240910155501-42f20443189f // indirect diff --git a/core/scripts/go.sum b/core/scripts/go.sum index b83609e38a7..6433035ca02 100644 --- a/core/scripts/go.sum +++ b/core/scripts/go.sum @@ -1081,10 +1081,10 @@ github.com/smartcontractkit/chain-selectors v1.0.23 h1:D2Eaex4Cw/O7Lg3tX6WklOqnj github.com/smartcontractkit/chain-selectors v1.0.23/go.mod h1:d4Hi+E1zqjy9HqMkjBE5q1vcG9VGgxf5VxiRHfzi2kE= github.com/smartcontractkit/chainlink-automation v1.0.4 h1:iyW181JjKHLNMnDleI8umfIfVVlwC7+n5izbLSFgjw8= github.com/smartcontractkit/chainlink-automation v1.0.4/go.mod h1:u4NbPZKJ5XiayfKHD/v3z3iflQWqvtdhj13jVZXj/cM= -github.com/smartcontractkit/chainlink-ccip v0.0.0-20240927145425-578a50b4d7c5 h1:958op4dZQK46g4zk9gQTj0GLa2G+jbuj4oHAnneJ4PI= -github.com/smartcontractkit/chainlink-ccip v0.0.0-20240927145425-578a50b4d7c5/go.mod h1:Q51RNKBrkqSJ0U2bwGQx4RL2X7c1qUL3NXVZF2kmCUA= -github.com/smartcontractkit/chainlink-common v0.2.3-0.20240927162447-20630b333f57 h1:pRiTiFOkPEyvgG0hchcCSZzwUbwYydnZBu0QbVaRnVk= -github.com/smartcontractkit/chainlink-common v0.2.3-0.20240927162447-20630b333f57/go.mod h1:F6WUS6N4mP5ScwpwyTyAJc9/vjR+GXbMCRUOVekQi1g= +github.com/smartcontractkit/chainlink-ccip v0.0.0-20240930150148-1c731b9602dd h1:16Hwnz4hdmWKOy5qVH9wHfyT1XXM0k31M3naexwzpVo= +github.com/smartcontractkit/chainlink-ccip v0.0.0-20240930150148-1c731b9602dd/go.mod h1:/nGkIe25kgtr+l6y30VH+aTVaxu0NjIEEEhtV1TDlaE= +github.com/smartcontractkit/chainlink-common v0.2.3-0.20240930142117-ef04dd443670 h1:z+XnayyX7pyvVv9OuMQ7oik7RkguQeWHhxcOoVM4oKI= +github.com/smartcontractkit/chainlink-common v0.2.3-0.20240930142117-ef04dd443670/go.mod h1:F6WUS6N4mP5ScwpwyTyAJc9/vjR+GXbMCRUOVekQi1g= github.com/smartcontractkit/chainlink-cosmos v0.4.1-0.20240911175228-daf2600bb7b7 h1:lTGIOQYLk1Ufn++X/AvZnt6VOcuhste5yp+C157No/Q= github.com/smartcontractkit/chainlink-cosmos v0.4.1-0.20240911175228-daf2600bb7b7/go.mod h1:BMYE1vC/pGmdFSsOJdPrAA0/4gZ0Xo0SxTMdGspBtRo= github.com/smartcontractkit/chainlink-data-streams v0.0.0-20240916152957-433914114bd2 h1:yRk4ektpx/UxwarqAfgxUXLrsYXlaNeP1NOwzHGrK2Q= diff --git a/core/services/gateway/handlers/webapicapabilities/handler.go b/core/services/gateway/handlers/webapicapabilities/handler.go index 69e448bfcfa..744bdc17406 100644 --- a/core/services/gateway/handlers/webapicapabilities/handler.go +++ b/core/services/gateway/handlers/webapicapabilities/handler.go @@ -7,6 +7,8 @@ import ( "sync" "time" + "go.uber.org/multierr" + "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/services/gateway/api" "github.com/smartcontractkit/chainlink/v2/core/services/gateway/config" @@ -17,11 +19,16 @@ import ( const ( // NOTE: more methods will go here. HTTP trigger/action/target; etc. - MethodWebAPITarget = "web_api_target" + MethodWebAPITarget = "web_api_target" + MethodWebAPITrigger = "web_api_trigger" ) type handler struct { + config HandlerConfig don handlers.DON + donConfig *config.DONConfig + savedCallbacks map[string]*savedCallback + mu sync.Mutex lggr logger.Logger httpClient network.HTTPClient nodeRateLimiter *common.RateLimiter @@ -29,7 +36,13 @@ type handler struct { } type HandlerConfig struct { - NodeRateLimiter common.RateLimiterConfig `json:"nodeRateLimiter"` + NodeRateLimiter common.RateLimiterConfig `json:"nodeRateLimiter"` + MaxAllowedMessageAgeSec uint `json:"maxAllowedMessageAgeSec"` +} + +type savedCallback struct { + id string + callbackCh chan<- handlers.UserCallbackPayload } var _ handlers.Handler = (*handler)(nil) @@ -46,18 +59,17 @@ func NewHandler(handlerConfig json.RawMessage, donConfig *config.DONConfig, don } return &handler{ + config: cfg, don: don, + donConfig: donConfig, lggr: lggr.Named("WebAPIHandler." + donConfig.DonId), httpClient: httpClient, nodeRateLimiter: nodeRateLimiter, wg: sync.WaitGroup{}, + savedCallbacks: make(map[string]*savedCallback), }, nil } -func (h *handler) HandleUserMessage(ctx context.Context, msg *api.Message, callbackCh chan<- handlers.UserCallbackPayload) error { - return nil -} - // sendHTTPMessageToClient is an outgoing message from the gateway to external endpoints // returns message to be sent back to the capability node func (h *handler) sendHTTPMessageToClient(ctx context.Context, req network.HTTPRequest, msg *api.Message) (*api.Message, error) { @@ -76,6 +88,7 @@ func (h *handler) sendHTTPMessageToClient(ctx context.Context, req network.HTTPR if err != nil { return nil, err } + return &api.Message{ Body: api.MessageBody{ MessageId: msg.Body.MessageId, @@ -147,8 +160,26 @@ func (h *handler) handleWebAPITargetMessage(ctx context.Context, msg *api.Messag return nil } +func (h *handler) handleWebAPITriggerMessage(ctx context.Context, msg *api.Message, nodeAddr string) error { + h.mu.Lock() + savedCb, found := h.savedCallbacks[msg.Body.MessageId] + delete(h.savedCallbacks, msg.Body.MessageId) + h.mu.Unlock() + + if found { + // Send first response from a node back to the user, ignore any other ones. + // TODO: in practice, we should wait for at least 2F+1 nodes to respond and then return an aggregated response + // back to the user. + savedCb.callbackCh <- handlers.UserCallbackPayload{Msg: msg, ErrCode: api.NoError, ErrMsg: ""} + close(savedCb.callbackCh) + } + return nil +} + func (h *handler) HandleNodeMessage(ctx context.Context, msg *api.Message, nodeAddr string) error { switch msg.Body.Method { + case MethodWebAPITrigger: + return h.handleWebAPITriggerMessage(ctx, msg, nodeAddr) case MethodWebAPITarget: return h.handleWebAPITargetMessage(ctx, msg, nodeAddr) default: @@ -164,3 +195,45 @@ func (h *handler) Close() error { h.wg.Wait() return nil } + +func (h *handler) HandleUserMessage(ctx context.Context, msg *api.Message, callbackCh chan<- handlers.UserCallbackPayload) error { + h.mu.Lock() + h.savedCallbacks[msg.Body.MessageId] = &savedCallback{msg.Body.MessageId, callbackCh} + don := h.don + h.mu.Unlock() + body := msg.Body + var payload TriggerRequestPayload + err := json.Unmarshal(body.Payload, &payload) + if err != nil { + h.lggr.Errorw("error decoding payload", "err", err) + callbackCh <- handlers.UserCallbackPayload{Msg: msg, ErrCode: api.UserMessageParseError, ErrMsg: fmt.Sprintf("error decoding payload %s", err.Error())} + close(callbackCh) + return nil + } + + if payload.Timestamp == 0 { + h.lggr.Errorw("error decoding payload") + callbackCh <- handlers.UserCallbackPayload{Msg: msg, ErrCode: api.UserMessageParseError, ErrMsg: "error decoding payload"} + close(callbackCh) + return nil + } + + if uint(time.Now().Unix())-h.config.MaxAllowedMessageAgeSec > uint(payload.Timestamp) { + callbackCh <- handlers.UserCallbackPayload{Msg: msg, ErrCode: api.HandlerError, ErrMsg: "stale message"} + close(callbackCh) + return nil + } + // TODO: apply allowlist and rate-limiting here + if msg.Body.Method != MethodWebAPITrigger { + h.lggr.Errorw("unsupported method", "method", body.Method) + callbackCh <- handlers.UserCallbackPayload{Msg: msg, ErrCode: api.HandlerError, ErrMsg: fmt.Sprintf("invalid method %s", msg.Body.Method)} + close(callbackCh) + return nil + } + + // Send to all nodes. + for _, member := range h.donConfig.Members { + err = multierr.Combine(err, don.SendToNode(ctx, member.Address, msg)) + } + return err +} diff --git a/core/services/gateway/handlers/webapicapabilities/handler_test.go b/core/services/gateway/handlers/webapicapabilities/handler_test.go index e8202b36cb8..599ac1291e2 100644 --- a/core/services/gateway/handlers/webapicapabilities/handler_test.go +++ b/core/services/gateway/handlers/webapicapabilities/handler_test.go @@ -9,18 +9,36 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "strconv" + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/services/gateway/api" gwcommon "github.com/smartcontractkit/chainlink/v2/core/services/gateway/common" "github.com/smartcontractkit/chainlink/v2/core/services/gateway/config" + "github.com/smartcontractkit/chainlink/v2/core/services/gateway/handlers" "github.com/smartcontractkit/chainlink/v2/core/services/gateway/handlers/common" handlermocks "github.com/smartcontractkit/chainlink/v2/core/services/gateway/handlers/mocks" "github.com/smartcontractkit/chainlink/v2/core/services/gateway/network" "github.com/smartcontractkit/chainlink/v2/core/services/gateway/network/mocks" ) +const ( + defaultSendChannelBufferSize = 1000 + privateKey1 = "65456ffb8af4a2b93959256a8e04f6f2fe0943579fb3c9c3350593aabb89023f" + privateKey2 = "65456ffb8af4a2b93959256a8e04f6f2fe0943579fb3c9c3350593aabb89023e" + triggerID1 = "5" + triggerID2 = "6" + workflowID1 = "15c631d295ef5e32deb99a10ee6804bc4af13855687559d7ff6552ac6dbb2ce0" + workflowExecutionID1 = "95ef5e32deb99a10ee6804bc4af13855687559d7ff6552ac6dbb2ce0abbadeed" + owner1 = "0x00000000000000000000000000000000000000aa" + address1 = "0x853d51d5d9935964267a5050aC53aa63ECA39bc5" +) + func setupHandler(t *testing.T) (*handler, *mocks.HTTPClient, *handlermocks.DON, []gwcommon.TestNode) { lggr := logger.TestLogger(t) httpClient := mocks.NewHTTPClient(t) @@ -32,8 +50,10 @@ func setupHandler(t *testing.T) (*handler, *mocks.HTTPClient, *handlermocks.DON, PerSenderBurst: 100, } handlerConfig := HandlerConfig{ - NodeRateLimiter: nodeRateLimiterConfig, + NodeRateLimiter: nodeRateLimiterConfig, + MaxAllowedMessageAgeSec: 30, } + cfgBytes, err := json.Marshal(handlerConfig) require.NoError(t, err) donConfig := &config.DONConfig{ @@ -47,9 +67,7 @@ func setupHandler(t *testing.T) (*handler, *mocks.HTTPClient, *handlermocks.DON, Address: n.Address, }) } - handler, err := NewHandler(json.RawMessage(cfgBytes), donConfig, don, httpClient, lggr) - require.NoError(t, err) return handler, httpClient, don, nodes } @@ -168,3 +186,127 @@ func TestHandler_SendHTTPMessageToClient(t *testing.T) { }, tests.WaitTimeout(t), 100*time.Millisecond) }) } + +func triggerRequest(t *testing.T, privateKey string, topics string, methodName string, timestamp string, payload string) *api.Message { + messageID := "12345" + if methodName == "" { + methodName = MethodWebAPITrigger + } + if timestamp == "" { + timestamp = strconv.FormatInt(time.Now().Unix(), 10) + } + donID := "workflow_don_1" + + key, err := crypto.HexToECDSA(privateKey) + require.NoError(t, err) + if payload == "" { + payload = `{ + "trigger_id": "web-trigger@1.0.0", + "trigger_event_id": "action_1234567890", + "timestamp": ` + timestamp + `, + "topics": ` + topics + `, + "params": { + "bid": "101", + "ask": "102" + } + } + ` + } + payloadJSON := []byte(payload) + msg := &api.Message{ + Body: api.MessageBody{ + MessageId: messageID, + Method: methodName, + DonId: donID, + Payload: json.RawMessage(payloadJSON), + }, + } + err = msg.Sign(key) + require.NoError(t, err) + return msg +} + +func requireNoChanMsg[T any](t *testing.T, ch <-chan T) { + timedOut := false + select { + case <-ch: + case <-time.After(100 * time.Millisecond): + timedOut = true + } + require.True(t, timedOut) +} + +func TestHandlerReceiveHTTPMessageFromClient(t *testing.T) { + handler, _, don, _ := setupHandler(t) + ctx := testutils.Context(t) + msg := triggerRequest(t, privateKey1, `["daily_price_update"]`, "", "", "") + + t.Run("happy case", func(t *testing.T) { + ch := make(chan handlers.UserCallbackPayload, defaultSendChannelBufferSize) + + // sends to 2 dons + don.On("SendToNode", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + require.Equal(t, msg, args.Get(2)) + }).Return(nil).Once() + don.On("SendToNode", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + require.Equal(t, msg, args.Get(2)) + }).Return(nil).Once() + + err := handler.HandleUserMessage(ctx, msg, ch) + require.NoError(t, err) + requireNoChanMsg(t, ch) + + err = handler.HandleNodeMessage(ctx, msg, "") + require.NoError(t, err) + + resp := <-ch + require.Equal(t, handlers.UserCallbackPayload{Msg: msg, ErrCode: api.NoError, ErrMsg: ""}, resp) + _, open := <-ch + require.Equal(t, open, false) + }) + + t.Run("sad case invalid method", func(t *testing.T) { + invalidMsg := triggerRequest(t, privateKey1, `["daily_price_update"]`, "foo", "", "") + ch := make(chan handlers.UserCallbackPayload, defaultSendChannelBufferSize) + err := handler.HandleUserMessage(ctx, invalidMsg, ch) + require.NoError(t, err) + resp := <-ch + require.Equal(t, handlers.UserCallbackPayload{Msg: invalidMsg, ErrCode: api.HandlerError, ErrMsg: "invalid method foo"}, resp) + _, open := <-ch + require.Equal(t, open, false) + }) + + t.Run("sad case stale message", func(t *testing.T) { + invalidMsg := triggerRequest(t, privateKey1, `["daily_price_update"]`, "", "123456", "") + ch := make(chan handlers.UserCallbackPayload, defaultSendChannelBufferSize) + err := handler.HandleUserMessage(ctx, invalidMsg, ch) + require.NoError(t, err) + resp := <-ch + require.Equal(t, handlers.UserCallbackPayload{Msg: invalidMsg, ErrCode: api.HandlerError, ErrMsg: "stale message"}, resp) + _, open := <-ch + require.Equal(t, open, false) + }) + + t.Run("sad case empty payload", func(t *testing.T) { + invalidMsg := triggerRequest(t, privateKey1, `["daily_price_update"]`, "", "123456", "{}") + ch := make(chan handlers.UserCallbackPayload, defaultSendChannelBufferSize) + err := handler.HandleUserMessage(ctx, invalidMsg, ch) + require.NoError(t, err) + resp := <-ch + require.Equal(t, handlers.UserCallbackPayload{Msg: invalidMsg, ErrCode: api.UserMessageParseError, ErrMsg: "error decoding payload"}, resp) + _, open := <-ch + require.Equal(t, open, false) + }) + + t.Run("sad case invalid payload", func(t *testing.T) { + invalidMsg := triggerRequest(t, privateKey1, `["daily_price_update"]`, "", "123456", `{"foo":"bar"}`) + ch := make(chan handlers.UserCallbackPayload, defaultSendChannelBufferSize) + err := handler.HandleUserMessage(ctx, invalidMsg, ch) + require.NoError(t, err) + resp := <-ch + require.Equal(t, handlers.UserCallbackPayload{Msg: invalidMsg, ErrCode: api.UserMessageParseError, ErrMsg: "error decoding payload"}, resp) + _, open := <-ch + require.Equal(t, open, false) + }) + // TODO: Validate Senders and rate limit chck, pending question in trigger about where senders and rate limits are validated +} diff --git a/core/services/gateway/handlers/webapicapabilities/webapi.go b/core/services/gateway/handlers/webapicapabilities/webapi.go index 3e80d924e54..25f3bca6c1d 100644 --- a/core/services/gateway/handlers/webapicapabilities/webapi.go +++ b/core/services/gateway/handlers/webapicapabilities/webapi.go @@ -1,5 +1,9 @@ package webapicapabilities +import ( + "github.com/smartcontractkit/chainlink-common/pkg/values" +) + type TargetRequestPayload struct { URL string `json:"url"` // URL to query, only http and https protocols are supported. Method string `json:"method,omitempty"` // HTTP verb, defaults to GET. @@ -15,3 +19,47 @@ type TargetResponsePayload struct { Headers map[string]string `json:"headers,omitempty"` // HTTP headers Body []byte `json:"body,omitempty"` // HTTP response body } + +// https://gateway-us-1.chain.link/web-trigger +// +// { +// jsonrpc: "2.0", +// id: "...", +// method: "web-trigger", +// params: { +// signature: "...", +// body: { +// don_id: "workflow_123", +// payload: { +// trigger_id: "web-trigger@1.0.0", +// trigger_event_id: "action_1234567890", +// timestamp: 1234567890, +// topics: ["daily_price_update"], +// params: { +// bid: "101", +// ask: "102" +// } +// } +// } +// } +// } +// +// from Web API Trigger Doc, with modifications. +// trigger_id - ID of the trigger corresponding to the capability ID +// trigger_event_id - uniquely identifies generated event (scoped to trigger_id and sender) +// timestamp - timestamp of the event (unix time), needs to be within certain freshness to be processed +// topics - an array of a single topic (string) to be started by this event +// params - key-value pairs for the workflow engine, untranslated. +type TriggerRequestPayload struct { + TriggerID string `json:"trigger_id"` + TriggerEventID string `json:"trigger_event_id"` + Timestamp int64 `json:"timestamp"` + Topics []string `json:"topics"` + Params values.Map `json:"params"` +} + +type TriggerResponsePayload struct { + ErrorMessage string `json:"error_message,omitempty"` + // ERROR, ACCEPTED, PENDING, COMPLETED + Status string `json:"status"` +} diff --git a/core/services/relay/evm/capabilities/log_event_trigger_test.go b/core/services/relay/evm/capabilities/log_event_trigger_test.go new file mode 100644 index 00000000000..f2104529b7f --- /dev/null +++ b/core/services/relay/evm/capabilities/log_event_trigger_test.go @@ -0,0 +1,89 @@ +package logevent_test + +import ( + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/services/servicetest" + commontypes "github.com/smartcontractkit/chainlink-common/pkg/types" + commonmocks "github.com/smartcontractkit/chainlink-common/pkg/types/core/mocks" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/triggers/logevent" + coretestutils "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/services/relay/evm/capabilities/testutils" +) + +// Test for Log Event Trigger Capability happy path for EVM +func TestLogEventTriggerEVMHappyPath(t *testing.T) { + th := testutils.NewContractReaderTH(t) + + logEventConfig := logevent.Config{ + ChainID: th.BackendTH.ChainID.String(), + Network: "evm", + LookbackBlocks: 1000, + PollPeriod: 1000, + } + + // Create a new contract reader to return from mock relayer + ctx := coretestutils.Context(t) + + // Fetch latest head from simulated backend to return from mock relayer + height, err := th.BackendTH.EVMClient.LatestBlockHeight(ctx) + require.NoError(t, err) + block, err := th.BackendTH.EVMClient.BlockByNumber(ctx, height) + require.NoError(t, err) + + // Mock relayer to return a New ContractReader instead of gRPC client of a ContractReader + relayer := commonmocks.NewRelayer(t) + relayer.On("NewContractReader", mock.Anything, th.LogEmitterContractReaderCfg).Return(th.LogEmitterContractReader, nil).Once() + relayer.On("LatestHead", mock.Anything).Return(commontypes.Head{ + Height: height.String(), + Hash: block.Hash().Bytes(), + Timestamp: block.Time(), + }, nil).Once() + + // Create Log Event Trigger Service and register trigger + logEventTriggerService, err := logevent.NewTriggerService(ctx, + th.BackendTH.Lggr, + relayer, + logEventConfig) + require.NoError(t, err) + + // Start the service + servicetest.Run(t, logEventTriggerService) + + log1Ch, err := logEventTriggerService.RegisterTrigger(ctx, th.LogEmitterRegRequest) + require.NoError(t, err) + + expectedLogVal := int64(10) + + // Send a blockchain transaction that emits logs + done := make(chan struct{}) + t.Cleanup(func() { <-done }) + go func() { + defer close(done) + _, err = + th.LogEmitterContract.EmitLog1(th.BackendTH.ContractsOwner, []*big.Int{big.NewInt(expectedLogVal)}) + assert.NoError(t, err) + th.BackendTH.Backend.Commit() + th.BackendTH.Backend.Commit() + th.BackendTH.Backend.Commit() + }() + + // Wait for logs with a timeout + _, output, err := testutils.WaitForLog(th.BackendTH.Lggr, log1Ch, 15*time.Second) + require.NoError(t, err) + th.BackendTH.Lggr.Infow("EmitLog", "output", output) + // Verify if valid cursor is returned + cursor, err := testutils.GetStrVal(output, "Cursor") + require.NoError(t, err) + require.True(t, len(cursor) > 60) + // Verify if Arg0 is correct + actualLogVal, err := testutils.GetBigIntValL2(output, "Data", "Arg0") + require.NoError(t, err) + require.Equal(t, expectedLogVal, actualLogVal.Int64()) +} diff --git a/core/services/relay/evm/capabilities/testutils/backend.go b/core/services/relay/evm/capabilities/testutils/backend.go new file mode 100644 index 00000000000..ef5761b3e4c --- /dev/null +++ b/core/services/relay/evm/capabilities/testutils/backend.go @@ -0,0 +1,120 @@ +package testutils + +import ( + "context" + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/accounts/abi/bind/backends" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/eth/ethconfig" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/types" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" + evmclient "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/headtracker" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/logpoller" + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils/pgtest" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/ethkey" + "github.com/smartcontractkit/chainlink/v2/core/services/relay/evm" + evmrelaytypes "github.com/smartcontractkit/chainlink/v2/core/services/relay/evm/types" +) + +// Test harness with EVM backend and chainlink core services like +// Log Poller and Head Tracker +type EVMBackendTH struct { + // Backend details + Lggr logger.Logger + ChainID *big.Int + Backend *backends.SimulatedBackend + EVMClient evmclient.Client + + ContractsOwner *bind.TransactOpts + ContractsOwnerKey ethkey.KeyV2 + + HeadTracker logpoller.HeadTracker + LogPoller logpoller.LogPoller +} + +// Test harness to create a simulated backend for testing a LOOPCapability +func NewEVMBackendTH(t *testing.T) *EVMBackendTH { + lggr := logger.TestLogger(t) + + ownerKey := cltest.MustGenerateRandomKey(t) + contractsOwner, err := bind.NewKeyedTransactorWithChainID(ownerKey.ToEcdsaPrivKey(), testutils.SimulatedChainID) + require.NoError(t, err) + + // Setup simulated go-ethereum EVM backend + genesisData := core.GenesisAlloc{ + contractsOwner.From: {Balance: assets.Ether(100000).ToInt()}, + } + chainID := testutils.SimulatedChainID + gasLimit := uint32(ethconfig.Defaults.Miner.GasCeil) //nolint:gosec + backend := cltest.NewSimulatedBackend(t, genesisData, gasLimit) + blockTime := time.UnixMilli(int64(backend.Blockchain().CurrentHeader().Time)) //nolint:gosec + err = backend.AdjustTime(time.Since(blockTime) - 24*time.Hour) + require.NoError(t, err) + backend.Commit() + + // Setup backend client + client := evmclient.NewSimulatedBackendClient(t, backend, chainID) + + th := &EVMBackendTH{ + Lggr: lggr, + ChainID: chainID, + Backend: backend, + EVMClient: client, + + ContractsOwner: contractsOwner, + ContractsOwnerKey: ownerKey, + } + th.HeadTracker, th.LogPoller = th.SetupCoreServices(t) + + return th +} + +// Setup core services like log poller and head tracker for the simulated backend +func (th *EVMBackendTH) SetupCoreServices(t *testing.T) (logpoller.HeadTracker, logpoller.LogPoller) { + db := pgtest.NewSqlxDB(t) + const finalityDepth = 2 + ht := headtracker.NewSimulatedHeadTracker(th.EVMClient, false, finalityDepth) + lp := logpoller.NewLogPoller( + logpoller.NewORM(testutils.SimulatedChainID, db, th.Lggr), + th.EVMClient, + th.Lggr, + ht, + logpoller.Opts{ + PollPeriod: 100 * time.Millisecond, + FinalityDepth: finalityDepth, + BackfillBatchSize: 3, + RpcBatchSize: 2, + KeepFinalizedBlocksDepth: 1000, + }, + ) + require.NoError(t, ht.Start(testutils.Context(t))) + require.NoError(t, lp.Start(testutils.Context(t))) + t.Cleanup(func() { ht.Close() }) + t.Cleanup(func() { lp.Close() }) + return ht, lp +} + +func (th *EVMBackendTH) NewContractReader(ctx context.Context, t *testing.T, cfg []byte) (types.ContractReader, error) { + crCfg := &evmrelaytypes.ChainReaderConfig{} + if err := json.Unmarshal(cfg, crCfg); err != nil { + return nil, err + } + + svc, err := evm.NewChainReaderService(ctx, th.Lggr, th.LogPoller, th.HeadTracker, th.EVMClient, *crCfg) + if err != nil { + return nil, err + } + + return svc, svc.Start(ctx) +} diff --git a/core/services/relay/evm/capabilities/testutils/chain_reader.go b/core/services/relay/evm/capabilities/testutils/chain_reader.go new file mode 100644 index 00000000000..3f0bf82da81 --- /dev/null +++ b/core/services/relay/evm/capabilities/testutils/chain_reader.go @@ -0,0 +1,169 @@ +package testutils + +import ( + "encoding/json" + "fmt" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + commoncaps "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + commontypes "github.com/smartcontractkit/chainlink-common/pkg/types" + commonvalues "github.com/smartcontractkit/chainlink-common/pkg/values" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/triggers/logevent" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/generated/log_emitter" + coretestutils "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/logger" + evmtypes "github.com/smartcontractkit/chainlink/v2/core/services/relay/evm/types" +) + +// Test harness with EVM backend and chainlink core services like +// Log Poller and Head Tracker +type ContractReaderTH struct { + BackendTH *EVMBackendTH + + LogEmitterAddress *common.Address + LogEmitterContract *log_emitter.LogEmitter + LogEmitterContractReader commontypes.ContractReader + LogEmitterRegRequest commoncaps.TriggerRegistrationRequest + LogEmitterContractReaderCfg []byte +} + +// Creates a new test harness for Contract Reader tests +func NewContractReaderTH(t *testing.T) *ContractReaderTH { + backendTH := NewEVMBackendTH(t) + + // Deploy a test contract LogEmitter for testing ContractReader + logEmitterAddress, _, _, err := + log_emitter.DeployLogEmitter(backendTH.ContractsOwner, backendTH.Backend) + require.NoError(t, err) + logEmitter, err := log_emitter.NewLogEmitter(logEmitterAddress, backendTH.Backend) + require.NoError(t, err) + + // Create new contract reader + reqConfig := logevent.RequestConfig{ + ContractName: "LogEmitter", + ContractAddress: logEmitterAddress.Hex(), + ContractEventName: "Log1", + } + contractReaderCfg := evmtypes.ChainReaderConfig{ + Contracts: map[string]evmtypes.ChainContractReader{ + reqConfig.ContractName: { + ContractPollingFilter: evmtypes.ContractPollingFilter{ + GenericEventNames: []string{reqConfig.ContractEventName}, + }, + ContractABI: log_emitter.LogEmitterABI, + Configs: map[string]*evmtypes.ChainReaderDefinition{ + reqConfig.ContractEventName: { + ChainSpecificName: reqConfig.ContractEventName, + ReadType: evmtypes.Event, + }, + }, + }, + }, + } + + // Encode contractReaderConfig as JSON and decode it into a map[string]any for + // the capability request config. Log Event Trigger capability takes in a + // []byte as ContractReaderConfig to not depend on evm ChainReaderConfig type + // and be chain agnostic + contractReaderCfgBytes, err := json.Marshal(contractReaderCfg) + require.NoError(t, err) + contractReaderCfgMap := make(map[string]any) + err = json.Unmarshal(contractReaderCfgBytes, &contractReaderCfgMap) + require.NoError(t, err) + // Encode the config map as JSON to specify in the expected call in mocked object + // The LogEventTrigger Capability receives a config map, encodes it and + // calls NewContractReader with it + contractReaderCfgBytes, err = json.Marshal(contractReaderCfgMap) + require.NoError(t, err) + + reqConfig.ContractReaderConfig = contractReaderCfgMap + + config, err := commonvalues.WrapMap(reqConfig) + require.NoError(t, err) + req := commoncaps.TriggerRegistrationRequest{ + TriggerID: "logeventtrigger_log1", + Config: config, + Metadata: commoncaps.RequestMetadata{ + ReferenceID: "logeventtrigger", + }, + } + + // Create a new contract reader to return from mock relayer + ctx := coretestutils.Context(t) + contractReader, err := backendTH.NewContractReader(ctx, t, contractReaderCfgBytes) + require.NoError(t, err) + + return &ContractReaderTH{ + BackendTH: backendTH, + + LogEmitterAddress: &logEmitterAddress, + LogEmitterContract: logEmitter, + LogEmitterContractReader: contractReader, + LogEmitterRegRequest: req, + LogEmitterContractReaderCfg: contractReaderCfgBytes, + } +} + +// Wait for a specific log to be emitted to a response channel by ChainReader +func WaitForLog(lggr logger.Logger, logCh <-chan commoncaps.TriggerResponse, timeout time.Duration) ( + *commoncaps.TriggerResponse, map[string]any, error) { + select { + case <-time.After(timeout): + return nil, nil, fmt.Errorf("timeout waiting for Log1 event from ContractReader") + case log := <-logCh: + lggr.Infow("Received log from ContractReader", "event", log.Event.ID) + if log.Err != nil { + return nil, nil, fmt.Errorf("error listening for Log1 event from ContractReader: %v", log.Err) + } + v := make(map[string]any) + err := log.Event.Outputs.UnwrapTo(&v) + if err != nil { + return nil, nil, fmt.Errorf("error unwrapping log to map: (log %v) %v", log.Event.Outputs, log.Err) + } + return &log, v, nil + } +} + +// Get the string value of a key from a generic map[string]any +func GetStrVal(m map[string]any, k string) (string, error) { + v, ok := m[k] + if !ok { + return "", fmt.Errorf("key %s not found", k) + } + vstr, ok := v.(string) + if !ok { + return "", fmt.Errorf("key %s not a string (%T)", k, v) + } + return vstr, nil +} + +// Get int value of a key from a generic map[string]any +func GetBigIntVal(m map[string]any, k string) (*big.Int, error) { + v, ok := m[k] + if !ok { + return nil, fmt.Errorf("key %s not found", k) + } + val, ok := v.(*big.Int) + if !ok { + return nil, fmt.Errorf("key %s not a *big.Int (%T)", k, v) + } + return val, nil +} + +// Get the int value from a map[string]map[string]any +func GetBigIntValL2(m map[string]any, level1Key string, level2Key string) (*big.Int, error) { + v, ok := m[level1Key] + if !ok { + return nil, fmt.Errorf("key %s not found", level1Key) + } + level2Map, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("key %s not a map[string]any (%T)", level1Key, v) + } + return GetBigIntVal(level2Map, level2Key) +} diff --git a/core/services/relay/evm/chain_reader.go b/core/services/relay/evm/chain_reader.go index e5041f5486a..6b9f9411789 100644 --- a/core/services/relay/evm/chain_reader.go +++ b/core/services/relay/evm/chain_reader.go @@ -19,7 +19,6 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/types/query" "github.com/smartcontractkit/chainlink-common/pkg/types/query/primitives" "github.com/smartcontractkit/chainlink-common/pkg/values" - evmclient "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/logpoller" evmtypes "github.com/smartcontractkit/chainlink/v2/core/chains/evm/types" diff --git a/core/services/standardcapabilities/delegate.go b/core/services/standardcapabilities/delegate.go index 15c829fbf84..1e27d2ffb33 100644 --- a/core/services/standardcapabilities/delegate.go +++ b/core/services/standardcapabilities/delegate.go @@ -13,7 +13,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/types" "github.com/smartcontractkit/chainlink-common/pkg/types/core" gatewayconnector "github.com/smartcontractkit/chainlink/v2/core/capabilities/gateway_connector" - "github.com/smartcontractkit/chainlink/v2/core/capabilities/webapi" + trigger "github.com/smartcontractkit/chainlink/v2/core/capabilities/webapi" webapitarget "github.com/smartcontractkit/chainlink/v2/core/capabilities/webapi/target" "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/services/job" @@ -82,7 +82,7 @@ func (d *Delegate) ServicesForSpec(ctx context.Context, spec job.Job) ([]job.Ser return nil, errors.New("gateway connector is required for web API Trigger capability") } connector := d.gatewayConnectorWrapper.GetGatewayConnector() - triggerSrvc, err := webapi.NewTrigger(spec.StandardCapabilitiesSpec.Config, d.registry, connector, log) + triggerSrvc, err := trigger.NewTrigger(spec.StandardCapabilitiesSpec.Config, d.registry, connector, log) if err != nil { return nil, fmt.Errorf("failed to create a Web API Trigger service: %w", err) } diff --git a/go.mod b/go.mod index 5af52202fce..73c38b8c2db 100644 --- a/go.mod +++ b/go.mod @@ -74,8 +74,8 @@ require ( github.com/shopspring/decimal v1.4.0 github.com/smartcontractkit/chain-selectors v1.0.23 github.com/smartcontractkit/chainlink-automation v1.0.4 - github.com/smartcontractkit/chainlink-ccip v0.0.0-20240927145425-578a50b4d7c5 - github.com/smartcontractkit/chainlink-common v0.2.3-0.20240927162447-20630b333f57 + github.com/smartcontractkit/chainlink-ccip v0.0.0-20240930150148-1c731b9602dd + github.com/smartcontractkit/chainlink-common v0.2.3-0.20240930142117-ef04dd443670 github.com/smartcontractkit/chainlink-cosmos v0.4.1-0.20240911175228-daf2600bb7b7 github.com/smartcontractkit/chainlink-data-streams v0.0.0-20240916152957-433914114bd2 github.com/smartcontractkit/chainlink-feeds v0.0.0-20240910155501-42f20443189f diff --git a/go.sum b/go.sum index baea1388fb5..ea1df80842d 100644 --- a/go.sum +++ b/go.sum @@ -1042,10 +1042,10 @@ github.com/smartcontractkit/chain-selectors v1.0.23 h1:D2Eaex4Cw/O7Lg3tX6WklOqnj github.com/smartcontractkit/chain-selectors v1.0.23/go.mod h1:d4Hi+E1zqjy9HqMkjBE5q1vcG9VGgxf5VxiRHfzi2kE= github.com/smartcontractkit/chainlink-automation v1.0.4 h1:iyW181JjKHLNMnDleI8umfIfVVlwC7+n5izbLSFgjw8= github.com/smartcontractkit/chainlink-automation v1.0.4/go.mod h1:u4NbPZKJ5XiayfKHD/v3z3iflQWqvtdhj13jVZXj/cM= -github.com/smartcontractkit/chainlink-ccip v0.0.0-20240927145425-578a50b4d7c5 h1:958op4dZQK46g4zk9gQTj0GLa2G+jbuj4oHAnneJ4PI= -github.com/smartcontractkit/chainlink-ccip v0.0.0-20240927145425-578a50b4d7c5/go.mod h1:Q51RNKBrkqSJ0U2bwGQx4RL2X7c1qUL3NXVZF2kmCUA= -github.com/smartcontractkit/chainlink-common v0.2.3-0.20240927162447-20630b333f57 h1:pRiTiFOkPEyvgG0hchcCSZzwUbwYydnZBu0QbVaRnVk= -github.com/smartcontractkit/chainlink-common v0.2.3-0.20240927162447-20630b333f57/go.mod h1:F6WUS6N4mP5ScwpwyTyAJc9/vjR+GXbMCRUOVekQi1g= +github.com/smartcontractkit/chainlink-ccip v0.0.0-20240930150148-1c731b9602dd h1:16Hwnz4hdmWKOy5qVH9wHfyT1XXM0k31M3naexwzpVo= +github.com/smartcontractkit/chainlink-ccip v0.0.0-20240930150148-1c731b9602dd/go.mod h1:/nGkIe25kgtr+l6y30VH+aTVaxu0NjIEEEhtV1TDlaE= +github.com/smartcontractkit/chainlink-common v0.2.3-0.20240930142117-ef04dd443670 h1:z+XnayyX7pyvVv9OuMQ7oik7RkguQeWHhxcOoVM4oKI= +github.com/smartcontractkit/chainlink-common v0.2.3-0.20240930142117-ef04dd443670/go.mod h1:F6WUS6N4mP5ScwpwyTyAJc9/vjR+GXbMCRUOVekQi1g= github.com/smartcontractkit/chainlink-cosmos v0.4.1-0.20240911175228-daf2600bb7b7 h1:lTGIOQYLk1Ufn++X/AvZnt6VOcuhste5yp+C157No/Q= github.com/smartcontractkit/chainlink-cosmos v0.4.1-0.20240911175228-daf2600bb7b7/go.mod h1:BMYE1vC/pGmdFSsOJdPrAA0/4gZ0Xo0SxTMdGspBtRo= github.com/smartcontractkit/chainlink-data-streams v0.0.0-20240916152957-433914114bd2 h1:yRk4ektpx/UxwarqAfgxUXLrsYXlaNeP1NOwzHGrK2Q= diff --git a/integration-tests/deployment/README.md b/integration-tests/deployment/README.md index 000219c8aba..c0ac1bbc530 100644 --- a/integration-tests/deployment/README.md +++ b/integration-tests/deployment/README.md @@ -20,7 +20,6 @@ environments like testnet/mainnet. - EVM only /deployment/devenv -- Coming soon - package name `devenv` - Docker environment for higher fidelity testing - Support non-EVMs (yet to be implemented) @@ -36,20 +35,22 @@ environments like testnet/mainnet. - package name `changeset` imported as `ccipchangesets` - These function like scripts describing state transitions you wish to apply to _persistent_ environments like testnet/mainnet -- Ordered list of Go functions following the format +- They should be go functions where the first argument is an + environment and the second argument is a config struct which can be unique to the + changeset. The return value should be a `deployment.ChangesetOutput` and an error. ```Go -0001_descriptive_name.go -func Apply0001(env deployment.Environment, c ccipdeployment.Config) (deployment.ChangesetOutput, error) +do_something.go +func DoSomethingChangeSet(env deployment.Environment, c ccipdeployment.Config) (deployment.ChangesetOutput, error) { // Deploy contracts, generate MCMS proposals, generate // job specs according to contracts etc. return deployment.ChangesetOutput{}, nil } -0001_descriptive_name_test.go -func TestApply0001(t *testing.T) +do_something_test.go +func TestDoSomething(t *testing.T) { // Set up memory env - // Apply0001 function + // DoSomethingChangeSet function // Take the artifacts from ChangeSet output // Apply them to the memory env // Send traffic, run assertions etc. diff --git a/integration-tests/deployment/ccip/add_chain_test.go b/integration-tests/deployment/ccip/add_chain_test.go index dbe86b85368..fc39bddcd93 100644 --- a/integration-tests/deployment/ccip/add_chain_test.go +++ b/integration-tests/deployment/ccip/add_chain_test.go @@ -24,8 +24,7 @@ import ( func TestAddChainInbound(t *testing.T) { // 4 chains where the 4th is added after initial deployment. - e := NewEnvironmentWithCRAndJobs(t, logger.TestLogger(t), 4) - require.Equal(t, len(e.Nodes), 5) + e := NewMemoryEnvironmentWithJobs(t, logger.TestLogger(t), 4) state, err := LoadOnchainState(e.Env, e.Ab) require.NoError(t, err) // Take first non-home chain as the new chain. @@ -127,6 +126,9 @@ func TestAddChainInbound(t *testing.T) { ExecuteProposal(t, e.Env, chainInboundExec, state, sel) } + replayBlocks, err := LatestBlocksByChain(testcontext.Get(t), e.Env.Chains) + require.NoError(t, err) + // Now configure the new chain using deployer key (not transferred to timelock yet). var offRampEnables []offramp.OffRampSourceChainConfigArgs for _, source := range initialDeploy { @@ -167,7 +169,7 @@ func TestAddChainInbound(t *testing.T) { } // Ensure job related logs are up to date. time.Sleep(30 * time.Second) - require.NoError(t, ReplayAllLogs(e.Nodes, e.Env.Chains)) + ReplayLogs(t, e.Env.Offchain, replayBlocks) // TODO: Send via all inbound lanes and use parallel helper // Now that the proposal has been executed we expect to be able to send traffic to this new 4th chain. diff --git a/integration-tests/deployment/ccip/add_lane_test.go b/integration-tests/deployment/ccip/add_lane_test.go index 3da74ec11a8..540b5dded54 100644 --- a/integration-tests/deployment/ccip/add_lane_test.go +++ b/integration-tests/deployment/ccip/add_lane_test.go @@ -16,7 +16,7 @@ func TestAddLane(t *testing.T) { // TODO: The offchain code doesn't yet support partial lane // enablement, need to address then re-enable this test. t.Skip() - e := NewEnvironmentWithCRAndJobs(t, logger.TestLogger(t), 3) + e := NewMemoryEnvironmentWithJobs(t, logger.TestLogger(t), 3) // Here we have CR + nodes set up, but no CCIP contracts deployed. state, err := LoadOnchainState(e.Env, e.Ab) require.NoError(t, err) diff --git a/integration-tests/deployment/ccip/changeset/1_cap_reg.go b/integration-tests/deployment/ccip/changeset/cap_reg.go similarity index 79% rename from integration-tests/deployment/ccip/changeset/1_cap_reg.go rename to integration-tests/deployment/ccip/changeset/cap_reg.go index 1ca288321cd..0c12d20d94a 100644 --- a/integration-tests/deployment/ccip/changeset/1_cap_reg.go +++ b/integration-tests/deployment/ccip/changeset/cap_reg.go @@ -7,8 +7,8 @@ import ( ccipdeployment "github.com/smartcontractkit/chainlink/integration-tests/deployment/ccip" ) -// Separate migration because cap reg is an env var for CL nodes. -func Apply0001(env deployment.Environment, homeChainSel uint64) (deployment.ChangesetOutput, error) { +// Separated changset because cap reg is an env var for CL nodes. +func CapRegChangeSet(env deployment.Environment, homeChainSel uint64) (deployment.ChangesetOutput, error) { // Note we also deploy the cap reg. ab, _, err := ccipdeployment.DeployCapReg(env.Logger, env.Chains[homeChainSel]) if err != nil { diff --git a/integration-tests/deployment/ccip/changeset/2_initial_deploy.go b/integration-tests/deployment/ccip/changeset/initial_deploy.go similarity index 88% rename from integration-tests/deployment/ccip/changeset/2_initial_deploy.go rename to integration-tests/deployment/ccip/changeset/initial_deploy.go index 99d16d21c40..9a97150ade7 100644 --- a/integration-tests/deployment/ccip/changeset/2_initial_deploy.go +++ b/integration-tests/deployment/ccip/changeset/initial_deploy.go @@ -12,7 +12,7 @@ import ( // TODO: Maybe there's a generics approach here? // Note if the change set is a deployment and it fails we have 2 options: // - Just throw away the addresses, fix issue and try again (potentially expensive on mainnet) -func Apply0002(env deployment.Environment, c ccipdeployment.DeployCCIPContractConfig) (deployment.ChangesetOutput, error) { +func InitialDeployChangeSet(env deployment.Environment, c ccipdeployment.DeployCCIPContractConfig) (deployment.ChangesetOutput, error) { ab, err := ccipdeployment.DeployCCIPContracts(env, c) if err != nil { env.Logger.Errorw("Failed to deploy CCIP contracts", "err", err, "addresses", ab) diff --git a/integration-tests/deployment/ccip/changeset/2_initial_deploy_test.go b/integration-tests/deployment/ccip/changeset/initial_deploy_test.go similarity index 91% rename from integration-tests/deployment/ccip/changeset/2_initial_deploy_test.go rename to integration-tests/deployment/ccip/changeset/initial_deploy_test.go index 8dc363b0cbb..6f40d41acb9 100644 --- a/integration-tests/deployment/ccip/changeset/2_initial_deploy_test.go +++ b/integration-tests/deployment/ccip/changeset/initial_deploy_test.go @@ -17,13 +17,11 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/logger" ) -func Test0002_InitialDeploy(t *testing.T) { +func TestInitialDeploy(t *testing.T) { lggr := logger.TestLogger(t) ctx := ccdeploy.Context(t) - tenv := ccdeploy.NewEnvironmentWithCRAndFeeds(t, lggr, 3) + tenv := ccdeploy.NewMemoryEnvironment(t, lggr, 3) e := tenv.Env - nodes := tenv.Nodes - chains := e.Chains state, err := ccdeploy.LoadOnchainState(tenv.Env, tenv.Ab) require.NoError(t, err) @@ -37,8 +35,8 @@ func Test0002_InitialDeploy(t *testing.T) { DeviationPPB: cciptypes.NewBigIntFromInt64(1e9), }, ) - // Apply migration - output, err := Apply0002(tenv.Env, ccdeploy.DeployCCIPContractConfig{ + // Apply changeset + output, err := InitialDeployChangeSet(tenv.Env, ccdeploy.DeployCCIPContractConfig{ HomeChainSel: tenv.HomeChainSel, FeedChainSel: tenv.FeedChainSel, ChainsToDeploy: tenv.Env.AllChainSelectors(), @@ -52,7 +50,7 @@ func Test0002_InitialDeploy(t *testing.T) { require.NoError(t, err) // Ensure capreg logs are up to date. - require.NoError(t, ccdeploy.ReplayAllLogs(nodes, chains)) + ccdeploy.ReplayLogs(t, e.Offchain, tenv.ReplayBlocks) // Apply the jobs. for nodeID, jobs := range output.JobSpecs { diff --git a/integration-tests/deployment/ccip/test_helpers.go b/integration-tests/deployment/ccip/test_helpers.go index c306ba4fd6a..59eecae4f29 100644 --- a/integration-tests/deployment/ccip/test_helpers.go +++ b/integration-tests/deployment/ccip/test_helpers.go @@ -9,6 +9,7 @@ import ( "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/pkg/errors" "golang.org/x/sync/errgroup" "github.com/smartcontractkit/chainlink-testing-framework/lib/utils/testcontext" @@ -64,11 +65,39 @@ type DeployedEnv struct { Env deployment.Environment HomeChainSel uint64 FeedChainSel uint64 + ReplayBlocks map[uint64]uint64 } -type DeployedTestEnvironment struct { - DeployedEnv - Nodes map[string]memory.Node +func (e *DeployedEnv) SetupJobs(t *testing.T) { + ctx := testcontext.Get(t) + jbs, err := NewCCIPJobSpecs(e.Env.NodeIDs, e.Env.Offchain) + require.NoError(t, err) + for nodeID, jobs := range jbs { + for _, job := range jobs { + // Note these auto-accept + _, err := e.Env.Offchain.ProposeJob(ctx, + &jobv1.ProposeJobRequest{ + NodeId: nodeID, + Spec: job, + }) + require.NoError(t, err) + } + } + // Wait for plugins to register filters? + // TODO: Investigate how to avoid. + time.Sleep(30 * time.Second) + ReplayLogs(t, e.Env.Offchain, e.ReplayBlocks) +} + +func ReplayLogs(t *testing.T, oc deployment.OffchainClient, replayBlocks map[uint64]uint64) { + switch oc := oc.(type) { + case *memory.JobClient: + require.NoError(t, oc.ReplayLogs(replayBlocks)) + case *devenv.JobDistributor: + require.NoError(t, oc.ReplayLogs(replayBlocks)) + default: + t.Fatalf("unsupported offchain client type %T", oc) + } } func SetUpHomeAndFeedChains(t *testing.T, lggr logger.Logger, homeChainSel, feedChainSel uint64, chains map[uint64]deployment.Chain) (deployment.AddressBook, deployment.CapabilityRegistryConfig) { @@ -85,11 +114,24 @@ func SetUpHomeAndFeedChains(t *testing.T, lggr logger.Logger, homeChainSel, feed } } -// NewEnvironmentWithCRAndFeeds creates a new CCIP environment +func LatestBlocksByChain(ctx context.Context, chains map[uint64]deployment.Chain) (map[uint64]uint64, error) { + latestBlocks := make(map[uint64]uint64) + for _, chain := range chains { + latesthdr, err := chain.Client.HeaderByNumber(ctx, nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to get latest header for chain %d", chain.Selector) + } + block := latesthdr.Number.Uint64() + latestBlocks[chain.Selector] = block + } + return latestBlocks, nil +} + +// NewMemoryEnvironment creates a new CCIP environment // with capreg, feeds and nodes set up. -func NewEnvironmentWithCRAndFeeds(t *testing.T, lggr logger.Logger, numChains int) DeployedTestEnvironment { +func NewMemoryEnvironment(t *testing.T, lggr logger.Logger, numChains int) DeployedEnv { require.GreaterOrEqual(t, numChains, 2, "numChains must be at least 2 for home and feed chains") - ctx := Context(t) + ctx := testcontext.Get(t) chains := memory.NewMemoryChains(t, numChains) // Lower chainSel is home chain. var chainSels []uint64 @@ -103,6 +145,8 @@ func NewEnvironmentWithCRAndFeeds(t *testing.T, lggr logger.Logger, numChains in // Take lowest for determinism. homeChainSel := chainSels[HomeChainIndex] feedSel := chainSels[FeedChainIndex] + replayBlocks, err := LatestBlocksByChain(ctx, chains) + require.NoError(t, err) ab, capReg := SetUpHomeAndFeedChains(t, lggr, homeChainSel, feedSel, chains) nodes := memory.NewNodes(t, zapcore.InfoLevel, chains, 4, 1, capReg) @@ -114,55 +158,21 @@ func NewEnvironmentWithCRAndFeeds(t *testing.T, lggr logger.Logger, numChains in } e := memory.NewMemoryEnvironmentFromChainsNodes(t, lggr, chains, nodes) - return DeployedTestEnvironment{ - DeployedEnv: DeployedEnv{ - Ab: ab, - Env: e, - HomeChainSel: homeChainSel, - FeedChainSel: feedSel, - }, - Nodes: nodes, + return DeployedEnv{ + Ab: ab, + Env: e, + HomeChainSel: homeChainSel, + FeedChainSel: feedSel, + ReplayBlocks: replayBlocks, } } -func NewEnvironmentWithCRAndJobs(t *testing.T, lggr logger.Logger, numChains int) DeployedTestEnvironment { - ctx := Context(t) - e := NewEnvironmentWithCRAndFeeds(t, lggr, numChains) - jbs, err := NewCCIPJobSpecs(e.Env.NodeIDs, e.Env.Offchain) - require.NoError(t, err) - for nodeID, jobs := range jbs { - for _, job := range jobs { - // Note these auto-accept - _, err := e.Env.Offchain.ProposeJob(ctx, - &jobv1.ProposeJobRequest{ - NodeId: nodeID, - Spec: job, - }) - require.NoError(t, err) - } - } - // Wait for plugins to register filters? - // TODO: Investigate how to avoid. - time.Sleep(30 * time.Second) - - // Ensure job related logs are up to date. - require.NoError(t, ReplayAllLogs(e.Nodes, e.Env.Chains)) +func NewMemoryEnvironmentWithJobs(t *testing.T, lggr logger.Logger, numChains int) DeployedEnv { + e := NewMemoryEnvironment(t, lggr, numChains) + e.SetupJobs(t) return e } -func ReplayAllLogs(nodes map[string]memory.Node, chains map[uint64]deployment.Chain) error { - blockBySel := make(map[uint64]uint64) - for sel := range chains { - blockBySel[sel] = 1 - } - for _, node := range nodes { - if err := node.ReplayLogs(blockBySel); err != nil { - return err - } - } - return nil -} - func SendRequest(t *testing.T, e deployment.Environment, state CCIPOnChainState, src, dest uint64, testRouter bool) uint64 { msg := router.ClientEVM2AnyMessage{ Receiver: common.LeftPadBytes(state.Chains[dest].Receiver.Address().Bytes(), 32), @@ -228,8 +238,8 @@ func (d DeployedLocalDevEnvironment) RestartChainlinkNodes(t *testing.T) error { return errGrp.Wait() } -func NewDeployedLocalDevEnvironment(t *testing.T, lggr logger.Logger) DeployedLocalDevEnvironment { - ctx := Context(t) +func NewLocalDevEnvironment(t *testing.T, lggr logger.Logger) DeployedEnv { + ctx := testcontext.Get(t) // create a local docker environment with simulated chains and job-distributor // we cannot create the chainlink nodes yet as we need to deploy the capability registry first envConfig, testEnv, cfg := devenv.CreateDockerEnv(t) @@ -243,6 +253,8 @@ func NewDeployedLocalDevEnvironment(t *testing.T, lggr logger.Logger) DeployedLo require.NotEmpty(t, homeChainSel, "homeChainSel should not be empty") feedSel := envConfig.FeedChainSelector require.NotEmpty(t, feedSel, "feedSel should not be empty") + replayBlocks, err := LatestBlocksByChain(ctx, chains) + require.NoError(t, err) ab, capReg := SetUpHomeAndFeedChains(t, lggr, homeChainSel, feedSel, chains) // start the chainlink nodes with the CR address @@ -252,20 +264,16 @@ func NewDeployedLocalDevEnvironment(t *testing.T, lggr logger.Logger) DeployedLo e, don, err := devenv.NewEnvironment(ctx, lggr, *envConfig) require.NoError(t, err) require.NotNil(t, e) - require.NotNil(t, don) zeroLogLggr := logging.GetTestLogger(t) // fund the nodes devenv.FundNodes(t, zeroLogLggr, testEnv, cfg, don.PluginNodes()) - return DeployedLocalDevEnvironment{ - DeployedEnv: DeployedEnv{ - Ab: ab, - Env: *e, - HomeChainSel: homeChainSel, - FeedChainSel: feedSel, - }, - DON: don, - testEnv: testEnv, + return DeployedEnv{ + Ab: ab, + Env: *e, + HomeChainSel: homeChainSel, + FeedChainSel: feedSel, + ReplayBlocks: replayBlocks, } } diff --git a/integration-tests/deployment/devenv/build_env.go b/integration-tests/deployment/devenv/build_env.go index a36a6f51a69..3e5af0866fa 100644 --- a/integration-tests/deployment/devenv/build_env.go +++ b/integration-tests/deployment/devenv/build_env.go @@ -11,10 +11,14 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" + "github.com/pkg/errors" "github.com/rs/zerolog" chainselectors "github.com/smartcontractkit/chain-selectors" "github.com/stretchr/testify/require" "github.com/subosito/gotenv" + "golang.org/x/sync/errgroup" + + "github.com/smartcontractkit/chainlink-testing-framework/lib/utils/testcontext" "github.com/smartcontractkit/chainlink-testing-framework/lib/utils/conversions" "github.com/smartcontractkit/chainlink-testing-framework/seth" @@ -207,7 +211,10 @@ func StartChainlinkNodes( InternalIP: n.API.InternalIP(), } } - envConfig.nodeInfo = nodeInfo + if envConfig == nil { + envConfig = &EnvironmentConfig{} + } + envConfig.JDConfig.nodeInfo = nodeInfo return nil } @@ -345,3 +352,26 @@ func CreateChainConfigFromNetworks( } return chains } + +// RestartChainlinkNodes restarts the chainlink nodes in the test environment +func RestartChainlinkNodes(t *testing.T, env *test_env.CLClusterTestEnv) error { + errGrp := errgroup.Group{} + if env == nil || env.ClCluster == nil { + return errors.Wrap(errors.New("no testenv or clcluster found "), "error restarting node") + } + for _, n := range env.ClCluster.Nodes { + n := n + errGrp.Go(func() error { + if err := n.Container.Terminate(testcontext.Get(t)); err != nil { + return err + } + err := n.RestartContainer() + if err != nil { + return err + } + return nil + }) + + } + return errGrp.Wait() +} diff --git a/integration-tests/deployment/devenv/don.go b/integration-tests/deployment/devenv/don.go index 9478b729f29..0b10bd091a8 100644 --- a/integration-tests/deployment/devenv/don.go +++ b/integration-tests/deployment/devenv/don.go @@ -5,10 +5,12 @@ import ( "fmt" "strconv" "strings" + "time" "github.com/AlekSi/pointer" "github.com/hashicorp/go-multierror" "github.com/rs/zerolog" + "github.com/sethvargo/go-retry" chainsel "github.com/smartcontractkit/chain-selectors" clclient "github.com/smartcontractkit/chainlink/integration-tests/client" @@ -160,7 +162,7 @@ type Node struct { // It expects bootstrap nodes to have label with key "type" and value as "bootstrap". // It fetches the account address, peer id, and OCR2 key bundle id and creates the JobDistributorChainConfig. func (n *Node) CreateCCIPOCRSupportedChains(ctx context.Context, chains []ChainConfig, jd JobDistributor) error { - for _, chain := range chains { + for i, chain := range chains { chainId := strconv.FormatUint(chain.ChainID, 10) accountAddr, err := n.gqlClient.FetchAccountAddress(ctx, chainId) if err != nil { @@ -198,10 +200,11 @@ func (n *Node) CreateCCIPOCRSupportedChains(ctx context.Context, chains []ChainC } // JD silently fails to update nodeChainConfig. Therefore, we fetch the node config and // if it's not updated , we retry creating the chain config. - // as a workaround, we keep trying creating the chain config for 3 times until it's created + // as a workaround, we keep trying creating the chain config for 5 times until it's created retryCount := 1 - for retryCount < 3 { - err = n.gqlClient.CreateJobDistributorChainConfig(ctx, client.JobDistributorChainConfigInput{ + created := false + for retryCount < 5 { + chainConfigId, err := n.gqlClient.CreateJobDistributorChainConfig(ctx, client.JobDistributorChainConfigInput{ JobDistributorID: n.JDId, ChainID: chainId, ChainType: chain.ChainType, @@ -217,30 +220,66 @@ func (n *Node) CreateCCIPOCRSupportedChains(ctx context.Context, chains []ChainC if err != nil { return fmt.Errorf("failed to create CCIPOCR2SupportedChains for node %s: %w", n.Name, err) } - - nodeChainConfigs, err := jd.ListNodeChainConfigs(context.Background(), &nodev1.ListNodeChainConfigsRequest{ - Filter: &nodev1.ListNodeChainConfigsRequest_Filter{ - NodeIds: []string{n.NodeId}, - }}) - if err != nil { - return fmt.Errorf("failed to list node chain configs for node %s: %w", n.Name, err) - } - if nodeChainConfigs != nil && len(nodeChainConfigs.ChainConfigs) > 0 { + // JD doesn't update the node chain config immediately, so we need to wait for it to be updated + err = retry.Do(ctx, retry.WithMaxRetries(3, retry.NewFibonacci(1*time.Second)), func(ctx context.Context) error { + nodeChainConfigs, err := jd.ListNodeChainConfigs(context.Background(), &nodev1.ListNodeChainConfigsRequest{ + Filter: &nodev1.ListNodeChainConfigsRequest_Filter{ + NodeIds: []string{n.NodeId}, + }}) + if err != nil { + return fmt.Errorf("failed to list node chain configs for node %s: %w", n.Name, err) + } + if nodeChainConfigs != nil && len(nodeChainConfigs.ChainConfigs) == i+1 { + return nil + } + return fmt.Errorf("node chain config not updated properly") + }) + if err == nil { + created = true break } + // delete the node chain config if it's not updated properly and retry + err = n.gqlClient.DeleteJobDistributorChainConfig(ctx, chainConfigId) + if err != nil { + return fmt.Errorf("failed to delete job distributor chain config for node %s: %w", n.Name, err) + } + retryCount++ } + if !created { + return fmt.Errorf("failed to create CCIPOCR2SupportedChains for node %s", n.Name) + } } return nil } -func (n *Node) AcceptJob(ctx context.Context, id string) error { - spec, err := n.gqlClient.ApproveJobProposalSpec(ctx, id, false) +// AcceptJob accepts the job proposal for the given job proposal spec +func (n *Node) AcceptJob(ctx context.Context, spec string) error { + // fetch JD to get the job proposals + jd, err := n.gqlClient.GetJobDistributor(ctx, n.JDId) + if err != nil { + return err + } + if jd.GetJobProposals() == nil { + return fmt.Errorf("no job proposals found for node %s", n.Name) + } + // locate the job proposal id for the given job spec + var idToAccept string + for _, jp := range jd.JobProposals { + if jp.LatestSpec.Definition == spec { + idToAccept = jp.Id + break + } + } + if idToAccept == "" { + return fmt.Errorf("no job proposal found for job spec %s", spec) + } + approvedSpec, err := n.gqlClient.ApproveJobProposalSpec(ctx, idToAccept, false) if err != nil { return err } - if spec == nil { - return fmt.Errorf("no job proposal spec found for job id %s", id) + if approvedSpec == nil { + return fmt.Errorf("no job proposal spec found for job id %s", idToAccept) } return nil } diff --git a/integration-tests/deployment/devenv/environment.go b/integration-tests/deployment/devenv/environment.go index 74cde258cc3..e06b69769f8 100644 --- a/integration-tests/deployment/devenv/environment.go +++ b/integration-tests/deployment/devenv/environment.go @@ -17,7 +17,6 @@ type EnvironmentConfig struct { Chains []ChainConfig HomeChainSelector uint64 FeedChainSelector uint64 - nodeInfo []NodeInfo JDConfig JDConfig } @@ -26,25 +25,24 @@ func NewEnvironment(ctx context.Context, lggr logger.Logger, config EnvironmentC if err != nil { return nil, nil, fmt.Errorf("failed to create chains: %w", err) } - offChain, err := NewJDClient(config.JDConfig) + offChain, err := NewJDClient(ctx, config.JDConfig) if err != nil { return nil, nil, fmt.Errorf("failed to create JD client: %w", err) } - jd, ok := offChain.(JobDistributor) + jd, ok := offChain.(*JobDistributor) if !ok { return nil, nil, fmt.Errorf("offchain client does not implement JobDistributor") } - don, err := NewRegisteredDON(ctx, config.nodeInfo, jd) - if err != nil { - return nil, nil, fmt.Errorf("failed to create registered DON: %w", err) + if jd == nil || jd.don == nil { + return nil, nil, fmt.Errorf("offchain client does not have a DON") } - nodeIDs := don.NodeIds() - err = don.CreateSupportedChains(ctx, config.Chains, jd) + err = jd.don.CreateSupportedChains(ctx, config.Chains, *jd) if err != nil { return nil, nil, err } + nodeIDs := jd.don.NodeIds() return &deployment.Environment{ Name: DevEnv, @@ -52,5 +50,5 @@ func NewEnvironment(ctx context.Context, lggr logger.Logger, config EnvironmentC NodeIDs: nodeIDs, Chains: chains, Logger: lggr, - }, don, nil + }, jd.don, nil } diff --git a/integration-tests/deployment/devenv/jd.go b/integration-tests/deployment/devenv/jd.go index 671e6e4cea3..7a8183f04aa 100644 --- a/integration-tests/deployment/devenv/jd.go +++ b/integration-tests/deployment/devenv/jd.go @@ -15,9 +15,10 @@ import ( ) type JDConfig struct { - GRPC string - WSRPC string - creds credentials.TransportCredentials + GRPC string + WSRPC string + creds credentials.TransportCredentials + nodeInfo []NodeInfo } func NewJDConnection(cfg JDConfig) (*grpc.ClientConn, error) { @@ -43,19 +44,25 @@ type JobDistributor struct { nodev1.NodeServiceClient jobv1.JobServiceClient csav1.CSAServiceClient + don *DON } -func NewJDClient(cfg JDConfig) (deployment.OffchainClient, error) { +func NewJDClient(ctx context.Context, cfg JDConfig) (deployment.OffchainClient, error) { conn, err := NewJDConnection(cfg) if err != nil { return nil, fmt.Errorf("failed to connect Job Distributor service. Err: %w", err) } - return JobDistributor{ + jd := &JobDistributor{ WSRPC: cfg.WSRPC, NodeServiceClient: nodev1.NewNodeServiceClient(conn), JobServiceClient: jobv1.NewJobServiceClient(conn), CSAServiceClient: csav1.NewCSAServiceClient(conn), - }, err + } + jd.don, err = NewRegisteredDON(ctx, cfg.nodeInfo, *jd) + if err != nil { + return nil, fmt.Errorf("failed to create registered DON: %w", err) + } + return jd, err } func (jd JobDistributor) GetCSAPublicKey(ctx context.Context) (string, error) { @@ -69,3 +76,28 @@ func (jd JobDistributor) GetCSAPublicKey(ctx context.Context) (string, error) { csakey := keypairs.Keypairs[0].PublicKey return csakey, nil } + +func (jd JobDistributor) ReplayLogs(selectorToBlock map[uint64]uint64) error { + return jd.don.ReplayAllLogs(selectorToBlock) +} + +// ProposeJob proposes jobs through the jobService and accepts the proposed job on selected node based on ProposeJobRequest.NodeId +func (jd JobDistributor) ProposeJob(ctx context.Context, in *jobv1.ProposeJobRequest, opts ...grpc.CallOption) (*jobv1.ProposeJobResponse, error) { + res, err := jd.JobServiceClient.ProposeJob(ctx, in, opts...) + if err != nil { + return nil, fmt.Errorf("failed to propose job. err: %w", err) + } + if res.Proposal == nil { + return nil, fmt.Errorf("failed to propose job. err: proposal is nil") + } + for _, node := range jd.don.Nodes { + if node.NodeId != in.NodeId { + continue + } + // TODO : is there a way to accept the job with proposal id? + if err := node.AcceptJob(ctx, res.Proposal.Spec); err != nil { + return nil, fmt.Errorf("failed to accept job. err: %w", err) + } + } + return res, nil +} diff --git a/integration-tests/deployment/memory/environment.go b/integration-tests/deployment/memory/environment.go index 409e8d3a816..2b68d5666fa 100644 --- a/integration-tests/deployment/memory/environment.go +++ b/integration-tests/deployment/memory/environment.go @@ -112,23 +112,6 @@ func NewMemoryEnvironmentFromChainsNodes(t *testing.T, } } -//func NewMemoryEnvironmentExistingChains(t *testing.T, lggr logger.Logger, -// chains map[uint64]deployment.Chain, config MemoryEnvironmentConfig) deployment.Environment { -// nodes := NewNodes(t, chains, config.Nodes, config.Bootstraps, config.CapabilityRegistryConfig) -// var nodeIDs []string -// for id := range nodes { -// nodeIDs = append(nodeIDs, id) -// } -// return deployment.Environment{ -// Name: Memory, -// Offchain: NewMemoryJobClient(nodes), -// // Note these have the p2p_ prefix. -// NodeIDs: nodeIDs, -// Chains: chains, -// Logger: lggr, -// } -//} - // To be used by tests and any kind of deployment logic. func NewMemoryEnvironment(t *testing.T, lggr logger.Logger, logLevel zapcore.Level, config MemoryEnvironmentConfig) deployment.Environment { chains := NewMemoryChains(t, config.Chains) diff --git a/integration-tests/deployment/memory/job_client.go b/integration-tests/deployment/memory/job_client.go index 25fec711d81..16dbfc2b828 100644 --- a/integration-tests/deployment/memory/job_client.go +++ b/integration-tests/deployment/memory/job_client.go @@ -156,6 +156,15 @@ func (j JobClient) DeleteJob(ctx context.Context, in *jobv1.DeleteJobRequest, op panic("implement me") } +func (j JobClient) ReplayLogs(selectorToBlock map[uint64]uint64) error { + for _, node := range j.Nodes { + if err := node.ReplayLogs(selectorToBlock); err != nil { + return err + } + } + return nil +} + func NewMemoryJobClient(nodesByPeerID map[string]Node) *JobClient { return &JobClient{nodesByPeerID} } diff --git a/integration-tests/go.mod b/integration-tests/go.mod index ab5a83f995e..b6dab3a01f9 100644 --- a/integration-tests/go.mod +++ b/integration-tests/go.mod @@ -39,8 +39,8 @@ require ( github.com/smartcontractkit/ccip-owner-contracts v0.0.0-20240926212305-a6deabdfce86 github.com/smartcontractkit/chain-selectors v1.0.23 github.com/smartcontractkit/chainlink-automation v1.0.4 - github.com/smartcontractkit/chainlink-ccip v0.0.0-20240927145425-578a50b4d7c5 - github.com/smartcontractkit/chainlink-common v0.2.3-0.20240927162447-20630b333f57 + github.com/smartcontractkit/chainlink-ccip v0.0.0-20240930150148-1c731b9602dd + github.com/smartcontractkit/chainlink-common v0.2.3-0.20240930142117-ef04dd443670 github.com/smartcontractkit/chainlink-testing-framework/havoc v1.50.0 github.com/smartcontractkit/chainlink-testing-framework/lib v1.50.9 github.com/smartcontractkit/chainlink-testing-framework/lib/grafana v1.50.0 diff --git a/integration-tests/go.sum b/integration-tests/go.sum index 590e4155b6e..7ab1481ca95 100644 --- a/integration-tests/go.sum +++ b/integration-tests/go.sum @@ -1423,10 +1423,10 @@ github.com/smartcontractkit/chain-selectors v1.0.23 h1:D2Eaex4Cw/O7Lg3tX6WklOqnj github.com/smartcontractkit/chain-selectors v1.0.23/go.mod h1:d4Hi+E1zqjy9HqMkjBE5q1vcG9VGgxf5VxiRHfzi2kE= github.com/smartcontractkit/chainlink-automation v1.0.4 h1:iyW181JjKHLNMnDleI8umfIfVVlwC7+n5izbLSFgjw8= github.com/smartcontractkit/chainlink-automation v1.0.4/go.mod h1:u4NbPZKJ5XiayfKHD/v3z3iflQWqvtdhj13jVZXj/cM= -github.com/smartcontractkit/chainlink-ccip v0.0.0-20240927145425-578a50b4d7c5 h1:958op4dZQK46g4zk9gQTj0GLa2G+jbuj4oHAnneJ4PI= -github.com/smartcontractkit/chainlink-ccip v0.0.0-20240927145425-578a50b4d7c5/go.mod h1:Q51RNKBrkqSJ0U2bwGQx4RL2X7c1qUL3NXVZF2kmCUA= -github.com/smartcontractkit/chainlink-common v0.2.3-0.20240927162447-20630b333f57 h1:pRiTiFOkPEyvgG0hchcCSZzwUbwYydnZBu0QbVaRnVk= -github.com/smartcontractkit/chainlink-common v0.2.3-0.20240927162447-20630b333f57/go.mod h1:F6WUS6N4mP5ScwpwyTyAJc9/vjR+GXbMCRUOVekQi1g= +github.com/smartcontractkit/chainlink-ccip v0.0.0-20240930150148-1c731b9602dd h1:16Hwnz4hdmWKOy5qVH9wHfyT1XXM0k31M3naexwzpVo= +github.com/smartcontractkit/chainlink-ccip v0.0.0-20240930150148-1c731b9602dd/go.mod h1:/nGkIe25kgtr+l6y30VH+aTVaxu0NjIEEEhtV1TDlaE= +github.com/smartcontractkit/chainlink-common v0.2.3-0.20240930142117-ef04dd443670 h1:z+XnayyX7pyvVv9OuMQ7oik7RkguQeWHhxcOoVM4oKI= +github.com/smartcontractkit/chainlink-common v0.2.3-0.20240930142117-ef04dd443670/go.mod h1:F6WUS6N4mP5ScwpwyTyAJc9/vjR+GXbMCRUOVekQi1g= github.com/smartcontractkit/chainlink-cosmos v0.4.1-0.20240911175228-daf2600bb7b7 h1:lTGIOQYLk1Ufn++X/AvZnt6VOcuhste5yp+C157No/Q= github.com/smartcontractkit/chainlink-cosmos v0.4.1-0.20240911175228-daf2600bb7b7/go.mod h1:BMYE1vC/pGmdFSsOJdPrAA0/4gZ0Xo0SxTMdGspBtRo= github.com/smartcontractkit/chainlink-data-streams v0.0.0-20240916152957-433914114bd2 h1:yRk4ektpx/UxwarqAfgxUXLrsYXlaNeP1NOwzHGrK2Q= diff --git a/integration-tests/load/go.mod b/integration-tests/load/go.mod index 49c7f1d43d4..975b1a13d35 100644 --- a/integration-tests/load/go.mod +++ b/integration-tests/load/go.mod @@ -15,7 +15,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/rs/zerolog v1.33.0 github.com/slack-go/slack v0.12.2 - github.com/smartcontractkit/chainlink-common v0.2.3-0.20240927162447-20630b333f57 + github.com/smartcontractkit/chainlink-common v0.2.3-0.20240930142117-ef04dd443670 github.com/smartcontractkit/chainlink-testing-framework/lib v1.50.9 github.com/smartcontractkit/chainlink-testing-framework/seth v1.50.1 github.com/smartcontractkit/chainlink-testing-framework/wasp v1.50.0 @@ -30,7 +30,7 @@ require ( require ( github.com/AlekSi/pointer v1.1.0 // indirect github.com/smartcontractkit/chainlink-automation v1.0.4 // indirect - github.com/smartcontractkit/chainlink-ccip v0.0.0-20240927145425-578a50b4d7c5 // indirect + github.com/smartcontractkit/chainlink-ccip v0.0.0-20240930150148-1c731b9602dd // indirect github.com/smartcontractkit/libocr v0.0.0-20240717100443-f6226e09bee7 // indirect ) diff --git a/integration-tests/load/go.sum b/integration-tests/load/go.sum index a59cc20777a..3d40c225116 100644 --- a/integration-tests/load/go.sum +++ b/integration-tests/load/go.sum @@ -1397,10 +1397,10 @@ github.com/smartcontractkit/chain-selectors v1.0.23 h1:D2Eaex4Cw/O7Lg3tX6WklOqnj github.com/smartcontractkit/chain-selectors v1.0.23/go.mod h1:d4Hi+E1zqjy9HqMkjBE5q1vcG9VGgxf5VxiRHfzi2kE= github.com/smartcontractkit/chainlink-automation v1.0.4 h1:iyW181JjKHLNMnDleI8umfIfVVlwC7+n5izbLSFgjw8= github.com/smartcontractkit/chainlink-automation v1.0.4/go.mod h1:u4NbPZKJ5XiayfKHD/v3z3iflQWqvtdhj13jVZXj/cM= -github.com/smartcontractkit/chainlink-ccip v0.0.0-20240927145425-578a50b4d7c5 h1:958op4dZQK46g4zk9gQTj0GLa2G+jbuj4oHAnneJ4PI= -github.com/smartcontractkit/chainlink-ccip v0.0.0-20240927145425-578a50b4d7c5/go.mod h1:Q51RNKBrkqSJ0U2bwGQx4RL2X7c1qUL3NXVZF2kmCUA= -github.com/smartcontractkit/chainlink-common v0.2.3-0.20240927162447-20630b333f57 h1:pRiTiFOkPEyvgG0hchcCSZzwUbwYydnZBu0QbVaRnVk= -github.com/smartcontractkit/chainlink-common v0.2.3-0.20240927162447-20630b333f57/go.mod h1:F6WUS6N4mP5ScwpwyTyAJc9/vjR+GXbMCRUOVekQi1g= +github.com/smartcontractkit/chainlink-ccip v0.0.0-20240930150148-1c731b9602dd h1:16Hwnz4hdmWKOy5qVH9wHfyT1XXM0k31M3naexwzpVo= +github.com/smartcontractkit/chainlink-ccip v0.0.0-20240930150148-1c731b9602dd/go.mod h1:/nGkIe25kgtr+l6y30VH+aTVaxu0NjIEEEhtV1TDlaE= +github.com/smartcontractkit/chainlink-common v0.2.3-0.20240930142117-ef04dd443670 h1:z+XnayyX7pyvVv9OuMQ7oik7RkguQeWHhxcOoVM4oKI= +github.com/smartcontractkit/chainlink-common v0.2.3-0.20240930142117-ef04dd443670/go.mod h1:F6WUS6N4mP5ScwpwyTyAJc9/vjR+GXbMCRUOVekQi1g= github.com/smartcontractkit/chainlink-cosmos v0.4.1-0.20240911175228-daf2600bb7b7 h1:lTGIOQYLk1Ufn++X/AvZnt6VOcuhste5yp+C157No/Q= github.com/smartcontractkit/chainlink-cosmos v0.4.1-0.20240911175228-daf2600bb7b7/go.mod h1:BMYE1vC/pGmdFSsOJdPrAA0/4gZ0Xo0SxTMdGspBtRo= github.com/smartcontractkit/chainlink-data-streams v0.0.0-20240916152957-433914114bd2 h1:yRk4ektpx/UxwarqAfgxUXLrsYXlaNeP1NOwzHGrK2Q= diff --git a/integration-tests/smoke/ccip_test.go b/integration-tests/smoke/ccip_test.go index 02c8435c222..8e80985620d 100644 --- a/integration-tests/smoke/ccip_test.go +++ b/integration-tests/smoke/ccip_test.go @@ -1,7 +1,6 @@ package smoke import ( - "strconv" "testing" "github.com/stretchr/testify/require" @@ -11,73 +10,62 @@ import ( "github.com/smartcontractkit/chainlink-testing-framework/lib/utils/testcontext" - ccipdeployment "github.com/smartcontractkit/chainlink/integration-tests/deployment/ccip" + ccdeploy "github.com/smartcontractkit/chainlink/integration-tests/deployment/ccip" "github.com/smartcontractkit/chainlink/integration-tests/deployment/ccip/changeset" jobv1 "github.com/smartcontractkit/chainlink/integration-tests/deployment/jd/job/v1" "github.com/smartcontractkit/chainlink/v2/core/logger" ) -func Test0002_InitialDeployOnLocal(t *testing.T) { +func TestInitialDeployOnLocal(t *testing.T) { lggr := logger.TestLogger(t) - ctx := ccipdeployment.Context(t) - tenv := ccipdeployment.NewDeployedLocalDevEnvironment(t, lggr) + ctx := ccdeploy.Context(t) + tenv := ccdeploy.NewLocalDevEnvironment(t, lggr) e := tenv.Env - don := tenv.DON - state, err := ccipdeployment.LoadOnchainState(tenv.Env, tenv.Ab) + state, err := ccdeploy.LoadOnchainState(tenv.Env, tenv.Ab) require.NoError(t, err) feeds := state.Chains[tenv.FeedChainSel].USDFeeds - tokenConfig := ccipdeployment.NewTokenConfig() - tokenConfig.UpsertTokenInfo(ccipdeployment.LinkSymbol, + tokenConfig := ccdeploy.NewTokenConfig() + tokenConfig.UpsertTokenInfo(ccdeploy.LinkSymbol, pluginconfig.TokenInfo{ - AggregatorAddress: feeds[ccipdeployment.LinkSymbol].Address().String(), - Decimals: ccipdeployment.LinkDecimals, + AggregatorAddress: feeds[ccdeploy.LinkSymbol].Address().String(), + Decimals: ccdeploy.LinkDecimals, DeviationPPB: cciptypes.NewBigIntFromInt64(1e9), }, ) // Apply migration - output, err := changeset.Apply0002(tenv.Env, ccipdeployment.DeployCCIPContractConfig{ + output, err := changeset.InitialDeployChangeSet(tenv.Env, ccdeploy.DeployCCIPContractConfig{ HomeChainSel: tenv.HomeChainSel, FeedChainSel: tenv.FeedChainSel, - TokenConfig: tokenConfig, ChainsToDeploy: tenv.Env.AllChainSelectors(), - // Capreg/config already exist. + TokenConfig: tokenConfig, + // Capreg/config and feeds already exist. CCIPOnChainState: state, }) require.NoError(t, err) // Get new state after migration. - state, err = ccipdeployment.LoadOnchainState(e, output.AddressBook) + state, err = ccdeploy.LoadOnchainState(e, output.AddressBook) require.NoError(t, err) + // Ensure capreg logs are up to date. + ccdeploy.ReplayLogs(t, e.Offchain, tenv.ReplayBlocks) + // Apply the jobs. - nodeIdToJobIds := make(map[string][]string) for nodeID, jobs := range output.JobSpecs { - nodeIdToJobIds[nodeID] = make([]string, 0, len(jobs)) for _, job := range jobs { - res, err := e.Offchain.ProposeJob(ctx, + // Note these auto-accept + _, err := e.Offchain.ProposeJob(ctx, &jobv1.ProposeJobRequest{ NodeId: nodeID, Spec: job, }) require.NoError(t, err) - require.NotNil(t, res.Proposal) - nodeIdToJobIds[nodeID] = append(nodeIdToJobIds[nodeID], res.Proposal.JobId) - } - } - - // Accept all the jobs for this node. - for _, n := range don.Nodes { - jobsToAccept, exists := nodeIdToJobIds[n.NodeId] - require.True(t, exists, "node %s has no jobs to accept", n.NodeId) - for i, jobID := range jobsToAccept { - require.NoError(t, n.AcceptJob(ctx, strconv.Itoa(i+1)), "node -%s failed to accept job %s", n.Name, jobID) } } - t.Log("Jobs accepted") // Add all lanes - require.NoError(t, ccipdeployment.AddLanesForAll(e, state)) + require.NoError(t, ccdeploy.AddLanesForAll(e, state)) // Need to keep track of the block number for each chain so that event subscription can be done from that block. startBlocks := make(map[uint64]*uint64) // Send a message from each chain to every other chain. @@ -91,13 +79,13 @@ func Test0002_InitialDeployOnLocal(t *testing.T) { require.NoError(t, err) block := latesthdr.Number.Uint64() startBlocks[dest] = &block - seqNum := ccipdeployment.SendRequest(t, e, state, src, dest, false) + seqNum := ccdeploy.SendRequest(t, e, state, src, dest, false) expectedSeqNum[dest] = seqNum } } // Wait for all commit reports to land. - ccipdeployment.ConfirmCommitForAllWithExpectedSeqNums(t, e, state, expectedSeqNum, startBlocks) + ccdeploy.ConfirmCommitForAllWithExpectedSeqNums(t, e, state, expectedSeqNum, startBlocks) // After commit is reported on all chains, token prices should be updated in FeeQuoter. for dest := range e.Chains { @@ -105,8 +93,11 @@ func Test0002_InitialDeployOnLocal(t *testing.T) { feeQuoter := state.Chains[dest].FeeQuoter timestampedPrice, err := feeQuoter.GetTokenPrice(nil, linkAddress) require.NoError(t, err) - require.Equal(t, ccipdeployment.MockLinkPrice, timestampedPrice.Value) + require.Equal(t, ccdeploy.MockLinkPrice, timestampedPrice.Value) } + // Wait for all exec reports to land - ccipdeployment.ConfirmExecWithSeqNrForAll(t, e, state, expectedSeqNum, startBlocks) + ccdeploy.ConfirmExecWithSeqNrForAll(t, e, state, expectedSeqNum, startBlocks) + + // TODO: Apply the proposal. } diff --git a/integration-tests/web/sdk/client/client.go b/integration-tests/web/sdk/client/client.go index 783a8a88565..e633bf04c15 100644 --- a/integration-tests/web/sdk/client/client.go +++ b/integration-tests/web/sdk/client/client.go @@ -21,12 +21,13 @@ type Client interface { FetchOCR2KeyBundleID(ctx context.Context, chainType string) (string, error) GetJob(ctx context.Context, id string) (*generated.GetJobResponse, error) ListJobs(ctx context.Context, offset, limit int) (*generated.ListJobsResponse, error) - GetJobDistributor(ctx context.Context, id string) (*generated.GetFeedsManagerResponse, error) + GetJobDistributor(ctx context.Context, id string) (generated.FeedsManagerParts, error) ListJobDistributors(ctx context.Context) (*generated.ListFeedsManagersResponse, error) CreateJobDistributor(ctx context.Context, cmd JobDistributorInput) (string, error) UpdateJobDistributor(ctx context.Context, id string, cmd JobDistributorInput) error - CreateJobDistributorChainConfig(ctx context.Context, in JobDistributorChainConfigInput) error - GetJobProposal(ctx context.Context, id string) (*generated.GetJobProposalResponse, error) + CreateJobDistributorChainConfig(ctx context.Context, in JobDistributorChainConfigInput) (string, error) + DeleteJobDistributorChainConfig(ctx context.Context, id string) error + GetJobProposal(ctx context.Context, id string) (*generated.GetJobProposalJobProposal, error) ApproveJobProposalSpec(ctx context.Context, id string, force bool) (*JobProposalApprovalSuccessSpec, error) CancelJobProposalSpec(ctx context.Context, id string) (*generated.CancelJobProposalSpecResponse, error) RejectJobProposalSpec(ctx context.Context, id string) (*generated.RejectJobProposalSpecResponse, error) @@ -142,8 +143,18 @@ func (c *client) ListBridges(ctx context.Context, offset, limit int) (*generated return generated.ListBridges(ctx, c.gqlClient, offset, limit) } -func (c *client) GetJobDistributor(ctx context.Context, id string) (*generated.GetFeedsManagerResponse, error) { - return generated.GetFeedsManager(ctx, c.gqlClient, id) +func (c *client) GetJobDistributor(ctx context.Context, id string) (generated.FeedsManagerParts, error) { + res, err := generated.GetFeedsManager(ctx, c.gqlClient, id) + if err != nil { + return generated.FeedsManagerParts{}, err + } + if res == nil { + return generated.FeedsManagerParts{}, fmt.Errorf("no feeds manager found") + } + if success, ok := res.GetFeedsManager().(*generated.GetFeedsManagerFeedsManager); ok { + return success.FeedsManagerParts, nil + } + return generated.FeedsManagerParts{}, fmt.Errorf("failed to get feeds manager") } func (c *client) ListJobDistributors(ctx context.Context) (*generated.ListFeedsManagersResponse, error) { @@ -178,18 +189,51 @@ func (c *client) UpdateJobDistributor(ctx context.Context, id string, in JobDist return err } -func (c *client) CreateJobDistributorChainConfig(ctx context.Context, in JobDistributorChainConfigInput) error { +func (c *client) CreateJobDistributorChainConfig(ctx context.Context, in JobDistributorChainConfigInput) (string, error) { var cmd generated.CreateFeedsManagerChainConfigInput err := DecodeInput(in, &cmd) + if err != nil { + return "", err + } + res, err := generated.CreateFeedsManagerChainConfig(ctx, c.gqlClient, cmd) + if err != nil { + return "", err + } + if res == nil { + return "", fmt.Errorf("failed to create feeds manager chain config") + } + if success, ok := res.GetCreateFeedsManagerChainConfig().(*generated.CreateFeedsManagerChainConfigCreateFeedsManagerChainConfigCreateFeedsManagerChainConfigSuccess); ok { + return success.ChainConfig.Id, nil + } + return "", fmt.Errorf("failed to create feeds manager chain config") +} + +func (c *client) DeleteJobDistributorChainConfig(ctx context.Context, id string) error { + res, err := generated.DeleteFeedsManagerChainConfig(ctx, c.gqlClient, id) if err != nil { return err } - _, err = generated.CreateFeedsManagerChainConfig(ctx, c.gqlClient, cmd) - return err + if res == nil { + return fmt.Errorf("failed to delete feeds manager chain config") + } + if _, ok := res.GetDeleteFeedsManagerChainConfig().(*generated.DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccess); ok { + return nil + } + return fmt.Errorf("failed to delete feeds manager chain config") } -func (c *client) GetJobProposal(ctx context.Context, id string) (*generated.GetJobProposalResponse, error) { - return generated.GetJobProposal(ctx, c.gqlClient, id) +func (c *client) GetJobProposal(ctx context.Context, id string) (*generated.GetJobProposalJobProposal, error) { + proposal, err := generated.GetJobProposal(ctx, c.gqlClient, id) + if err != nil { + return nil, err + } + if proposal == nil { + return nil, fmt.Errorf("no job proposal found") + } + if success, ok := proposal.GetJobProposal().(*generated.GetJobProposalJobProposal); ok { + return success, nil + } + return nil, fmt.Errorf("failed to get job proposal") } func (c *client) ApproveJobProposalSpec(ctx context.Context, id string, force bool) (*JobProposalApprovalSuccessSpec, error) { diff --git a/integration-tests/web/sdk/internal/generated/generated.go b/integration-tests/web/sdk/internal/generated/generated.go index 8efde4c453f..68ab3e48e4f 100644 --- a/integration-tests/web/sdk/internal/generated/generated.go +++ b/integration-tests/web/sdk/internal/generated/generated.go @@ -1194,6 +1194,11 @@ func (v *CreateFeedsManagerCreateFeedsManagerCreateFeedsManagerSuccessFeedsManag return v.FeedsManagerParts.CreatedAt } +// GetJobProposals returns CreateFeedsManagerCreateFeedsManagerCreateFeedsManagerSuccessFeedsManager.JobProposals, and is useful for accessing the field via an interface. +func (v *CreateFeedsManagerCreateFeedsManagerCreateFeedsManagerSuccessFeedsManager) GetJobProposals() []FeedsManagerPartsJobProposalsJobProposal { + return v.FeedsManagerParts.JobProposals +} + func (v *CreateFeedsManagerCreateFeedsManagerCreateFeedsManagerSuccessFeedsManager) UnmarshalJSON(b []byte) error { if string(b) == "null" { @@ -1231,6 +1236,8 @@ type __premarshalCreateFeedsManagerCreateFeedsManagerCreateFeedsManagerSuccessFe IsConnectionActive bool `json:"isConnectionActive"` CreatedAt string `json:"createdAt"` + + JobProposals []FeedsManagerPartsJobProposalsJobProposal `json:"jobProposals"` } func (v *CreateFeedsManagerCreateFeedsManagerCreateFeedsManagerSuccessFeedsManager) MarshalJSON() ([]byte, error) { @@ -1250,6 +1257,7 @@ func (v *CreateFeedsManagerCreateFeedsManagerCreateFeedsManagerSuccessFeedsManag retval.PublicKey = v.FeedsManagerParts.PublicKey retval.IsConnectionActive = v.FeedsManagerParts.IsConnectionActive retval.CreatedAt = v.FeedsManagerParts.CreatedAt + retval.JobProposals = v.FeedsManagerParts.JobProposals return &retval, nil } @@ -1425,6 +1433,200 @@ func (v *CreateFeedsManagerResponse) __premarshalJSON() (*__premarshalCreateFeed return &retval, nil } +// DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload includes the requested fields of the GraphQL interface DeleteFeedsManagerChainConfigPayload. +// +// DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload is implemented by the following types: +// DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccess +// DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError +type DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload interface { + implementsGraphQLInterfaceDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload() + // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). + GetTypename() string +} + +func (v *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccess) implementsGraphQLInterfaceDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload() { +} +func (v *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError) implementsGraphQLInterfaceDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload() { +} + +func __unmarshalDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload(b []byte, v *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload) error { + if string(b) == "null" { + return nil + } + + var tn struct { + TypeName string `json:"__typename"` + } + err := json.Unmarshal(b, &tn) + if err != nil { + return err + } + + switch tn.TypeName { + case "DeleteFeedsManagerChainConfigSuccess": + *v = new(DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccess) + return json.Unmarshal(b, *v) + case "NotFoundError": + *v = new(DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError) + return json.Unmarshal(b, *v) + case "": + return fmt.Errorf( + "response was missing DeleteFeedsManagerChainConfigPayload.__typename") + default: + return fmt.Errorf( + `unexpected concrete type for DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload: "%v"`, tn.TypeName) + } +} + +func __marshalDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload(v *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload) ([]byte, error) { + + var typename string + switch v := (*v).(type) { + case *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccess: + typename = "DeleteFeedsManagerChainConfigSuccess" + + result := struct { + TypeName string `json:"__typename"` + *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccess + }{typename, v} + return json.Marshal(result) + case *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError: + typename = "NotFoundError" + + result := struct { + TypeName string `json:"__typename"` + *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError + }{typename, v} + return json.Marshal(result) + case nil: + return []byte("null"), nil + default: + return nil, fmt.Errorf( + `unexpected concrete type for DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload: "%T"`, v) + } +} + +// DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccess includes the requested fields of the GraphQL type DeleteFeedsManagerChainConfigSuccess. +type DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccess struct { + Typename string `json:"__typename"` + ChainConfig DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccessChainConfigFeedsManagerChainConfig `json:"chainConfig"` +} + +// GetTypename returns DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccess.Typename, and is useful for accessing the field via an interface. +func (v *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccess) GetTypename() string { + return v.Typename +} + +// GetChainConfig returns DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccess.ChainConfig, and is useful for accessing the field via an interface. +func (v *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccess) GetChainConfig() DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccessChainConfigFeedsManagerChainConfig { + return v.ChainConfig +} + +// DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccessChainConfigFeedsManagerChainConfig includes the requested fields of the GraphQL type FeedsManagerChainConfig. +type DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccessChainConfigFeedsManagerChainConfig struct { + Id string `json:"id"` +} + +// GetId returns DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccessChainConfigFeedsManagerChainConfig.Id, and is useful for accessing the field via an interface. +func (v *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigSuccessChainConfigFeedsManagerChainConfig) GetId() string { + return v.Id +} + +// DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError includes the requested fields of the GraphQL type NotFoundError. +type DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError struct { + Typename string `json:"__typename"` + Message string `json:"message"` + Code ErrorCode `json:"code"` +} + +// GetTypename returns DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError.Typename, and is useful for accessing the field via an interface. +func (v *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError) GetTypename() string { + return v.Typename +} + +// GetMessage returns DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError.Message, and is useful for accessing the field via an interface. +func (v *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError) GetMessage() string { + return v.Message +} + +// GetCode returns DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError.Code, and is useful for accessing the field via an interface. +func (v *DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigNotFoundError) GetCode() ErrorCode { + return v.Code +} + +// DeleteFeedsManagerChainConfigResponse is returned by DeleteFeedsManagerChainConfig on success. +type DeleteFeedsManagerChainConfigResponse struct { + DeleteFeedsManagerChainConfig DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload `json:"-"` +} + +// GetDeleteFeedsManagerChainConfig returns DeleteFeedsManagerChainConfigResponse.DeleteFeedsManagerChainConfig, and is useful for accessing the field via an interface. +func (v *DeleteFeedsManagerChainConfigResponse) GetDeleteFeedsManagerChainConfig() DeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload { + return v.DeleteFeedsManagerChainConfig +} + +func (v *DeleteFeedsManagerChainConfigResponse) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *DeleteFeedsManagerChainConfigResponse + DeleteFeedsManagerChainConfig json.RawMessage `json:"deleteFeedsManagerChainConfig"` + graphql.NoUnmarshalJSON + } + firstPass.DeleteFeedsManagerChainConfigResponse = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + { + dst := &v.DeleteFeedsManagerChainConfig + src := firstPass.DeleteFeedsManagerChainConfig + if len(src) != 0 && string(src) != "null" { + err = __unmarshalDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload( + src, dst) + if err != nil { + return fmt.Errorf( + "unable to unmarshal DeleteFeedsManagerChainConfigResponse.DeleteFeedsManagerChainConfig: %w", err) + } + } + } + return nil +} + +type __premarshalDeleteFeedsManagerChainConfigResponse struct { + DeleteFeedsManagerChainConfig json.RawMessage `json:"deleteFeedsManagerChainConfig"` +} + +func (v *DeleteFeedsManagerChainConfigResponse) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *DeleteFeedsManagerChainConfigResponse) __premarshalJSON() (*__premarshalDeleteFeedsManagerChainConfigResponse, error) { + var retval __premarshalDeleteFeedsManagerChainConfigResponse + + { + + dst := &retval.DeleteFeedsManagerChainConfig + src := v.DeleteFeedsManagerChainConfig + var err error + *dst, err = __marshalDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigDeleteFeedsManagerChainConfigPayload( + &src) + if err != nil { + return nil, fmt.Errorf( + "unable to marshal DeleteFeedsManagerChainConfigResponse.DeleteFeedsManagerChainConfig: %w", err) + } + } + return &retval, nil +} + type ErrorCode string const ( @@ -1435,12 +1637,13 @@ const ( // FeedsManagerParts includes the GraphQL fields of FeedsManager requested by the fragment FeedsManagerParts. type FeedsManagerParts struct { - Id string `json:"id"` - Name string `json:"name"` - Uri string `json:"uri"` - PublicKey string `json:"publicKey"` - IsConnectionActive bool `json:"isConnectionActive"` - CreatedAt string `json:"createdAt"` + Id string `json:"id"` + Name string `json:"name"` + Uri string `json:"uri"` + PublicKey string `json:"publicKey"` + IsConnectionActive bool `json:"isConnectionActive"` + CreatedAt string `json:"createdAt"` + JobProposals []FeedsManagerPartsJobProposalsJobProposal `json:"jobProposals"` } // GetId returns FeedsManagerParts.Id, and is useful for accessing the field via an interface. @@ -1461,6 +1664,137 @@ func (v *FeedsManagerParts) GetIsConnectionActive() bool { return v.IsConnection // GetCreatedAt returns FeedsManagerParts.CreatedAt, and is useful for accessing the field via an interface. func (v *FeedsManagerParts) GetCreatedAt() string { return v.CreatedAt } +// GetJobProposals returns FeedsManagerParts.JobProposals, and is useful for accessing the field via an interface. +func (v *FeedsManagerParts) GetJobProposals() []FeedsManagerPartsJobProposalsJobProposal { + return v.JobProposals +} + +// FeedsManagerPartsJobProposalsJobProposal includes the requested fields of the GraphQL type JobProposal. +type FeedsManagerPartsJobProposalsJobProposal struct { + Id string `json:"id"` + Status JobProposalStatus `json:"status"` + RemoteUUID string `json:"remoteUUID"` + ExternalJobID string `json:"externalJobID"` + JobID string `json:"jobID"` + Specs []FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec `json:"specs"` + LatestSpec FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec `json:"latestSpec"` +} + +// GetId returns FeedsManagerPartsJobProposalsJobProposal.Id, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposal) GetId() string { return v.Id } + +// GetStatus returns FeedsManagerPartsJobProposalsJobProposal.Status, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposal) GetStatus() JobProposalStatus { return v.Status } + +// GetRemoteUUID returns FeedsManagerPartsJobProposalsJobProposal.RemoteUUID, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposal) GetRemoteUUID() string { return v.RemoteUUID } + +// GetExternalJobID returns FeedsManagerPartsJobProposalsJobProposal.ExternalJobID, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposal) GetExternalJobID() string { return v.ExternalJobID } + +// GetJobID returns FeedsManagerPartsJobProposalsJobProposal.JobID, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposal) GetJobID() string { return v.JobID } + +// GetSpecs returns FeedsManagerPartsJobProposalsJobProposal.Specs, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposal) GetSpecs() []FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec { + return v.Specs +} + +// GetLatestSpec returns FeedsManagerPartsJobProposalsJobProposal.LatestSpec, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposal) GetLatestSpec() FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec { + return v.LatestSpec +} + +// FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec includes the requested fields of the GraphQL type JobProposalSpec. +type FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec struct { + Id string `json:"id"` + Definition string `json:"definition"` + Version int `json:"version"` + Status SpecStatus `json:"status"` + StatusUpdatedAt string `json:"statusUpdatedAt"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// GetId returns FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec.Id, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec) GetId() string { + return v.Id +} + +// GetDefinition returns FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec.Definition, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec) GetDefinition() string { + return v.Definition +} + +// GetVersion returns FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec.Version, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec) GetVersion() int { + return v.Version +} + +// GetStatus returns FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec.Status, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec) GetStatus() SpecStatus { + return v.Status +} + +// GetStatusUpdatedAt returns FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec.StatusUpdatedAt, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec) GetStatusUpdatedAt() string { + return v.StatusUpdatedAt +} + +// GetCreatedAt returns FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec.CreatedAt, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec) GetCreatedAt() string { + return v.CreatedAt +} + +// GetUpdatedAt returns FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec.UpdatedAt, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalLatestSpecJobProposalSpec) GetUpdatedAt() string { + return v.UpdatedAt +} + +// FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec includes the requested fields of the GraphQL type JobProposalSpec. +type FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec struct { + Id string `json:"id"` + Definition string `json:"definition"` + Version int `json:"version"` + Status SpecStatus `json:"status"` + StatusUpdatedAt string `json:"statusUpdatedAt"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// GetId returns FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec.Id, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec) GetId() string { return v.Id } + +// GetDefinition returns FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec.Definition, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec) GetDefinition() string { + return v.Definition +} + +// GetVersion returns FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec.Version, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec) GetVersion() int { + return v.Version +} + +// GetStatus returns FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec.Status, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec) GetStatus() SpecStatus { + return v.Status +} + +// GetStatusUpdatedAt returns FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec.StatusUpdatedAt, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec) GetStatusUpdatedAt() string { + return v.StatusUpdatedAt +} + +// GetCreatedAt returns FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec.CreatedAt, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec) GetCreatedAt() string { + return v.CreatedAt +} + +// GetUpdatedAt returns FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec.UpdatedAt, and is useful for accessing the field via an interface. +func (v *FeedsManagerPartsJobProposalsJobProposalSpecsJobProposalSpec) GetUpdatedAt() string { + return v.UpdatedAt +} + // FetchAccountsEthKeysEthKeysPayload includes the requested fields of the GraphQL type EthKeysPayload. type FetchAccountsEthKeysEthKeysPayload struct { Results []FetchAccountsEthKeysEthKeysPayloadResultsEthKey `json:"results"` @@ -1929,6 +2263,11 @@ func (v *GetFeedsManagerFeedsManager) GetIsConnectionActive() bool { // GetCreatedAt returns GetFeedsManagerFeedsManager.CreatedAt, and is useful for accessing the field via an interface. func (v *GetFeedsManagerFeedsManager) GetCreatedAt() string { return v.FeedsManagerParts.CreatedAt } +// GetJobProposals returns GetFeedsManagerFeedsManager.JobProposals, and is useful for accessing the field via an interface. +func (v *GetFeedsManagerFeedsManager) GetJobProposals() []FeedsManagerPartsJobProposalsJobProposal { + return v.FeedsManagerParts.JobProposals +} + func (v *GetFeedsManagerFeedsManager) UnmarshalJSON(b []byte) error { if string(b) == "null" { @@ -1968,6 +2307,8 @@ type __premarshalGetFeedsManagerFeedsManager struct { IsConnectionActive bool `json:"isConnectionActive"` CreatedAt string `json:"createdAt"` + + JobProposals []FeedsManagerPartsJobProposalsJobProposal `json:"jobProposals"` } func (v *GetFeedsManagerFeedsManager) MarshalJSON() ([]byte, error) { @@ -1988,6 +2329,7 @@ func (v *GetFeedsManagerFeedsManager) __premarshalJSON() (*__premarshalGetFeedsM retval.PublicKey = v.FeedsManagerParts.PublicKey retval.IsConnectionActive = v.FeedsManagerParts.IsConnectionActive retval.CreatedAt = v.FeedsManagerParts.CreatedAt + retval.JobProposals = v.FeedsManagerParts.JobProposals return &retval, nil } @@ -2465,6 +2807,11 @@ func (v *GetJobProposalJobProposalFeedsManager) GetCreatedAt() string { return v.FeedsManagerParts.CreatedAt } +// GetJobProposals returns GetJobProposalJobProposalFeedsManager.JobProposals, and is useful for accessing the field via an interface. +func (v *GetJobProposalJobProposalFeedsManager) GetJobProposals() []FeedsManagerPartsJobProposalsJobProposal { + return v.FeedsManagerParts.JobProposals +} + func (v *GetJobProposalJobProposalFeedsManager) UnmarshalJSON(b []byte) error { if string(b) == "null" { @@ -2502,6 +2849,8 @@ type __premarshalGetJobProposalJobProposalFeedsManager struct { IsConnectionActive bool `json:"isConnectionActive"` CreatedAt string `json:"createdAt"` + + JobProposals []FeedsManagerPartsJobProposalsJobProposal `json:"jobProposals"` } func (v *GetJobProposalJobProposalFeedsManager) MarshalJSON() ([]byte, error) { @@ -2521,6 +2870,7 @@ func (v *GetJobProposalJobProposalFeedsManager) __premarshalJSON() (*__premarsha retval.PublicKey = v.FeedsManagerParts.PublicKey retval.IsConnectionActive = v.FeedsManagerParts.IsConnectionActive retval.CreatedAt = v.FeedsManagerParts.CreatedAt + retval.JobProposals = v.FeedsManagerParts.JobProposals return &retval, nil } @@ -3650,6 +4000,11 @@ func (v *ListFeedsManagersFeedsManagersFeedsManagersPayloadResultsFeedsManager) return v.FeedsManagerParts.CreatedAt } +// GetJobProposals returns ListFeedsManagersFeedsManagersFeedsManagersPayloadResultsFeedsManager.JobProposals, and is useful for accessing the field via an interface. +func (v *ListFeedsManagersFeedsManagersFeedsManagersPayloadResultsFeedsManager) GetJobProposals() []FeedsManagerPartsJobProposalsJobProposal { + return v.FeedsManagerParts.JobProposals +} + func (v *ListFeedsManagersFeedsManagersFeedsManagersPayloadResultsFeedsManager) UnmarshalJSON(b []byte) error { if string(b) == "null" { @@ -3687,6 +4042,8 @@ type __premarshalListFeedsManagersFeedsManagersFeedsManagersPayloadResultsFeedsM IsConnectionActive bool `json:"isConnectionActive"` CreatedAt string `json:"createdAt"` + + JobProposals []FeedsManagerPartsJobProposalsJobProposal `json:"jobProposals"` } func (v *ListFeedsManagersFeedsManagersFeedsManagersPayloadResultsFeedsManager) MarshalJSON() ([]byte, error) { @@ -3706,6 +4063,7 @@ func (v *ListFeedsManagersFeedsManagersFeedsManagersPayloadResultsFeedsManager) retval.PublicKey = v.FeedsManagerParts.PublicKey retval.IsConnectionActive = v.FeedsManagerParts.IsConnectionActive retval.CreatedAt = v.FeedsManagerParts.CreatedAt + retval.JobProposals = v.FeedsManagerParts.JobProposals return &retval, nil } @@ -4469,6 +4827,11 @@ func (v *UpdateFeedsManagerUpdateFeedsManagerUpdateFeedsManagerSuccessFeedsManag return v.FeedsManagerParts.CreatedAt } +// GetJobProposals returns UpdateFeedsManagerUpdateFeedsManagerUpdateFeedsManagerSuccessFeedsManager.JobProposals, and is useful for accessing the field via an interface. +func (v *UpdateFeedsManagerUpdateFeedsManagerUpdateFeedsManagerSuccessFeedsManager) GetJobProposals() []FeedsManagerPartsJobProposalsJobProposal { + return v.FeedsManagerParts.JobProposals +} + func (v *UpdateFeedsManagerUpdateFeedsManagerUpdateFeedsManagerSuccessFeedsManager) UnmarshalJSON(b []byte) error { if string(b) == "null" { @@ -4506,6 +4869,8 @@ type __premarshalUpdateFeedsManagerUpdateFeedsManagerUpdateFeedsManagerSuccessFe IsConnectionActive bool `json:"isConnectionActive"` CreatedAt string `json:"createdAt"` + + JobProposals []FeedsManagerPartsJobProposalsJobProposal `json:"jobProposals"` } func (v *UpdateFeedsManagerUpdateFeedsManagerUpdateFeedsManagerSuccessFeedsManager) MarshalJSON() ([]byte, error) { @@ -4525,6 +4890,7 @@ func (v *UpdateFeedsManagerUpdateFeedsManagerUpdateFeedsManagerSuccessFeedsManag retval.PublicKey = v.FeedsManagerParts.PublicKey retval.IsConnectionActive = v.FeedsManagerParts.IsConnectionActive retval.CreatedAt = v.FeedsManagerParts.CreatedAt + retval.JobProposals = v.FeedsManagerParts.JobProposals return &retval, nil } @@ -4803,6 +5169,14 @@ type __CreateFeedsManagerInput struct { // GetInput returns __CreateFeedsManagerInput.Input, and is useful for accessing the field via an interface. func (v *__CreateFeedsManagerInput) GetInput() CreateFeedsManagerInput { return v.Input } +// __DeleteFeedsManagerChainConfigInput is used internally by genqlient +type __DeleteFeedsManagerChainConfigInput struct { + Id string `json:"id"` +} + +// GetId returns __DeleteFeedsManagerChainConfigInput.Id, and is useful for accessing the field via an interface. +func (v *__DeleteFeedsManagerChainConfigInput) GetId() string { return v.Id } + // __GetBridgeInput is used internally by genqlient type __GetBridgeInput struct { Id string `json:"id"` @@ -5033,6 +5407,31 @@ fragment FeedsManagerParts on FeedsManager { publicKey isConnectionActive createdAt + jobProposals { + id + status + remoteUUID + externalJobID + jobID + specs { + id + definition + version + status + statusUpdatedAt + createdAt + updatedAt + } + latestSpec { + id + definition + version + status + statusUpdatedAt + createdAt + updatedAt + } + } } ` @@ -5142,6 +5541,50 @@ func CreateFeedsManagerChainConfig( return &data_, err_ } +// The query or mutation executed by DeleteFeedsManagerChainConfig. +const DeleteFeedsManagerChainConfig_Operation = ` +mutation DeleteFeedsManagerChainConfig ($id: ID!) { + deleteFeedsManagerChainConfig(id: $id) { + __typename + ... on DeleteFeedsManagerChainConfigSuccess { + chainConfig { + id + } + } + ... on NotFoundError { + message + code + } + } +} +` + +func DeleteFeedsManagerChainConfig( + ctx_ context.Context, + client_ graphql.Client, + id string, +) (*DeleteFeedsManagerChainConfigResponse, error) { + req_ := &graphql.Request{ + OpName: "DeleteFeedsManagerChainConfig", + Query: DeleteFeedsManagerChainConfig_Operation, + Variables: &__DeleteFeedsManagerChainConfigInput{ + Id: id, + }, + } + var err_ error + + var data_ DeleteFeedsManagerChainConfigResponse + resp_ := &graphql.Response{Data: &data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return &data_, err_ +} + // The query or mutation executed by FetchAccounts. const FetchAccounts_Operation = ` query FetchAccounts { @@ -5357,6 +5800,31 @@ fragment FeedsManagerParts on FeedsManager { publicKey isConnectionActive createdAt + jobProposals { + id + status + remoteUUID + externalJobID + jobID + specs { + id + definition + version + status + statusUpdatedAt + createdAt + updatedAt + } + latestSpec { + id + definition + version + status + statusUpdatedAt + createdAt + updatedAt + } + } } ` @@ -5514,6 +5982,31 @@ fragment FeedsManagerParts on FeedsManager { publicKey isConnectionActive createdAt + jobProposals { + id + status + remoteUUID + externalJobID + jobID + specs { + id + definition + version + status + statusUpdatedAt + createdAt + updatedAt + } + latestSpec { + id + definition + version + status + statusUpdatedAt + createdAt + updatedAt + } + } } ` @@ -5610,6 +6103,31 @@ fragment FeedsManagerParts on FeedsManager { publicKey isConnectionActive createdAt + jobProposals { + id + status + remoteUUID + externalJobID + jobID + specs { + id + definition + version + status + statusUpdatedAt + createdAt + updatedAt + } + latestSpec { + id + definition + version + status + statusUpdatedAt + createdAt + updatedAt + } + } } ` @@ -5796,6 +6314,31 @@ fragment FeedsManagerParts on FeedsManager { publicKey isConnectionActive createdAt + jobProposals { + id + status + remoteUUID + externalJobID + jobID + specs { + id + definition + version + status + statusUpdatedAt + createdAt + updatedAt + } + latestSpec { + id + definition + version + status + statusUpdatedAt + createdAt + updatedAt + } + } } ` diff --git a/integration-tests/web/sdk/internal/genqlient.graphql b/integration-tests/web/sdk/internal/genqlient.graphql index cd1912b88cf..06baf4f7913 100644 --- a/integration-tests/web/sdk/internal/genqlient.graphql +++ b/integration-tests/web/sdk/internal/genqlient.graphql @@ -212,6 +212,31 @@ fragment FeedsManagerParts on FeedsManager { publicKey isConnectionActive createdAt + jobProposals { + id + status + remoteUUID + externalJobID + jobID + specs { + id + definition + version + status + statusUpdatedAt + createdAt + updatedAt + } + latestSpec { + id + definition + version + status + statusUpdatedAt + createdAt + updatedAt + } + } } query GetFeedsManager($id: ID!) { @@ -328,6 +353,19 @@ mutation CreateFeedsManagerChainConfig($input: CreateFeedsManagerChainConfigInpu } } +mutation DeleteFeedsManagerChainConfig($id: ID!) { + deleteFeedsManagerChainConfig(id: $id) { + ... on DeleteFeedsManagerChainConfigSuccess { + chainConfig { + id + } + } + ... on NotFoundError { + message + code + } + } +} ##################### # Job Proposals @@ -418,4 +456,4 @@ mutation UpdateJobProposalSpecDefinition( code } } -} +} \ No newline at end of file diff --git a/plugins/cmd/capabilities/log-event-trigger/main.go b/plugins/cmd/capabilities/log-event-trigger/main.go new file mode 100644 index 00000000000..8abecf54aeb --- /dev/null +++ b/plugins/cmd/capabilities/log-event-trigger/main.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hashicorp/go-plugin" + + "github.com/smartcontractkit/chainlink/v2/core/capabilities/triggers/logevent" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/loop" + "github.com/smartcontractkit/chainlink-common/pkg/types" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" +) + +const ( + serviceName = "LogEventTriggerCapability" +) + +type LogEventTriggerGRPCService struct { + trigger capabilities.TriggerCapability + s *loop.Server + config logevent.Config +} + +func main() { + s := loop.MustNewStartedServer(serviceName) + defer s.Stop() + + s.Logger.Infof("Starting %s", serviceName) + + stopCh := make(chan struct{}) + defer close(stopCh) + + plugin.Serve(&plugin.ServeConfig{ + HandshakeConfig: loop.StandardCapabilitiesHandshakeConfig(), + Plugins: map[string]plugin.Plugin{ + loop.PluginStandardCapabilitiesName: &loop.StandardCapabilitiesLoop{ + PluginServer: &LogEventTriggerGRPCService{ + s: s, + }, + BrokerConfig: loop.BrokerConfig{Logger: s.Logger, StopCh: stopCh, GRPCOpts: s.GRPCOpts}, + }, + }, + GRPCServer: s.GRPCOpts.NewServer, + }) +} + +func (cs *LogEventTriggerGRPCService) Start(ctx context.Context) error { + return nil +} + +func (cs *LogEventTriggerGRPCService) Close() error { + return nil +} + +func (cs *LogEventTriggerGRPCService) Ready() error { + return nil +} + +func (cs *LogEventTriggerGRPCService) HealthReport() map[string]error { + return nil +} + +func (cs *LogEventTriggerGRPCService) Name() string { + return serviceName +} + +func (cs *LogEventTriggerGRPCService) Infos(ctx context.Context) ([]capabilities.CapabilityInfo, error) { + triggerInfo, err := cs.trigger.Info(ctx) + if err != nil { + return nil, err + } + + return []capabilities.CapabilityInfo{ + triggerInfo, + }, nil +} + +func (cs *LogEventTriggerGRPCService) Initialise( + ctx context.Context, + config string, + telemetryService core.TelemetryService, + store core.KeyValueStore, + capabilityRegistry core.CapabilitiesRegistry, + errorLog core.ErrorLog, + pipelineRunner core.PipelineRunnerService, + relayerSet core.RelayerSet, +) error { + cs.s.Logger.Debugf("Initialising %s", serviceName) + + var logEventConfig logevent.Config + err := json.Unmarshal([]byte(config), &logEventConfig) + if err != nil { + return fmt.Errorf("error decoding log_event_trigger config: %v", err) + } + + relayID := types.NewRelayID(logEventConfig.Network, logEventConfig.ChainID) + relayer, err := relayerSet.Get(ctx, relayID) + if err != nil { + return fmt.Errorf("error fetching relayer for chainID %s from relayerSet: %v", logEventConfig.ChainID, err) + } + + // Set relayer and trigger in LogEventTriggerGRPCService + cs.config = logEventConfig + cs.trigger, err = logevent.NewTriggerService(ctx, cs.s.Logger, relayer, logEventConfig) + if err != nil { + return fmt.Errorf("error creating new trigger for chainID %s: %v", logEventConfig.ChainID, err) + } + + if err := capabilityRegistry.Add(ctx, cs.trigger); err != nil { + return fmt.Errorf("error when adding cron trigger to the registry: %w", err) + } + + return nil +}