Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/quiet-geese-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink": minor
---

#added adds the option to not specify a node address in gateway connector config
71 changes: 64 additions & 7 deletions core/capabilities/gateway_connector/service_wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package gatewayconnector

import (
"context"
"errors"
"math/big"

"github.com/ethereum/go-ethereum/common"
"github.com/jonboulle/clockwork"
Expand All @@ -13,22 +15,33 @@ import (
gwcommon "github.com/smartcontractkit/chainlink/v2/core/services/gateway/common"
"github.com/smartcontractkit/chainlink/v2/core/services/gateway/connector"
"github.com/smartcontractkit/chainlink/v2/core/services/gateway/network"
"github.com/smartcontractkit/chainlink/v2/core/services/keystore"
"github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/ethkey"
)

type Keystore interface {
keys.AddressChecker
keys.MessageSigner
}

// OrderedKeyProvider is an interface for keystores that support
// ordered key discovery. This allows auto-discovery of node addresses.
type OrderedKeyProvider interface {
ListKeys(ctx context.Context, chainID *big.Int, opts *keystore.ListKeysOptions) (keys []ethkey.KeyV2, err error)
}

type ServiceWrapper struct {
services.StateMachine
stopCh services.StopChan

config config.GatewayConnector
keystore Keystore
connector connector.GatewayConnector
lggr logger.Logger
clock clockwork.Clock
config config.GatewayConnector
keystore Keystore
okp OrderedKeyProvider
chainID *big.Int
connector connector.GatewayConnector
lggr logger.Logger
clock clockwork.Clock
discoveredNodeAddress string // Stores auto-discovered node address if not configured
}

func translateConfigs(f config.GatewayConnector) connector.ConnectorConfig {
Expand All @@ -50,11 +63,22 @@ func translateConfigs(f config.GatewayConnector) connector.ConnectorConfig {
}

// NOTE: this wrapper is needed to make sure that our services are started after Keystore.
func NewGatewayConnectorServiceWrapper(config config.GatewayConnector, keystore Keystore, clock clockwork.Clock, lggr logger.Logger) *ServiceWrapper {
// keystore is used for signing operations.
// chainID is the chain ID for which keys should be discovered.
func NewGatewayConnectorServiceWrapper(
config config.GatewayConnector,
keystore Keystore,
okp OrderedKeyProvider,
chainID *big.Int,
clock clockwork.Clock,
lggr logger.Logger,
) *ServiceWrapper {
return &ServiceWrapper{
stopCh: make(services.StopChan),
config: config,
keystore: keystore,
okp: okp,
chainID: chainID,
clock: clock,
lggr: logger.Named(lggr, "GatewayConnectorServiceWrapper"),
}
Expand All @@ -64,13 +88,42 @@ func (e *ServiceWrapper) Start(ctx context.Context) error {
return e.StartOnce("GatewayConnectorServiceWrapper", func() error {
conf := e.config
nodeAddress := conf.NodeAddress()

// Auto-discover node address if not configured
if nodeAddress == "" {
if e.okp == nil {
return errors.New("NodeAddress must be configured when ordered key provider is not available")
}
keys, err := e.okp.ListKeys(ctx, e.chainID, &keystore.ListKeysOptions{
SortBy: keystore.SortByInsertOrder,
})
if err != nil {
return err
}

if len(keys) == 0 {
return errors.New("no enabled keys found for auto-discovery")
}

// Use the first account (lowest State.ID) as the node address
nodeAddress = keys[0].Address.String()
e.discoveredNodeAddress = nodeAddress
e.lggr.Infow("Auto-discovered node address", "address", nodeAddress)
}

configuredNodeAddress := common.HexToAddress(nodeAddress)
err := e.keystore.CheckEnabled(ctx, configuredNodeAddress)
if err != nil {
return err
}

// Update config with discovered address for translateConfigs
translated := translateConfigs(conf)
// Override NodeAddress in translated config if we auto-discovered it
if translated.NodeAddress == "" {
translated.NodeAddress = nodeAddress
}

e.connector, err = connector.NewGatewayConnector(&translated, e, e.clock, e.lggr)
if err != nil {
return err
Expand All @@ -80,7 +133,11 @@ func (e *ServiceWrapper) Start(ctx context.Context) error {
}

func (e *ServiceWrapper) Sign(ctx context.Context, data ...[]byte) ([]byte, error) {
account := common.HexToAddress(e.config.NodeAddress())
nodeAddress := e.config.NodeAddress()
if nodeAddress == "" {
nodeAddress = e.discoveredNodeAddress
}
account := common.HexToAddress(nodeAddress)
return e.keystore.SignMessage(ctx, account, gwcommon.Flatten(data...))
}

Expand Down
206 changes: 205 additions & 1 deletion core/capabilities/gateway_connector/service_wrapper_test.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,46 @@
package gatewayconnector_test

import (
"context"
"crypto/ecdsa"
"math/big"
"testing"

"github.com/jonboulle/clockwork"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/chainlink-common/pkg/logger"
"github.com/smartcontractkit/chainlink-evm/pkg/keys"
"github.com/smartcontractkit/chainlink-evm/pkg/keys/keystest"
gatewayconnector "github.com/smartcontractkit/chainlink/v2/core/capabilities/gateway_connector"
"github.com/smartcontractkit/chainlink/v2/core/config/toml"
"github.com/smartcontractkit/chainlink/v2/core/internal/testutils"
"github.com/smartcontractkit/chainlink/v2/core/services/chainlink"
"github.com/smartcontractkit/chainlink/v2/core/services/keystore"
"github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/ethkey"
evmtestutils "github.com/smartcontractkit/chainlink/v2/core/services/relay/evm/capabilities/testutils"
"github.com/smartcontractkit/chainlink/v2/core/utils"
)

// fakeOrderedKeyProvider is a simple fake implementation for testing
type fakeOrderedKeyProvider struct {
keys []ethkey.KeyV2
err error
chainID *big.Int
}

func (f *fakeOrderedKeyProvider) ListKeys(ctx context.Context, chainID *big.Int, opts *keystore.ListKeysOptions) ([]ethkey.KeyV2, error) {
if f.err != nil {
return nil, f.err
}
// Verify chainID matches
if f.chainID != nil && f.chainID.Cmp(chainID) != 0 {
return nil, assert.AnError
}
return f.keys, nil
}

func generateWrapper(t *testing.T, privateKey *ecdsa.PrivateKey, keystoreKey *ecdsa.PrivateKey) (*gatewayconnector.ServiceWrapper, error) {
logger := logger.Test(t)
privateKeyV2 := ethkey.FromPrivateKey(privateKey)
Expand All @@ -42,7 +66,8 @@ func generateWrapper(t *testing.T, privateKey *ecdsa.PrivateKey, keystoreKey *ec
}.New()
ethKeystore := &keystest.FakeChainStore{Addresses: keystest.Addresses{keystoreKeyV2.Address}}
gc := config.Capabilities().GatewayConnector()
wrapper := gatewayconnector.NewGatewayConnectorServiceWrapper(gc, ethKeystore, clockwork.NewFakeClock(), logger)
chainID := big.NewInt(1)
wrapper := gatewayconnector.NewGatewayConnectorServiceWrapper(gc, ethKeystore, nil, chainID, clockwork.NewFakeClock(), logger)
require.NoError(t, err)
return wrapper, err
}
Expand Down Expand Up @@ -77,3 +102,182 @@ func TestGatewayConnectorServiceWrapper_NonexistentKey(t *testing.T) {
}

func ptr[T any](t T) *T { return &t }

// setupAutoDiscoverTest creates a wrapper with auto-discovery configuration
func setupAutoDiscoverTest(
t *testing.T,
nodeAddress *string,
orderedKeyProvider gatewayconnector.OrderedKeyProvider,
keystoreAddresses []ethkey.KeyV2,
addressToPrivateKey map[string]*ecdsa.PrivateKey,
) (*gatewayconnector.ServiceWrapper, error) {
logger := logger.Test(t)
chainID := big.NewInt(1)

config, err := chainlink.GeneralConfigOpts{
Config: chainlink.Config{
Core: toml.Core{
Capabilities: toml.Capabilities{
GatewayConnector: toml.GatewayConnector{
ChainIDForNodeKey: ptr("1"),
NodeAddress: nodeAddress,
DonID: ptr("5"),
WSHandshakeTimeoutMillis: ptr[uint32](100),
AuthMinChallengeLen: ptr[int](0),
AuthTimestampToleranceSec: ptr[uint32](10),
Gateways: []toml.ConnectorGateway{{ID: ptr("example_gateway"), URL: ptr("wss://localhost:8081/node")}},
},
},
},
},
}.New()
require.NoError(t, err)

var ethKeystore keys.Store
if addressToPrivateKey != nil {
// Use signing keystore that actually signs
// addressToPrivateKey is already map[string]*ecdsa.PrivateKey
ethKeystore = evmtestutils.NewSigningKeystore(addressToPrivateKey, keystoreAddresses)
} else {
// Use fake keystore for tests that don't need actual signing
addresses := make(keystest.Addresses, len(keystoreAddresses))
for i, key := range keystoreAddresses {
addresses[i] = key.Address
}
ethKeystore = &keystest.FakeChainStore{Addresses: addresses}
}
gc := config.Capabilities().GatewayConnector()

wrapper := gatewayconnector.NewGatewayConnectorServiceWrapper(
gc,
ethKeystore,
orderedKeyProvider,
chainID,
clockwork.NewFakeClock(),
logger,
)

return wrapper, nil
}

func TestGatewayConnectorServiceWrapper_AutoDiscoverNodeAddress(t *testing.T) {
t.Parallel()

key1, _ := testutils.NewPrivateKeyAndAddress(t)
key2, _ := testutils.NewPrivateKeyAndAddress(t)
keystoreKey, _ := testutils.NewPrivateKeyAndAddress(t)

key1V2 := ethkey.FromPrivateKey(key1)
key2V2 := ethkey.FromPrivateKey(key2)
keystoreKeyV2 := ethkey.FromPrivateKey(keystoreKey)

chainID := big.NewInt(1)
orderedKeyProvider := &fakeOrderedKeyProvider{
keys: []ethkey.KeyV2{key1V2, key2V2},
chainID: chainID,
}

// Create address to private key mapping for signing keystore
addressToKey := map[string]*ecdsa.PrivateKey{
key1V2.Address.Hex(): key1,
keystoreKeyV2.Address.Hex(): keystoreKey,
}
wrapper, err := setupAutoDiscoverTest(t, nil, orderedKeyProvider, []ethkey.KeyV2{key1V2, keystoreKeyV2}, addressToKey)
require.NoError(t, err)

ctx := testutils.Context(t)
err = wrapper.Start(ctx)
require.NoError(t, err)

// Verify that Sign() uses the discovered address (key1V2) by verifying the signature
testData := []byte("test")
wrapperSignature, err := wrapper.Sign(ctx, testData)
require.NoError(t, err, "Sign should succeed with auto-discovered address")

// Verify the signature was created with key1's address using utils
recoveredAddr, err := utils.GetSignersEthAddress(testData, wrapperSignature)
require.NoError(t, err, "Should be able to recover address from signature")
assert.Equal(t, key1V2.Address, recoveredAddr, "Signature should be from key1V2 (the discovered address)")

t.Cleanup(func() {
assert.NoError(t, wrapper.Close())
})
}

func TestGatewayConnectorServiceWrapper_AutoDiscover(t *testing.T) {
t.Parallel()

tests := []struct {
name string
nodeAddress *string
orderedKeyProvider gatewayconnector.OrderedKeyProvider
keystoreKeyCount int
wantErr bool
errContains string
expectedErr error
}{
{
name: "no provider",
nodeAddress: nil,
orderedKeyProvider: nil,
keystoreKeyCount: 2,
wantErr: true,
errContains: "NodeAddress must be configured when ordered key provider is not available",
},
{
name: "no keys",
nodeAddress: nil,
orderedKeyProvider: &fakeOrderedKeyProvider{
keys: []ethkey.KeyV2{},
chainID: big.NewInt(1),
},
keystoreKeyCount: 1,
wantErr: true,
errContains: "no enabled keys found for auto-discovery",
},
{
name: "provider error",
nodeAddress: nil,
orderedKeyProvider: &fakeOrderedKeyProvider{
keys: nil,
err: assert.AnError,
chainID: big.NewInt(1),
},
keystoreKeyCount: 1,
wantErr: true,
expectedErr: assert.AnError,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

keystoreKeysV2 := make([]ethkey.KeyV2, tt.keystoreKeyCount)
for i := 0; i < tt.keystoreKeyCount; i++ {
key, _ := testutils.NewPrivateKeyAndAddress(t)
keystoreKeysV2[i] = ethkey.FromPrivateKey(key)
}

wrapper, err := setupAutoDiscoverTest(t, tt.nodeAddress, tt.orderedKeyProvider, keystoreKeysV2, nil)
require.NoError(t, err)

ctx := testutils.Context(t)
err = wrapper.Start(ctx)
if tt.wantErr {
require.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
if tt.expectedErr != nil {
assert.Equal(t, tt.expectedErr, err)
}
} else {
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, wrapper.Close())
})
}
})
}
}
5 changes: 4 additions & 1 deletion core/services/chainlink/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -1010,9 +1010,12 @@ func newCREServices(
if !ok {
return nil, fmt.Errorf("failed to parse gateway connector chain ID as integer: %s", capCfg.GatewayConnector().ChainIDForNodeKey())
}
ethKeystore := keyStore.Eth()
gatewayConnectorWrapper = gatewayconnector.NewGatewayConnectorServiceWrapper(
capCfg.GatewayConnector(),
keys.NewStore(keystore.NewEthSigner(keyStore.Eth(), chainID)),
keys.NewStore(keystore.NewEthSigner(ethKeystore, chainID)),
ethKeystore,
chainID,
clockwork.NewRealClock(),
globalLogger)
srvcs = append(srvcs, gatewayConnectorWrapper)
Expand Down
Loading
Loading