From 30351dc20cfd6b5fca9c2435214a0e15bd9012e2 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 12 Aug 2026 15:18:06 +0300 Subject: [PATCH 1/2] feat(local_testnet): archive-exporter node for the aetheria (boole) Step 12 consensus check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional "ssv-exporter" node (nodes.exporter.count, enabled in params-boole) running in archive exporter mode (EXPORTER_MODE=archive) — the mode that registers /v1/exporter/traces/*. It records committee duty traces so the aetheria (boole) suite's Step 12 can verify the post-fork AGGREGATOR_COMMITTEE runner reaches QBFT consensus. Read-only: empty operator key (it doesn't sign; its p2p network key auto-generates), auto-subscribes to all subnets. Boole-only. --- main.star | 10 +++++++++- nodes/ssv/node.star | 18 +++++++++++++----- params-boole.yaml | 4 ++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/main.star b/main.star index 947a3c2..7eb62c1 100644 --- a/main.star +++ b/main.star @@ -195,9 +195,17 @@ def run(plan, args): ssv_configs[service_name] = ssv_node.get_service_config(node_index, config, is_exporter, ssv_image) node_index += 1 + # Optional archive-exporter node (enabled in params-boole). Read-only: an empty operator key — + # it doesn't sign, and its p2p identity auto-generates like every node's. node.star runs it in + # archive mode, which is what serves the committee duty traces the aetheria (boole) Step 12 reads. + exporter_count = args["nodes"].get("exporter", {}).get("count", 0) + if exporter_count > 0: + exporter_config = ssv_node.generate_config(plan, node_index, cl_url, el_ws, "", enr, True, args) + ssv_configs["ssv-exporter"] = ssv_node.get_service_config(node_index, exporter_config, True, ssv_image) + ssv_services = plan.add_services( ssv_configs, - description="Starting {} SSV nodes in parallel".format(ssv_node_count), + description="Starting SSV nodes in parallel", ) first_ssv_name = "ssv-node-{}".format(node_index - ssv_node_count) diff --git a/nodes/ssv/node.star b/nodes/ssv/node.star index bf0dfc8..c5b0cfa 100644 --- a/nodes/ssv/node.star +++ b/nodes/ssv/node.star @@ -68,6 +68,18 @@ def get_service_config(index, config_artifact, is_exporter, image): """Returns a ServiceConfig for an SSV node without starting it (for use with plan.add_services).""" config_path = "{}/ssv-config-{}.yaml".format(SSV_CONFIG_DIR_PATH_ON_SERVICE, index) + env_vars = { + "CONFIG_PATH": config_path, + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": "grpc", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "http://alloy:4317", + } + if is_exporter: + # `archive`, not `standard`: only archive mode registers /v1/exporter/traces/* — the committee + # duty traces the aetheria (boole) Step 12 reads. The exporter auto-subscribes to all subnets, + # so it observes every committee's consensus. + env_vars["EXPORTER"] = "true" + env_vars["EXPORTER_MODE"] = "archive" + return ServiceConfig( image=image, entrypoint=[ @@ -87,11 +99,7 @@ def get_service_config(index, config_artifact, is_exporter, image): application_protocol="http", ), }, - env_vars={ - "CONFIG_PATH": config_path, - "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": "grpc", - "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "http://alloy:4317", - }, + env_vars=env_vars, files={ SSV_CONFIG_DIR_PATH_ON_SERVICE: config_artifact, }, diff --git a/params-boole.yaml b/params-boole.yaml index 68141a7..40b6c87 100644 --- a/params-boole.yaml +++ b/params-boole.yaml @@ -53,6 +53,10 @@ nodes: count: 4 anchor: count: 0 + # Archive-exporter node the aetheria (boole) Step 12 reads to verify AGGREGATOR_COMMITTEE consensus. + # Boole-only (Step 12 is boole-specific); see main.star / node.star for how it's run. + exporter: + count: 1 images: ssv: "node/ssv" From a32c36400066f8dcf8a28bfc455b24077dfb2a32 Mon Sep 17 00:00:00 2001 From: iurii Date: Wed, 12 Aug 2026 17:45:51 +0300 Subject: [PATCH 2/2] refactor(local_testnet): exporter as a boolean, configured via the SSV config Rework the archive-exporter node (aetheria boole Step 12 consensus check) per review feedback: - nodes.exporter.count -> nodes.exporter.enabled. The `ssv-exporter` hostname is a cross-repo contract (aetheria pins it), so the node is singular by design; a boolean matches the monitor.enabled convention instead of a count that can only ever be 0 or 1. - Configure the exporter through the rendered SSV config (exporter.Enabled / exporter.Mode) instead of EXPORTER / EXPORTER_MODE env vars. The flat `Exporter:` template field mapped to no struct field and was inert (the real key is `exporter:`), which is why the env vars were needed. The config file is now the single source of truth, consistent with every other SSV setting, and resolves identically (ApplyDefaults -> yaml -> env). - Drop the now-unused is_exporter param from get_service_config and the dead ssv_node.start() helper. - Rename monitor's misleading ssv_exporter_url param: it receives the first SSV node's API URL, not the exporter. --- main.star | 17 ++++++++++------- monitor/monitor.star | 8 ++++---- nodes/ssv/config.yml.tmpl | 6 ++++-- nodes/ssv/node.star | 31 ++++++++++--------------------- params-boole.yaml | 5 +++-- 5 files changed, 31 insertions(+), 36 deletions(-) diff --git a/main.star b/main.star index 7eb62c1..9fd8a6a 100644 --- a/main.star +++ b/main.star @@ -192,22 +192,25 @@ def run(plan, args): is_exporter = False config = ssv_node.generate_config(plan, node_index, cl_url, el_ws, private_keys[node_index], enr, is_exporter, args) service_name = "ssv-node-{}".format(node_index) - ssv_configs[service_name] = ssv_node.get_service_config(node_index, config, is_exporter, ssv_image) + ssv_configs[service_name] = ssv_node.get_service_config(node_index, config, ssv_image) node_index += 1 # Optional archive-exporter node (enabled in params-boole). Read-only: an empty operator key — - # it doesn't sign, and its p2p identity auto-generates like every node's. node.star runs it in - # archive mode, which is what serves the committee duty traces the aetheria (boole) Step 12 reads. - exporter_count = args["nodes"].get("exporter", {}).get("count", 0) - if exporter_count > 0: + # it doesn't sign, and its p2p identity auto-generates like every node's. generate_config renders + # it in archive mode, which serves the committee duty traces the aetheria (boole) Step 12 reads. + # Singular by design (the aetheria side pins the hostname `ssv-exporter`), hence a bool, not a count. + exporter_enabled = args["nodes"].get("exporter", {}).get("enabled", False) + if exporter_enabled: exporter_config = ssv_node.generate_config(plan, node_index, cl_url, el_ws, "", enr, True, args) - ssv_configs["ssv-exporter"] = ssv_node.get_service_config(node_index, exporter_config, True, ssv_image) + ssv_configs["ssv-exporter"] = ssv_node.get_service_config(node_index, exporter_config, ssv_image) ssv_services = plan.add_services( ssv_configs, - description="Starting SSV nodes in parallel", + description="Starting {} SSV services in parallel".format(len(ssv_configs)), ) + # The exporter above reused node_index without incrementing it, so node_index is still + # anchor_node_count + ssv_node_count here — this resolves to the first SSV node. first_ssv_name = "ssv-node-{}".format(node_index - ssv_node_count) ssv_node_api_url = ssv_services[first_ssv_name].ports[ssv_node.SSV_API_PORT_NAME].url diff --git a/monitor/monitor.star b/monitor/monitor.star index d71a88d..a995bf8 100644 --- a/monitor/monitor.star +++ b/monitor/monitor.star @@ -2,11 +2,11 @@ constants = import_module("../utils/constants.star") postgres = import_module("postgres.star") redis = import_module("redis.star") -def start(plan, ssv_exporter_url, cl_url, monitor_image, postgres_image, redis_image): +def start(plan, ssv_node_api_url, cl_url, monitor_image, postgres_image, redis_image): postgres_service_name, postgres_port = postgres.start(plan, postgres_image) redis_service_name, redis_port = redis.start(plan, redis_image) - env_vars = shared_envs(postgres_service_name, postgres_port, redis_service_name, redis_port, ssv_exporter_url, cl_url) + env_vars = shared_envs(postgres_service_name, postgres_port, redis_service_name, redis_port, ssv_node_api_url, cl_url) plan.add_service( name="monitor-daemon", @@ -46,13 +46,13 @@ def shared_envs( postgres_port, redis_service_name, redis_port, - ssv_exporter_url, + ssv_node_api_url, cl_url): return { "NETWORK": "other", "BEACON_ADDR": cl_url, "DEFAULT_POOL": "ssv", - "POOLS": '[{{"id":1,"name":"ssv","indices":[],"endpoint":"{}/v1/validators"}}]'.format(ssv_exporter_url), + "POOLS": '[{{"id":1,"name":"ssv","indices":[],"endpoint":"{}/v1/validators"}}]'.format(ssv_node_api_url), "POSTGRES_URL": "postgres://postgres:postgres@{}:{}/monitor?sslmode=disable".format(postgres_service_name, postgres_port), "REDIS_URL": "redis://{}:{}".format(redis_service_name, redis_port), "LOG_LEVEL": "debug", diff --git a/nodes/ssv/config.yml.tmpl b/nodes/ssv/config.yml.tmpl index c01d4f7..d33dcac 100644 --- a/nodes/ssv/config.yml.tmpl +++ b/nodes/ssv/config.yml.tmpl @@ -23,8 +23,10 @@ ssv: Forks: GasLimit36: 0 Boole: {{ .BooleEpoch }} +exporter: + Enabled: {{ .ExporterEnabled }} + Mode: "{{ .ExporterMode }}" OperatorPrivateKey: "{{ .OperatorPrivateKey }}" SSVAPIPort: {{ .SSVAPIPort }} -Exporter: "{{ .Exporter }}" EnableTraces: {{ .EnableTraces }} -MetricsAPIPort: {{ .MetricsAPIPort }} \ No newline at end of file +MetricsAPIPort: {{ .MetricsAPIPort }} diff --git a/nodes/ssv/node.star b/nodes/ssv/node.star index c5b0cfa..b90b0d9 100644 --- a/nodes/ssv/node.star +++ b/nodes/ssv/node.star @@ -41,7 +41,10 @@ def generate_config( DiscoveryProtocolID = "0x737376647635", # ssvdv5 Discovery=discovery, ENR=enr, - Exporter=is_exporter, + ExporterEnabled=is_exporter, + # archive (not standard) also registers /v1/exporter/traces/* — the committee duty traces the + # aetheria (boole) Step 12 reads; standard mode wouldn't. Inert for operator nodes (ExporterEnabled=False). + ExporterMode="archive" if is_exporter else "standard", SSVAPIPort=SSV_API_PORT, MetricsAPIPort=SSV_METRICS_PORT, EnableTraces=True, @@ -64,22 +67,10 @@ def generate_config( SSV_CONFIG_DIR_PATH_ON_SERVICE = "/ssv-config" -def get_service_config(index, config_artifact, is_exporter, image): +def get_service_config(index, config_artifact, image): """Returns a ServiceConfig for an SSV node without starting it (for use with plan.add_services).""" config_path = "{}/ssv-config-{}.yaml".format(SSV_CONFIG_DIR_PATH_ON_SERVICE, index) - env_vars = { - "CONFIG_PATH": config_path, - "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": "grpc", - "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "http://alloy:4317", - } - if is_exporter: - # `archive`, not `standard`: only archive mode registers /v1/exporter/traces/* — the committee - # duty traces the aetheria (boole) Step 12 reads. The exporter auto-subscribes to all subnets, - # so it observes every committee's consensus. - env_vars["EXPORTER"] = "true" - env_vars["EXPORTER_MODE"] = "archive" - return ServiceConfig( image=image, entrypoint=[ @@ -99,14 +90,12 @@ def get_service_config(index, config_artifact, is_exporter, image): application_protocol="http", ), }, - env_vars=env_vars, + env_vars={ + "CONFIG_PATH": config_path, + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": "grpc", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "http://alloy:4317", + }, files={ SSV_CONFIG_DIR_PATH_ON_SERVICE: config_artifact, }, ) - -def start(plan, index, config_artifact, is_exporter, image): - """Starts a single SSV node. Use get_service_config + plan.add_services for parallel startup.""" - service_name = "ssv-node-{}".format(index) if not is_exporter else "ssv-exporter" - service_config = get_service_config(index, config_artifact, is_exporter, image) - return plan.add_service(service_name, service_config) diff --git a/params-boole.yaml b/params-boole.yaml index 40b6c87..0b2525b 100644 --- a/params-boole.yaml +++ b/params-boole.yaml @@ -54,9 +54,10 @@ nodes: anchor: count: 0 # Archive-exporter node the aetheria (boole) Step 12 reads to verify AGGREGATOR_COMMITTEE consensus. - # Boole-only (Step 12 is boole-specific); see main.star / node.star for how it's run. + # Boole-only (Step 12 is boole-specific); see main.star / node.star for how it's run. Singular by + # design — the aetheria side pins the hostname `ssv-exporter` — so this is a boolean, not a count. exporter: - count: 1 + enabled: true images: ssv: "node/ssv"