diff --git a/server/configuration_watcher.go b/server/configuration_watcher.go index 1ba9976..6b08b9a 100644 --- a/server/configuration_watcher.go +++ b/server/configuration_watcher.go @@ -2,17 +2,20 @@ package server import ( "errors" + "fmt" "maps" "net/url" "os" + "github.com/ethereum/go-ethereum/log" "gopkg.in/yaml.v3" ) var ErrCustomerNotConfigured = errors.New("customer is not configured") type CustomersConfig struct { - URLs map[string][]string `yaml:"urls"` + URLs map[string][]string `yaml:"urls"` + Presets map[string]string `yaml:"presets,omitempty"` } // ConfigurationWatcher @@ -20,31 +23,64 @@ type CustomersConfig struct { type ConfigurationWatcher struct { // CustomersConfig represents config for each custom with allowed list of configuration parameters ParsedCustomersConfig map[string][]URLParameters + // ParsedPresets contains pre-parsed preset configurations for header-based override + ParsedPresets map[string]URLParameters +} + +// parseURLToParameters converts a raw URL string to URLParameters +func parseURLToParameters(rawURL string) (URLParameters, error) { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return URLParameters{}, fmt.Errorf("failed to parse URL: %w", err) + } + + params, err := ExtractParametersFromUrl(parsedURL, nil) + if err != nil { + return URLParameters{}, fmt.Errorf("failed to extract parameters: %w", err) + } + + return params, nil } func NewConfigurationWatcher(customersConfig CustomersConfig) (*ConfigurationWatcher, error) { parsedCustomersConfig := make(map[string][]URLParameters) - for k, v := range customersConfig.URLs { - var allowedConfigs []URLParameters - for _, rawUrl := range v { - parsedUrl, err := url.Parse(rawUrl) + for customerID, urls := range customersConfig.URLs { + allowedConfigs := make([]URLParameters, 0, len(urls)) + for _, rawURL := range urls { + urlParam, err := parseURLToParameters(rawURL) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid URL for customer %s: %w", customerID, err) } - URLParam, err := ExtractParametersFromUrl(parsedUrl, nil) - if err != nil { - return nil, err - } - allowedConfigs = append(allowedConfigs, URLParam) + allowedConfigs = append(allowedConfigs, urlParam) + } + parsedCustomersConfig[customerID] = allowedConfigs + } + + // Parse presets for header-based override + parsedPresets := make(map[string]URLParameters) + for originID, presetURL := range customersConfig.Presets { + params, err := parseURLToParameters(presetURL) + if err != nil { + // Log error but continue - graceful degradation + log.Error("Failed to parse preset configuration", "originID", originID, "url", presetURL, "error", err) + continue } - parsedCustomersConfig[k] = allowedConfigs + parsedPresets[originID] = params + log.Info("Loaded preset configuration", "originID", originID) } - return &ConfigurationWatcher{ParsedCustomersConfig: parsedCustomersConfig}, nil + + return &ConfigurationWatcher{ + ParsedCustomersConfig: parsedCustomersConfig, + ParsedPresets: parsedPresets, + }, nil } func ReadCustomerConfigFromFile(fileName string) (*ConfigurationWatcher, error) { if fileName == "" { - return &ConfigurationWatcher{ParsedCustomersConfig: make(map[string][]URLParameters)}, nil + return &ConfigurationWatcher{ + ParsedCustomersConfig: make(map[string][]URLParameters), + ParsedPresets: make(map[string]URLParameters), + }, nil } data, err := os.ReadFile(fileName) if err != nil { diff --git a/server/configuration_watcher_test.go b/server/configuration_watcher_test.go new file mode 100644 index 0000000..4d242ce --- /dev/null +++ b/server/configuration_watcher_test.go @@ -0,0 +1,54 @@ +package server + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConfigurationWatcherPresets(t *testing.T) { + // Test the core business logic: valid presets are parsed and available + config := CustomersConfig{ + URLs: map[string][]string{ + "quicknode": {"/fast?originId=quicknode"}, + }, + Presets: map[string]string{ + "quicknode": "/fast?originId=quicknode&refund=0x1234567890123456789012345678901234567890:90", + }, + } + + watcher, err := NewConfigurationWatcher(config) + require.NoError(t, err) + require.NotNil(t, watcher) + + // Core functionality: preset should be parsed and available + preset, exists := watcher.ParsedPresets["quicknode"] + require.True(t, exists) + require.Equal(t, "quicknode", preset.originId) + require.True(t, preset.fast) + require.Equal(t, 1, len(preset.pref.Validity.Refund)) +} + +func TestConfigurationWatcherInvalidPresets(t *testing.T) { + // Test graceful degradation: invalid presets are skipped, don't break startup + config := CustomersConfig{ + URLs: map[string][]string{ + "test": {"/fast?originId=test"}, + }, + Presets: map[string]string{ + "valid": "/fast?originId=valid", + "invalid": "://invalid-url", // This should be skipped + }, + } + + watcher, err := NewConfigurationWatcher(config) + require.NoError(t, err) // Should not fail startup + + // Valid preset loaded + _, exists := watcher.ParsedPresets["valid"] + require.True(t, exists) + + // Invalid preset skipped + _, exists = watcher.ParsedPresets["invalid"] + require.False(t, exists) +} \ No newline at end of file diff --git a/server/request_handler.go b/server/request_handler.go index afc12c4..1ebd5ac 100644 --- a/server/request_handler.go +++ b/server/request_handler.go @@ -73,6 +73,28 @@ func NewRpcRequestHandler( } } +// getEffectiveParameters determines the URL parameters to use for this request. +// It checks for header-based preset override first, then falls back to URL parsing. +func (r *RpcRequestHandler) getEffectiveParameters() (URLParameters, error) { + extracted, err := ExtractParametersFromUrl(r.req.URL, r.builderNames) + if err != nil { + return extracted, err + } + if r.configurationWatcher == nil { + return extracted, nil + } + originID := extracted.originId + if headerOriginID := r.req.Header.Get("X-Flashbots-Origin"); headerOriginID != "" { + originID = headerOriginID + } + if preset, exists := r.configurationWatcher.ParsedPresets[originID]; exists { + r.logger.Info("Using preset configuration", "originID", originID) + return preset, nil + } + + return extracted, nil +} + // nolint func (r *RpcRequestHandler) process() { r.logger = r.logger.New("uid", r.uid) @@ -132,7 +154,7 @@ func (r *RpcRequestHandler) process() { } // mev-share parameters - urlParams, err := ExtractParametersFromUrl(r.req.URL, r.builderNames) + urlParams, err := r.getEffectiveParameters() if err != nil { r.logger.Warn("[process] Invalid auction preference", "error", err, "url", r.req.URL) res := AuctionPreferenceErrorToJSONRPCResponse(jsonReq, err) diff --git a/server/request_handler_test.go b/server/request_handler_test.go new file mode 100644 index 0000000..cfdf2e5 --- /dev/null +++ b/server/request_handler_test.go @@ -0,0 +1,94 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +func TestGetEffectiveParameters(t *testing.T) { + // Core business logic test: header with preset uses preset (ignores URL) + config := CustomersConfig{ + Presets: map[string]string{ + "quicknode": "/fast?originId=quicknode&refund=0x1234567890123456789012345678901234567890:90", + }, + } + + watcher, err := NewConfigurationWatcher(config) + require.NoError(t, err) + + // Request with header and different URL parameters + req := httptest.NewRequest(http.MethodPost, "/fast?originId=user-provided&refund=0xdadB0d80178819F2319190D340ce9A924f783711:10", nil) + req.Header.Set("X-Flashbots-Origin", "quicknode") + + w := httptest.NewRecorder() + respw := http.ResponseWriter(w) + + handler := &RpcRequestHandler{ + respw: &respw, + req: req, + logger: log.New(), + builderNames: []string{"flashbots"}, + configurationWatcher: watcher, + } + + params, err := handler.getEffectiveParameters() + require.NoError(t, err) + + // Should use preset values, ignore URL + require.Equal(t, "quicknode", params.originId) + require.True(t, params.fast) + require.Equal(t, 1, len(params.pref.Validity.Refund)) // Preset refund, not URL refund + require.Equal(t, params.pref.Validity.Refund[0].Address, common.HexToAddress("0x1234567890123456789012345678901234567890")) +} + +func TestGetEffectiveParametersNoHeader(t *testing.T) { + // Fallback behavior: no header uses URL normally + req := httptest.NewRequest(http.MethodPost, "/fast?originId=normal-user", nil) + // No X-Flashbots-Origin-ID header + + w := httptest.NewRecorder() + respw := http.ResponseWriter(w) + + handler := &RpcRequestHandler{ + respw: &respw, + req: req, + logger: log.New(), + builderNames: []string{"flashbots"}, + configurationWatcher: &ConfigurationWatcher{ + ParsedPresets: make(map[string]URLParameters), + }, + } + + params, err := handler.getEffectiveParameters() + require.NoError(t, err) + require.Equal(t, "normal-user", params.originId) + require.True(t, params.fast) +} + +func TestGetEffectiveParametersHeaderNoPreset(t *testing.T) { + // Edge case: header present but no matching preset falls back to URL + req := httptest.NewRequest(http.MethodPost, "/fast?originId=fallback-user", nil) + req.Header.Set("X-Flashbots-Origin", "unknown") + + w := httptest.NewRecorder() + respw := http.ResponseWriter(w) + + handler := &RpcRequestHandler{ + respw: &respw, + req: req, + logger: log.New(), + builderNames: []string{"flashbots"}, + configurationWatcher: &ConfigurationWatcher{ + ParsedPresets: make(map[string]URLParameters), + }, + } + + params, err := handler.getEffectiveParameters() + require.NoError(t, err) + require.Equal(t, "fallback-user", params.originId) +}