From 0ffe4216054ac23acb2a5473f21fe1786d24d750 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 11 Aug 2026 14:03:27 -0700 Subject: [PATCH 01/21] docs: port the content refresh from PR #472 onto main Brings over the docs/docs tree, sidebars.js and api/signer-api.yml from d628b78 (PR #472 head) as a straight file replacement. The docs on main have not changed since that PR's merge base, so this is a clean port. New pages: - get_started/mux-key-loaders.md - get_started/running/k8s.md - get_started/running/metrics-catalog.md - developing/extending-pbs.md - developing/commit-modules.md (replaces developing/commit-module.md) Removed: developing/custom-modules.md (folded into commit-modules.md). sidebars.js rides along because it is what wires the new pages in, and it also adds developing/prop-commit-signing.md, which was an orphan on main. Out of scope here (later PR): docs/versioned_docs, docs/versions.json, the docusaurus.config.js versioning keys, docs/src/pages/index.js and crates/common/src/config/module.rs. --- api/signer-api.yml | 530 +++++++++--------- docs/docs/architecture/overview.md | 4 +- docs/docs/developing/commit-module.md | 132 ----- docs/docs/developing/commit-modules.md | 163 ++++++ docs/docs/developing/custom-modules.md | 12 - docs/docs/developing/extending-pbs.md | 111 ++++ docs/docs/developing/prop-commit-signing.md | 120 +++- docs/docs/get_started/building.md | 14 +- docs/docs/get_started/configuration.md | 139 +++-- docs/docs/get_started/mux-key-loaders.md | 276 +++++++++ docs/docs/get_started/overview.md | 39 +- docs/docs/get_started/running/binary.md | 25 +- docs/docs/get_started/running/docker.md | 85 +-- docs/docs/get_started/running/k8s.md | 75 +++ .../get_started/running/metrics-catalog.md | 55 ++ docs/docs/get_started/running/metrics.md | 6 +- docs/docs/get_started/troubleshooting.md | 198 ++++++- docs/docs/res/img/consensus-key-sign.png | Bin 0 -> 103080 bytes docs/docs/res/img/proxy-key-sign.png | Bin 0 -> 111225 bytes docs/sidebars.js | 9 +- 20 files changed, 1452 insertions(+), 541 deletions(-) delete mode 100644 docs/docs/developing/commit-module.md create mode 100644 docs/docs/developing/commit-modules.md delete mode 100644 docs/docs/developing/custom-modules.md create mode 100644 docs/docs/developing/extending-pbs.md create mode 100644 docs/docs/get_started/mux-key-loaders.md create mode 100644 docs/docs/get_started/running/k8s.md create mode 100644 docs/docs/get_started/running/metrics-catalog.md create mode 100644 docs/docs/res/img/consensus-key-sign.png create mode 100644 docs/docs/res/img/proxy-key-sign.png diff --git a/api/signer-api.yml b/api/signer-api.yml index be44f8fdd..931e88699 100644 --- a/api/signer-api.yml +++ b/api/signer-api.yml @@ -2,7 +2,31 @@ openapi: "3.1.1" info: title: Signer API version: "0.2.0" - description: API that allows commit modules to request generic signatures from validators + description: | + API that allows commit modules to request generic signatures from validators. + + ## Authentication + + All endpoints (except `/status`) require a Bearer JWT in the `Authorization` header. + + ### Module JWT claims + + HS256-signed token with the module's pre-shared secret (`CB_SIGNER_JWT` env var): + + - **module** (string, required) — Module `id` from `[[modules]]` in `cb-config.toml`. + - **route** (string, required) — Exact request path, e.g. `/signer/v1/get_pubkeys`. + - **exp** (integer, required) — UNIX expiry timestamp. Expires after 5 minutes. + - **payload_hash** (string, POST only) — Keccak-256 hash of the JSON request body, `0x`-prefixed. Prevents JWT replay attacks. + + Refresh is client-side — the module generates a new JWT locally. No refresh endpoint. + + ### Admin JWT + + Admin endpoints use a separate secret (`CB_SIGNER_ADMIN_JWT` env var) and include `admin: true` in claims. + + ### Rate limiting + + Per-IP rate limit on JWT auth failures. Default: 3 failures within 5 minutes. Configurable via `[signer].jwt_auth_fail_limit` and `jwt_auth_fail_timeout_seconds` in `cb-config.toml`. tags: - name: Signer - name: Management @@ -10,9 +34,9 @@ paths: /signer/v1/get_pubkeys: get: summary: Get a list of public keys for which signatures may be requested - description: > + description: | This endpoint requires a valid JWT Bearer token. - + The token **must include** the following claims: - `exp` (integer): Expiration timestamp - `route` (string): The route being requested (must be `/signer/v1/get_pubkeys` for this endpoint). @@ -51,26 +75,17 @@ paths: "500": description: Internal error content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 500 - message: - type: string - example: "Internal error" + type: string + example: "internal error" /signer/v1/request_signature/bls: post: summary: Request a signature for a 32-byte blob of data (typically a hash), signed by the BLS private key for the requested public key. - description: > + description: | This endpoint requires a valid JWT Bearer token. - + The token **must include** the following claims: - `exp` (integer): Expiration timestamp - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. @@ -114,111 +129,65 @@ paths: "400": description: | This can occur in several scenarios: + - The request did not include a valid `Authorization` header with a Bearer token (a missing or malformed header is rejected with `400`, not `401`). - The Commit-Boost configuration file does not contain a signing ID for the module that made the request. - You requested an operation while using the Dirk signer mode instead of locally-managed signer mode, but Dirk doesn't support that operation. - Something went wrong while preparing your request; the error text will provide more information. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 400 - message: - type: string - example: "Bad request: Invalid pubkey format" + type: string + example: "Dirk signer does not support this operation" "401": - description: The requesting module did not provide a JWT string in the request's authorization header, or the JWT string was not configured in the signer service's configuration file as belonging to the module. + description: The JWT provided in the request's authorization header was invalid. For example, it was expired, signed with the wrong secret, or its claims did not match the request. Note that a request with a missing or malformed `Authorization` header is rejected with `400` instead. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 401 - message: - type: string - example: "Unauthorized" - + type: string + example: "unauthorized" + "404": description: You either requested a route that doesn't exist, or you requested a signature from a key that does not exist. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 404 - message: - type: string - example: "Unknown pubkey" + type: string + example: "unknown consensus signer: 0xa9e9cff900de07e295a044789fd4bdb6785eb0651ad282f9e76d12afd87e75180bdd64caf2e315b815d7322bd31ab48a" + "422": + description: The request body could not be deserialized. For example, the pubkey was not valid hex of the expected length. + content: + text/plain: + schema: + type: string + example: "Failed to deserialize the JSON body into the target type" "429": description: Your module attempted and failed JWT authentication too many times recently, and is currently timed out. It cannot make any more requests until the timeout ends. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 429 - message: - type: string - example: "Too many requests" + type: string + example: "rate limited for 12.3s" "500": description: Your request was valid, but something went wrong internally that prevented it from being fulfilled. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 500 - message: - type: string - example: "Internal error" + type: string + example: "internal error" "502": description: The signer service is running in Dirk signer mode, but Dirk could not be reached. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 502 - message: - type: string - example: "Bad gateway: Dirk signer service is unreachable" + type: string + example: "Dirk communication error" /signer/v1/request_signature/proxy-bls: post: summary: Request a signature for a 32-byte blob of data (typically a hash), signed by the BLS private key for the requested proxy public key. - description: > + description: | This endpoint requires a valid JWT Bearer token. - + The token **must include** the following claims: - `exp` (integer): Expiration timestamp - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. @@ -237,7 +206,7 @@ paths: required: [proxy, object_root, nonce] properties: proxy: - description: The 48-byte BLS public key (for `proxy_bls` mode) or the 20-byte Ethereum address (for `proxy_ecdsa` mode), with optional `0x` prefix, of the proxy key that you want to request a signature from. + description: The 48-byte BLS public key, with optional `0x` prefix, of the proxy key that you want to request a signature from. $ref: "#/components/schemas/BlsPubkey" object_root: description: The 32-byte data you want to sign, with optional `0x` prefix. @@ -245,7 +214,7 @@ paths: nonce: $ref: "#/components/schemas/Nonce" example: - pubkey: "0xa3ffa9241f78279f1af04644cb8c79c2d8f02bcf0e28e2f186f6dcccac0a869c2be441fda50f0dea895cfce2e53f0989" + proxy: "0xa3ffa9241f78279f1af04644cb8c79c2d8f02bcf0e28e2f186f6dcccac0a869c2be441fda50f0dea895cfce2e53f0989" object_root: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" responses: "200": @@ -262,111 +231,65 @@ paths: "400": description: | This can occur in several scenarios: + - The request did not include a valid `Authorization` header with a Bearer token (a missing or malformed header is rejected with `400`, not `401`). - The Commit-Boost configuration file does not contain a signing ID for the module that made the request. - You requested an operation while using the Dirk signer mode instead of locally-managed signer mode, but Dirk doesn't support that operation. - Something went wrong while preparing your request; the error text will provide more information. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 400 - message: - type: string - example: "Bad request: Invalid pubkey format" + type: string + example: "Dirk signer does not support this operation" "401": - description: The requesting module did not provide a JWT string in the request's authorization header, or the JWT string was not configured in the signer service's configuration file as belonging to the module. + description: The JWT provided in the request's authorization header was invalid. For example, it was expired, signed with the wrong secret, or its claims did not match the request. Note that a request with a missing or malformed `Authorization` header is rejected with `400` instead. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 401 - message: - type: string - example: "Unauthorized" - + type: string + example: "unauthorized" + "404": description: You either requested a route that doesn't exist, or you requested a signature from a key that does not exist. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 404 - message: - type: string - example: "Unknown pubkey" + type: string + example: "unknown consensus signer: 0xa9e9cff900de07e295a044789fd4bdb6785eb0651ad282f9e76d12afd87e75180bdd64caf2e315b815d7322bd31ab48a" + "422": + description: The request body could not be deserialized. For example, the pubkey was not valid hex of the expected length. + content: + text/plain: + schema: + type: string + example: "Failed to deserialize the JSON body into the target type" "429": description: Your module attempted and failed JWT authentication too many times recently, and is currently timed out. It cannot make any more requests until the timeout ends. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 429 - message: - type: string - example: "Too many requests" + type: string + example: "rate limited for 12.3s" "500": description: Your request was valid, but something went wrong internally that prevented it from being fulfilled. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 500 - message: - type: string - example: "Internal error" + type: string + example: "internal error" "502": description: The signer service is running in Dirk signer mode, but Dirk could not be reached. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 502 - message: - type: string - example: "Bad gateway: Dirk signer service is unreachable" + type: string + example: "Dirk communication error" /signer/v1/request_signature/proxy-ecdsa: post: summary: Request a signature for a 32-byte blob of data (typically a hash), signed by the ECDSA private key for the requested proxy Ethereum address. - description: > + description: | This endpoint requires a valid JWT Bearer token. - + The token **must include** the following claims: - `exp` (integer): Expiration timestamp - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. @@ -410,111 +333,65 @@ paths: "400": description: | This can occur in several scenarios: + - The request did not include a valid `Authorization` header with a Bearer token (a missing or malformed header is rejected with `400`, not `401`). - The Commit-Boost configuration file does not contain a signing ID for the module that made the request. - You requested an operation while using the Dirk signer mode instead of locally-managed signer mode, but Dirk doesn't support that operation. - Something went wrong while preparing your request; the error text will provide more information. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 400 - message: - type: string - example: "Bad request: Invalid pubkey format" + type: string + example: "Dirk signer does not support this operation" "401": - description: The requesting module did not provide a JWT string in the request's authorization header, or the JWT string was not configured in the signer service's configuration file as belonging to the module. + description: The JWT provided in the request's authorization header was invalid. For example, it was expired, signed with the wrong secret, or its claims did not match the request. Note that a request with a missing or malformed `Authorization` header is rejected with `400` instead. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 401 - message: - type: string - example: "Unauthorized" - + type: string + example: "unauthorized" + "404": description: You either requested a route that doesn't exist, or you requested a signature from a key that does not exist. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 404 - message: - type: string - example: "Unknown pubkey" + type: string + example: "unknown consensus signer: 0xa9e9cff900de07e295a044789fd4bdb6785eb0651ad282f9e76d12afd87e75180bdd64caf2e315b815d7322bd31ab48a" + "422": + description: The request body could not be deserialized — for example, the pubkey was not valid hex of the expected length. + content: + text/plain: + schema: + type: string + example: "Failed to deserialize the JSON body into the target type" "429": description: Your module attempted and failed JWT authentication too many times recently, and is currently timed out. It cannot make any more requests until the timeout ends. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 429 - message: - type: string - example: "Too many requests" + type: string + example: "rate limited for 12.3s" "500": description: Your request was valid, but something went wrong internally that prevented it from being fulfilled. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 500 - message: - type: string - example: "Internal error" + type: string + example: "internal error" "502": description: The signer service is running in Dirk signer mode, but Dirk could not be reached. content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 502 - message: - type: string - example: "Bad gateway: Dirk signer service is unreachable" + type: string + example: "Dirk communication error" /signer/v1/generate_proxy_key: post: summary: Request a proxy key be generated for a specific consensus pubkey - description: > + description: | This endpoint requires a valid JWT Bearer token. - + The token **must include** the following claims: - `exp` (integer): Expiration timestamp - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. @@ -589,35 +466,128 @@ paths: "404": description: Unknown value (pubkey, etc.) content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 404 - message: - type: string - example: "Unknown pubkey" + type: string + example: "unknown consensus signer: 0xa9e9cff900de07e295a044789fd4bdb6785eb0651ad282f9e76d12afd87e75180bdd64caf2e315b815d7322bd31ab48a" "500": description: Internal error content: - application/json: + text/plain: schema: - type: object - required: - - code - - message - properties: - code: - type: number - example: 500 - message: - type: string - example: "Internal error" + type: string + example: "internal error" + + /reload: + post: + summary: Hot-reload signer configuration + description: | + Re-reads cb-config.toml and environment variables, rebuilding the signer's + internal state. Accepts optional body overrides for JWT secrets and the + admin secret. + + **Behaviour:** + - New modules in config are registered. + - Removed modules are dropped from the access list. + - JWT secrets and admin secret are reset to env var values. + - Previous runtime changes (from /revoke_jwt or body overrides) are reverted. + + **Body overrides** (applied on top of the config baseline): + - `jwt_secrets`: comma-separated `=` pairs. + - `admin_secret`: string to override the admin JWT secret. + + Body overrides are **not persisted** across restarts. + + A JSON body with `Content-Type: application/json` is required even when no + overrides are sent — send an empty object `{}`. + tags: + - Management + security: + - AdminBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + jwt_secrets: + description: Comma-separated list of MODULE_ID=SECRET pairs to override module JWT secrets + type: string + example: "module_a=newsecret,module_b=anothersecret" + admin_secret: + description: Override for the admin JWT secret + type: string + example: "my-new-admin-secret" + responses: + "200": + description: Configuration reloaded successfully + "400": + description: Body references a module ID not present in the config + content: + text/plain: + schema: + type: string + example: "bad request: Module unknown-module not found in config, cannot override JWT secret" + "500": + description: Failed to reload config (previous state preserved) + content: + text/plain: + schema: + type: string + example: "internal error" + + /revoke_jwt: + post: + summary: Immediately revoke a module's access + description: | + Removes a module from the signer's access list. The module will no longer + be able to authenticate with its JWT. + + If the module is still present in cb-config.toml, the next `/reload` will + re-add it. Remove the module from the config to make revocation permanent. + tags: + - Management + security: + - AdminBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - module_id + properties: + module_id: + description: The ID of the module to revoke + type: string + example: "MY_MODULE" + responses: + "200": + description: Module access revoked successfully + "404": + description: Module ID not found + content: + text/plain: + schema: + type: string + example: "module id not found" + + /status: + get: + summary: Health check + description: Simple health check endpoint. Returns 200 OK with no body. No authentication required. + tags: + - Management + responses: + "200": + description: Signer service is healthy + content: + text/plain: + schema: + type: string + example: "" components: securitySchemes: @@ -625,6 +595,10 @@ components: type: http scheme: bearer bearerFormat: JWT + AdminBearerAuth: + type: http + scheme: bearer + bearerFormat: JWT schemas: B256: type: string @@ -666,9 +640,10 @@ components: nonce: $ref: "#/components/schemas/Nonce" chain_id: - description: The chain ID that the signature is valid for, as specified in the Commit-Boost configuration - type: integer - example: 1 + description: The chain ID that the signature is valid for, as specified in the Commit-Boost configuration. Serialized as a `0x`-prefixed hex quantity string. + type: string + format: hex + example: "0x1" signature: description: The BLS signature of the Merkle root hash of the provided `object_root` field and the requesting module's Signing ID. For details on this signature, see the [signature structure documentation](https://commit-boost.github.io/commit-boost-client/developing/prop-commit-signing.md#structure-of-a-signature). $ref: "#/components/schemas/BlsSignature" @@ -687,9 +662,10 @@ components: nonce: $ref: "#/components/schemas/Nonce" chain_id: - description: The chain ID that the signature is valid for, as specified in the Commit-Boost configuration - type: integer - example: 1 + description: The chain ID that the signature is valid for, as specified in the Commit-Boost configuration. Serialized as a `0x`-prefixed hex quantity string. + type: string + format: hex + example: "0x1" signature: description: The ECDSA signature (in Ethereum RSV format) of the Merkle root hash of the provided `object_root` field and the requesting module's Signing ID. For details on this signature, see the [signature structure documentation](https://commit-boost.github.io/commit-boost-client/developing/prop-commit-signing.md#structure-of-a-signature). $ref: "#/components/schemas/EcdsaSignature" diff --git a/docs/docs/architecture/overview.md b/docs/docs/architecture/overview.md index 20137675a..48eaebde9 100644 --- a/docs/docs/architecture/overview.md +++ b/docs/docs/architecture/overview.md @@ -7,8 +7,8 @@ description: Overview of the architecture of Commit-Boost Below is schematic overview of Commit-Boost. Commit-Boost runs as a single sidecar composed of multiple modules: -- Pbs Module with the [BuilderAPI](https://ethereum.github.io/builder-specs/) for [MEV Boost](https://docs.flashbots.net/flashbots-mev-boost/architecture-overview/specifications) -- A Signer Module implementing the SignerAPI +- Pbs Service with the [BuilderAPI](https://ethereum.github.io/builder-specs/) for [MEV Boost](https://docs.flashbots.net/flashbots-mev-boost/architecture-overview/specifications) +- A Signer Service implementing the SignerAPI - Commit Modules that implement some custom commit protocol logic - Telemetry modules like Prometheus and Grafana diff --git a/docs/docs/developing/commit-module.md b/docs/docs/developing/commit-module.md deleted file mode 100644 index 30921be2f..000000000 --- a/docs/docs/developing/commit-module.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -sidebar_position: 2 ---- - -# Commit Module - -While a module can be written in any language, we currently provide some utilities for Rust, with the goal of supporting more generalized APIs and simplify development in languages other than Rust. - -In Rust, we provide utilities to load and run modules. Simply add to your `Cargo.toml`: -```toml -commit-boost = { git = "https://github.com/Commit-Boost/commit-boost-client", version = "..." } -``` - -You will now be able to import the utils with: -```rust -use commit_boost::prelude::*; -``` - - -## Config -Your module will likely need a configuration for the Node Operator to customize. This will have to be in the `cb-config.toml` file, in the correct `[[module]]` section. In the module, you can define and load your config as follows. - -First, define all the parameters needed in a struct: -```rust -#[derive(Debug, Deserialize)] -struct ExtraConfig { - sleep_secs: u64, -} -``` -then pass that struct to the `load_commit_module_config` function, which will load and parse the config. Your custom config will be under the `extra` field. - -```rust -let config = load_commit_module_config::().unwrap(); -let to_sleep = config.extra.sleep_secs; -``` - -The loaded `config` also has a few other useful fields: -- the unique `id` of the module -- chain spec -- a `SignerClient` to call the [SignerAPI](/api), already setup with the correct JWT - - -## Requesting signatures -At its core the Signer Module simply provides a signature on a 32-byte data digest. The signatures are currently provided with either the validator keys (BLS) or a proxy key (BLS or ECDSA) for a given validator key, both on the [builder domain](https://github.com/Commit-Boost/commit-boost-client/blob/main/crates/common/src/signature.rs#L88-L96). - -In the example we use `TreeHash`, already used in the CL, to create the digest from a custom struct: -```rust -#[derive(TreeHash)] -struct Datagram { - data: u64, -} -``` - -Furthermore, in order to request a signature, we'd need a public key of the validator. You can get a list of available keys by calling: -```rust -let pubkeys = config.signer_client.get_pubkeys().await.unwrap(); -``` - -Which will call the `get_pubkeys` endpoint of the [SignerAPI](/api), returning all the consensus pubkeys and the corresponding proxy keys, of your module. - -Note that the requests are authenticated using a JWT, that must be regularly refreshed as it expires after a certain time. To do so, you can call: -```rust -config.signer_client.refresh_token().await.unwrap(); -``` -You have the `SIGNER_JWT_EXPIRATION` constant available in the `commit-boost` crate, which is the time in seconds after which the JWT will expire. - -Then, we can request a signature either with a consensus key or with a proxy key: - -### With a consensus key -Requesting a signature is as simple as: -```rust -let datagram = Datagram { data: 1 }; -let request = SignConsensusRequest::builder(pubkey).with_msg(&datagram); -let signature = config.signer_client.request_consensus_signature(&request).await.unwrap(); -``` - -Where `pubkey` is the validator (consensus) public key for which the signature is requested. - -### With a proxy key -You'll have to first request a proxy key be generated for a given consensus key. -We support two signature schemes for proxies: BLS or ECDSA. - -To request a proxy: -```rust -// BLS proxy -let proxy_delegation = self.config.signer_client.generate_proxy_key_bls(pubkey).await?; -let proxy_pubkey = proxy_delegation.message.proxy; - -// or ECDSA proxy -let proxy_delegation = self.config.signer_client.generate_proxy_key_ecdsa(pubkey).await?; -let proxy_address = proxy_delegation.message.proxy; -``` - -Where `pubkey` is the validator (consensus) public key for which a proxy is to be generated. - -Then you can use the generated proxy key to request a signature: -```rust -// if `proxy_pubkey` is a BLS proxy -let datagram = Datagram { data: 1 }; -let request = SignProxyRequest::builder(proxy_pubkey).with_msg(&datagram); -let signature = config.signer_client.request_proxy_signature_bls(&request).await.unwrap(); - -// or for ECDSA proxy -let datagram = Datagram { data: 1 }; -let request = SignProxyRequest::builder(proxy_address).with_msg(&datagram); -let signature = config.signer_client.request_proxy_signature_ecdsa(&request).await.unwrap(); -``` - -## Metrics -We provide support for modules to record custom metrics which are automatically scraped by Prometheus. This involves three steps -### Define metrics -You can use the `prometheus` crate to create a custom registry and metrics, for example: - -```rust -static ref MY_CUSTOM_REGISTRY: Registry = Registry::new_custom(Some("da_commit".to_string()), None).unwrap(); -static ref SIG_RECEIVED_COUNTER: IntCounter = IntCounter::new("signature_received", "successful signature requests received").unwrap(); -``` - -### Start Metrics Provider -When starting the module, you should register all metrics, and start the `MetricsProvider`: -```rust -MY_CUSTOM_REGISTRY.register(Box::new(SIG_RECEIVED_COUNTER.clone())).unwrap(); -MetricsProvider::load_and_run(MY_CUSTOM_REGISTRY.clone()); -``` -The `MetricsProvider` will load the configuration needed and start a server with a `/metrics` endpoint for Prometheus to scrape. - -### Record metrics -All that is left is to use the metrics throughout your code: -```rust -SIG_RECEIVED_COUNTER.inc(); -``` -These will be automatically scraped by the Prometheus service running, and exposed on port `9090`. We plan to allow developers to ship pre-made dashboards together with their modules, to allow Node Operators to have an improved oversight on the modules they are running. diff --git a/docs/docs/developing/commit-modules.md b/docs/docs/developing/commit-modules.md new file mode 100644 index 000000000..9913ecb35 --- /dev/null +++ b/docs/docs/developing/commit-modules.md @@ -0,0 +1,163 @@ +--- +sidebar_position: 1 +--- + +# Commit Modules + +Commit-Boost provides an open platform for developers to create and distribute commitment protocol sidecars. **Commit Modules** are the primary way to add custom logic — they run as sidecar processes alongside the PBS and Signer services, and can request signatures from the proposer. + +> **For system context**, see the [Architecture Overview](../architecture/overview.md). + +## Config entry + +Each commit module is declared in the `cb-config.toml` file under a `[[modules]]` entry: + +```toml +[[modules]] +id = "DA_COMMIT" +type = "commit" +docker_image = "my-module-image" +signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" +``` + +| Field | Description | +|---|---| +| `id` | A unique identifier for the module (used for JWT scoping and container naming). | +| `type` | **Must be `"commit"`.** This is the only valid value. | +| `docker_image` | The Docker image to run for this module. | +| `signing_id` | A 32-byte identifier used to scope signatures to this module (see [Signing structure](#signing-structure)). | +| `env` | Optional map of environment variables for the module. | +| `env_file` | Optional path to an environment file for the module. | +| (custom) | Additional fields are passed through as opaque config to the module's runtime. | + +:::warning +Setting `type = "pbs"` in a `[[modules]]` entry is **not** a supported path. The configuration parser will reject it at parse time. If you want to extend the PBS binary itself, see [Extending PBS](./extending-pbs.md). +::: + + + +## Rust SDK usage + +While a module can be written in any language, we provide Rust utilities to simplify loading and running modules. Add to your `Cargo.toml`: + +```toml +commit-boost = { git = "https://github.com/Commit-Boost/commit-boost-client", version = "..." } +``` + +Import the prelude: + +```rust +use commit_boost::prelude::*; +``` + +### Loading module config + +Your module will likely need a configuration section for the Node Operator to customize. Define it as a struct and pass it to `load_commit_module_config`: + +```rust +#[derive(Debug, Deserialize)] +struct ExtraConfig { + sleep_secs: u64, +} + +let config = load_commit_module_config::().unwrap(); +let to_sleep = config.extra.sleep_secs; +``` + +The returned `StartCommitModuleConfig` also provides: +- `id` — unique module ID +- `chain` — chain spec +- `signer_client` — a pre-configured `SignerClient` to call the [SignerAPI](/api) + +### Requesting signatures + +At its core, the Signer Service provides a signature on a 32-byte data digest. Signatures are provided using either the validator keys (BLS) or a proxy key (BLS or ECDSA), both on the [Commit-Boost domain](#signing-structure). + +Use `TreeHash` to create a digest from a custom struct: + +```rust +#[derive(TreeHash)] +struct Datagram { + data: u64, +} +``` + +To request a signature, you need a public key. Get available keys: + +```rust +let pubkeys = config.signer_client.get_pubkeys().await.unwrap(); +``` + +JWT tokens are created and refreshed internally by `SignerClient` — each method generates a fresh token with the correct `route`, `exp`, and `payload_hash` claims automatically. No manual token management is needed. + +#### Consensus key signatures + +```rust +let datagram = Datagram { data: 1 }; +let request = SignConsensusRequest::builder(pubkey).with_msg(&datagram); +let signature = config.signer_client.request_consensus_signature(request).await.unwrap(); +``` + +Where `pubkey` is the validator (consensus) public key. + +#### Proxy key signatures + +First, generate a proxy key for a given consensus key. We support BLS and ECDSA: + +```rust +// BLS proxy +let proxy_delegation = config.signer_client.generate_proxy_key_bls(pubkey).await?; +let proxy_pubkey = proxy_delegation.message.proxy; + +// ECDSA proxy +let proxy_delegation = config.signer_client.generate_proxy_key_ecdsa(pubkey).await?; +let proxy_address = proxy_delegation.message.proxy; +``` + +Then request a signature using the proxy key: + +```rust +// BLS proxy +let datagram = Datagram { data: 1 }; +let request = SignProxyRequest::builder(proxy_pubkey).with_msg(&datagram); +let signature = config.signer_client.request_proxy_signature_bls(request).await.unwrap(); + +// ECDSA proxy +let datagram = Datagram { data: 1 }; +let request = SignProxyRequest::builder(proxy_address).with_msg(&datagram); +let signature = config.signer_client.request_proxy_signature_ecdsa(request).await.unwrap(); +``` + +### Signing structure + +For details on the signing structure — including domain separation, nonces, SSZ Merkle tree construction, and the signing ID format — see [Requesting Proposer Commitment Signatures](./prop-commit-signing.md). + +## Metrics + +Modules can record custom metrics that are automatically scraped by Prometheus. + +### Define metrics + +Use the `prometheus` crate: + +```rust +static ref MY_CUSTOM_REGISTRY: Registry = Registry::new_custom(Some("da_commit".to_string()), None).unwrap(); +static ref SIG_RECEIVED_COUNTER: IntCounter = IntCounter::new("signature_received", "successful signature requests received").unwrap(); +``` + +### Start the metrics provider + +```rust +MY_CUSTOM_REGISTRY.register(Box::new(SIG_RECEIVED_COUNTER.clone())).unwrap(); +MetricsProvider::load_and_run(config.chain, MY_CUSTOM_REGISTRY.clone()); +``` + +This starts a server with a `/metrics` endpoint on the port set by the `CB_METRICS_PORT` env var (assigned from `[metrics].start_port`, default `10000`, by `commit-boost init`). + +### Record metrics + +```rust +SIG_RECEIVED_COUNTER.inc(); +``` + +For a full reference of available metrics, see the [Metrics catalog](../get_started/running/metrics-catalog.md). The Prometheus scrape target is already configured by the docker-init setup. diff --git a/docs/docs/developing/custom-modules.md b/docs/docs/developing/custom-modules.md deleted file mode 100644 index cf2244480..000000000 --- a/docs/docs/developing/custom-modules.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -sidebar_position: 1 ---- - -# Custom Modules - -Commit-Boost aims to provide an open platform for developers to create and distribute commitment protocols sidecars. - -There are two ways to leverage Commit-Boost modularity: - -1. Commit Modules, which request signatures from the proposer, e.g. for preconfirmations ([example](https://github.com/Commit-Boost/commit-boost-client/tree/78bdc47bf89082f4d1ea302f9a3f86f609966b28/examples/da_commit)). -2. PBS Modules, which tweak the default PBS Module with additional logic, e.g. verifying additional constraints in `get_header` ([example](https://github.com/Commit-Boost/commit-boost-client/tree/78bdc47bf89082f4d1ea302f9a3f86f609966b28/examples/status_api)). diff --git a/docs/docs/developing/extending-pbs.md b/docs/docs/developing/extending-pbs.md new file mode 100644 index 000000000..baaff8cc2 --- /dev/null +++ b/docs/docs/developing/extending-pbs.md @@ -0,0 +1,111 @@ +--- +sidebar_position: 2 +--- + +# Extending PBS + +The PBS binary that ships with Commit-Boost can be extended with custom logic. This is **not** a config-level module declaration like commit modules — instead you replace the PBS binary entirely by implementing the `BuilderApi` trait (the default implementation is the `DefaultBuilderApi` struct). + +## Before you extend PBS + +| You want to... | Use... | +|---|---| +| Request signatures from the proposer's validator keys (BLS/ECDSA) | [Commit Module](./commit-modules.md) — runs as a sidecar alongside PBS | +| Add custom constraints to `get_header`, `submit_block`, or other BuilderAPI methods | Extend PBS — implement `BuilderApi` on your own struct | +| Run custom logic that triggers on each slot but does not modify relay interaction | Commit Module — cheaper to maintain and deploy independently | +| Add new HTTP routes alongside the standard BuilderAPI | Extend PBS — implement `extra_routes()` on your custom `BuilderApi` | + +**Rule of thumb:** if you need to change how relay responses are filtered, validated, or transformed, extend PBS. If you want to request signatures or run slot-triggered logic independently, write a Commit Module. + +## How it works + +The PBS binary ships with the [`DefaultBuilderApi`](https://github.com/Commit-Boost/commit-boost-client/blob/main/crates/pbs/src/api.rs) struct, which implements [`BuilderApi`](https://github.com/Commit-Boost/commit-boost-client/blob/main/crates/pbs/src/api.rs) with default (MEV-Boost-compatible) behavior for each method. + +The trait covers: + +- `get_header` — fetch the best header from relays +- `get_status` — check relay health +- `submit_block` — publish blinded blocks +- `register_validator` — register validators with relays +- `reload` — hot-reload configuration +- `extra_routes` — add custom HTTP endpoints + +PBS serves the submit-block route on both `POST /eth/v1/builder/blinded_blocks` and `POST /eth/v2/builder/blinded_blocks`; both are handled by the same `submit_block` method, which receives an `api_version: BuilderApiVersion` parameter (`V1` or `V2`) telling it which route was called. + +By implementing `BuilderApi` on your own struct, you can override any of these methods while reusing the default MEV-Boost logic: the default handlers (`get_header`, `get_status`, `register_validator`, `submit_block`) are re-exported in `commit_boost::prelude`, so you can call them from within your override: + +```rust +use commit_boost::prelude::*; + +// e.g. inside your `get_status` override +get_status(req_headers, state).await +``` + +Note that the default `reload` handler is not re-exported in the prelude, so a `reload` override must rebuild its state itself. + +### Reference example + +See [`examples/status_api/`](https://github.com/Commit-Boost/commit-boost-client/tree/main/examples/status_api) for a complete working example that: + +1. Defines a custom `ExtraConfig` struct with additional TOML fields (`inc_amount`). +2. Creates a custom `BuilderApiState` (`MyBuilderState`) to hold runtime state. +3. Implements `BuilderApi` that overrides `get_status` with custom logging and a counter, and adds a `/check` route via `extra_routes()`. +4. Loads config with `load_pbs_custom_config::()` and starts the service with `PbsService::run::<_, MyBuilderApi>(state)`. + +## Building and running a custom PBS binary + +### Dependencies + +Add the `commit-boost` crate to your `Cargo.toml`: + +```toml +commit-boost = { git = "https://github.com/Commit-Boost/commit-boost-client", version = "..." } +``` + +### Entry point + +Your `main.rs` should: + +1. Define your extra config (if any): + +```rust +#[derive(Debug, Deserialize)] +struct ExtraConfig { + inc_amount: u64, +} +``` + +2. Define your state (if any): + +```rust +#[derive(Clone)] +struct MyBuilderState { /* ... */ } +impl BuilderApiState for MyBuilderState {} +``` + +3. Implement `BuilderApi`: + +```rust +struct MyBuilderApi; + +#[async_trait] +impl BuilderApi for MyBuilderApi { + // Override methods here +} +``` + +4. Load config and run: + +```rust +let (pbs_config, extra) = load_pbs_custom_config::().await?; +let state = PbsState::new(pbs_config, config_path).with_data(MyBuilderState::from_config(extra)); +PbsService::run::(state).await +``` + +### Running + +Compile and run your binary. Set the same environment variables as the default PBS (see [Running with binary](../get_started/running/binary.md)). Your custom PBS handles the same BuilderAPI endpoints plus any extra routes you added. + +## Cross-reference + +For system context on how PBS fits into the Commit-Boost architecture, see [Architecture Overview](../architecture/overview.md). diff --git a/docs/docs/developing/prop-commit-signing.md b/docs/docs/developing/prop-commit-signing.md index 30f70413a..ab67d605a 100644 --- a/docs/docs/developing/prop-commit-signing.md +++ b/docs/docs/developing/prop-commit-signing.md @@ -14,10 +14,10 @@ Proposer commitment signatures produced by Commit-Boost's signer service conform - Signatures are **unique** to a given EVM chain (identified by its [chain ID](https://chainlist.org/)). Signatures generated for one chain will not work on a different chain. - Signatures are **unique** to Commit-Boost proposer commitments. The signer service **cannot** be used to create signatures that could be used for other applications, such as for attestations on the Beacon chain. While the signer service has access to the same validator private keys used to attest on the Beacon chain, it cannot create signatures that would get you slashed on the Beacon chain. - Signatures are **unique** to a particular module. One module cannot, for example, request an identical payload as another module and effectively "forge" a signature for the second module; identical payloads from two separate modules will result in two separate signatures. -- The data payload being signed must be a **32-byte array**, typically serializd as a 64-character hex string with an optional `0x` prefix. The value itself is arbitrary, as long as it has meaning to the requester - though it is typically the 256-bit hash of some kind of data. +- The data payload being signed must be a **32-byte array**, typically serialized as a 64-character hex string with an optional `0x` prefix. The value itself is arbitrary, as long as it has meaning to the requester - though it is typically the 256-bit hash of some kind of data. - If requesting a signature from a BLS key, the resulting signature will be a standard BLS signature (96 bytes in length). - If requesting a signature from an ECDSA key, the resulting signature will be a standard Ethereum RSV signature (65 bytes in length). -- Signatures **may** be **unique** per request, using the optional `nonce` field in their requests to indicate a unique sequence that this signature belongs to. +- Signatures **may** be **unique** per request, using the required `nonce` field in their requests (send `0` if unused) to indicate a unique sequence that this signature belongs to. ## Configuring a Module for Proposer Commitments @@ -51,11 +51,8 @@ In terms of implementation, the nonce format conforms to the specification in [E The form proposer commitment signatures take depends on the type of signature being requested. BLS signatures take the [standard form](https://eth2book.info/latest/part2/building_blocks/signatures/) (96-byte values). ECDSA (Ethereum EL) signatures take the [standard Ethereum ECDSA `r,s,v` signature form](https://forum.openzeppelin.com/t/sign-it-like-you-mean-it-creating-and-verifying-ethereum-signatures/697). In both cases, the data being signed is a 32-byte hash - the root hash of a composite two-stage [SSZ Merkle tree](https://thogiti.github.io/2024/05/02/Merkleization.html), described below: -
+![signature structure](../res/img/prop_commit_tree.png) - - -
where, for the sub-tree in blue: @@ -74,3 +71,114 @@ A Merkle tree must be constructed from these four leaf nodes, and its root hash The data signed in a proposer commitment is the 32-byte hash root of this new tree (the green `Root` box). Many languages provide libraries for computing the root of an SSZ Merkle tree, such as [fastssz for Go](https://github.com/ferranbt/fastssz) or [tree_hash for Rust](https://docs.rs/tree_hash/latest/tree_hash/). When verifying proposer commitment signatures, use a library that supports Merkle tree root hashing, the `compute_domain()` operation, and validation for signatures generated by your key of choice. + +--- + +## Authentication + +Every request to the Signer Service (except the health-check endpoint) must present a Bearer token in the `Authorization` header. + +### Module JWT + +Modules authenticate with a **signed JWT** using the pre-shared secret (`CB_SIGNER_JWT` env var). The JWT is an HS256 token with the following claims: + +| Claim | Type | Required | Description | +|-------|------|----------|-------------| +| `module` | string | always | The module's `id` from the `[[modules]]` entry in `cb-config.toml`. | +| `route` | string | always | The exact request path, e.g. `/signer/v1/get_pubkeys`. | +| `exp` | integer | always | UNIX timestamp for when the token expires. | +| `payload_hash` | string | POST only | Keccak-256 hash of the JSON-encoded request body, with `0x` prefix. Skipped for GET requests. | + +The `payload_hash` claim prevents JWT replay attacks: a token issued for one POST request body cannot be reused with a different body on the same route. + +**Token lifecycle:** Expiry is 5 minutes (`SIGNER_JWT_EXPIRATION` crate constant). Refresh is **client-side** — there is no refresh endpoint. The module generates a new JWT locally using the pre-shared secret. The SDK's `SignerClient` creates a fresh token on every request automatically. + +### Admin token + +Administrative endpoints (`/reload`, `/revoke_jwt`) authenticate with a **separate JWT** signed with the `CB_SIGNER_ADMIN_JWT` secret (env var). Same HS256 algorithm, includes `admin: true` in its claims. + +### Rate limiting + +The signer rate-limits by IP address. Default: **3 failed authentications within 5 minutes** locks a client out. Configurable via `[signer]` in `cb-config.toml`: + +```toml +[signer] +jwt_auth_fail_limit = 3 +jwt_auth_fail_timeout_seconds = 300 +``` + +If running behind a reverse proxy, configure the [reverse proxy header setup](../get_started/configuration.md#rate-limit) so the correct client IP is extracted. + +--- + +## API Quickstart + +Below is a walkthrough of the full Signer API flow using the Rust SDK. The `SignerClient` (returned by `load_commit_module_config`) handles JWT creation, payload hashing, and token refresh automatically — you never craft JWTs by hand. + +```rust +use commit_boost::prelude::*; + +// 1. Load the module config — this gives you a pre-configured SignerClient +#[derive(Debug, Deserialize)] +struct ExtraConfig { /* your module's custom fields */ } + +#[tokio::main] +async fn main() -> eyre::Result<()> { + let config = load_commit_module_config::()?; + let mut client = config.signer_client; + + // 2. List available validator pubkeys + let pubkeys = client.get_pubkeys().await?; + println!("Loaded {} validators", pubkeys.keys.len()); + + // 3. Generate a BLS proxy key for a consensus pubkey + let consensus = pubkeys.keys[0].consensus.clone(); + let delegation = client.generate_proxy_key_bls(consensus.clone()).await?; + let proxy_pubkey = delegation.message.proxy; + + // 4. Request a signature with the consensus key + #[derive(TreeHash)] + struct Datagram { data: u64 } + + let datagram = Datagram { data: 42 }; + let request = SignConsensusRequest::builder(consensus).with_msg(&datagram); + let sig = client.request_consensus_signature(request).await?; + + // 5. Or request a signature with the proxy key + let proxy_request = SignProxyRequest::builder(proxy_pubkey).with_msg(&datagram); + let proxy_sig = client.request_proxy_signature_bls(proxy_request).await?; + + Ok(()) +} +``` + +For a complete working example, see [`examples/da_commit/`](https://github.com/Commit-Boost/commit-boost-client/tree/main/examples/da_commit) in the repository. + +--- + +## Common workflows + +### Requesting a BLS consensus signature +![Generating and using a proxy key](../res/img/consensus-key-sign.png) + +### Generating and using a proxy key +![Generating and using a proxy key](../res/img/proxy-key-sign.png) + +:::tip ECDSA proxy signing with Dirk +ECDSA proxy signing is not available when the signer is using the Dirk backend. Dirk only supports BLS operations. +::: + +--- + +## Error codes + +All error responses return a plain-text body with a human-readable description of the error. + +| HTTP Status | Meaning | +|-------------|---------| +| `400` | Malformed request body, invalid pubkey format, missing signing ID, or operation not supported by current backend (e.g. ECDSA proxy with Dirk). | +| `401` | Missing or invalid JWT. Token may be expired, signed with wrong secret, or missing required claims. | +| `404` | Requested consensus signer, proxy signer, or module ID does not exist. | +| `429` | Too many failed authentication attempts — retry after the timeout period. | +| `500` | Internal server error. The request was valid but could not be fulfilled. | +| `502` | Signer is running in Dirk mode but Dirk is unreachable. | diff --git a/docs/docs/get_started/building.md b/docs/docs/get_started/building.md index 1b78edf35..049dbef21 100644 --- a/docs/docs/get_started/building.md +++ b/docs/docs/get_started/building.md @@ -14,7 +14,7 @@ The build system assumes that you've added your user account to the `docker` gro The Docker builder is built into the project's `justfile` which is used to invoke many facets of Commit Boost development. To use it, you'll need to install [Just](https://github.com/casey/just) on your system. -Use `just --list` to show all of the actions - there are many. The `justfile` provides granular actions, called "recipes", for building just the binaries of a specific crate (such as the CLI, `pbs`, or `signer`), as well as actions to build the Docker images for the PBS and Signer services. +Use `just --list` to show all of the actions - there are many. The `justfile` provides actions, called "recipes", for building the unified `commit-boost` binary, as well as actions to build the unified Docker image that is used to run the PBS and Signer services and the CLI. Below is a brief summary of the relevant ones for building the Commit-Boost artifacts: @@ -107,7 +107,6 @@ chain = "Hoodi" [pbs] port = 18550 -with_signer = true [[relays]] url = "https://0xafa4c6985aa049fb79dd37010438cfebeb0f2bd42b115b89dd678dab0670c1de38da0c4e9138c9290a398ecd9a0b3110@boost-relay-hoodi.flashbots.net" @@ -122,6 +121,12 @@ port = 20000 format = "lighthouse" keys_path = "/tmp/keys" secrets_path = "/tmp/secrets" + +[[modules]] +id = "test" +type = "commit" +docker_image = "test_module" +signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" ``` Set the path to it in the `CB_CONFIG` environment variable and run the binary: @@ -148,13 +153,14 @@ To verify the Signer service works, create [a TOML configuration](./configuratio The signer needs the following environment variables set: - `CB_CONFIG` = path of your config file. -- `CB_JWTS` = a dummy key-value pair of [JWT](https://en.wikipedia.org/wiki/JSON_Web_Token) values for various services. Since we don't need them for the sake of just testing the binary, we can use something like `"test_jwts=dummy"`. +- `CB_JWTS` = key-value pairs of [JWT](https://en.wikipedia.org/wiki/JSON_Web_Token) secrets for each module defined in the config file. The keys must match the module IDs, so for the `test` module above we can use something like `"test=dummy"`. +- `CB_SIGNER_ADMIN_JWT` = the JWT secret for the signer's admin endpoints. Since we don't need it for the sake of just testing the binary, we can use a dummy value. Set these values, create the `keys` and `secrets` directories listed in the configuration file, and run the binary: ``` mkdir -p /tmp/keys && mkdir -p /tmp/secrets -CB_CONFIG=cb-config.toml CB_JWTS="test_jwts=dummy" ./build///commit-boost signer +CB_CONFIG=cb-config.toml CB_JWTS="test=dummy" CB_SIGNER_ADMIN_JWT="dummy_admin" ./build///commit-boost signer ``` You should see output like this: diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index 7eefb2774..8df8a5bd3 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -7,12 +7,12 @@ description: Configure Commit-Boost Commit-Boost needs a configuration file detailing all the services that you want to run. Create a `cb-config.toml` and modify it depending on which modules you plan to run. - For a full explanation of all the fields, check out [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml). -- For some additional examples on config presets, check out [here](https://github.com/Commit-Boost/commit-boost-client/tree/main/configs). +- For some additional examples on config presets, check out [here](https://github.com/Commit-Boost/commit-boost-client/tree/main/examples/configs). -## Minimal PBS setup on Holesky +## Minimal PBS setup on Hoodi ```toml -chain = "Holesky" +chain = "Hoodi" [pbs] port = 18550 @@ -24,20 +24,86 @@ url = "" enabled = true ``` -You can find a list of MEV-Boost Holesky relays [here](https://www.coincashew.com/coins/overview-eth/mev-boost/mev-relay-list#holesky-testnet-relays). +You can find a list of MEV-Boost Hoodi relays [here](https://github.com/ethstaker/ethstaker-guides/blob/main/MEV-relay-list.md#mev-relay-list-for-hoodi-testnet). After the sidecar is started, it will expose a port (`18550` in this example), that you need to point your CL to. This may be different depending on which CL you're running, check out [here](https://docs.flashbots.net/flashbots-mev-boost/getting-started/system-requirements#consensus-client-configuration-guides) for a list of configuration guides. :::note -In this setup, the signer module will not be started. +In this setup, the Signer Service will not be started. ::: -## Signer module +## Custom chains -Commit-Boost supports both local and remote signers. The signer module is responsible for signing the transactions that other modules generates. Please note that only one signer at a time is allowed. +Besides the known chain names (`Mainnet`, `Holesky`, `Sepolia`, `Hoodi`), the `chain` field also accepts a custom chain in two forms: + +- **Spec file**: a genesis time plus a path to a chain spec file, either in JSON (as returned by the beacon endpoint `/eth/v1/config/spec`) or YAML format: + +```toml +chain = { genesis_time_secs = 1695902400, path = "/path/to/spec.json" } +``` + +- **Inline object**: all parameters specified directly: + +```toml +chain = { genesis_time_secs = 1695902400, slot_time_secs = 12, genesis_fork_version = "0x01017000", fulu_fork_slot = 5283840, chain_id = 17000 } +``` + +When using the spec-file form, the `CB_CHAIN_SPEC` environment variable can be set to override the spec file path at runtime (see [Binary](./running/binary.md#common)). + +## PBS safety and tuning options + +Beyond the basics shown above, the `[pbs]` section supports some additional knobs (see the [annotated config example](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml) for the full list): + +- `skip_sigverify`: whether to skip verification of the relay signature and pubkey in `get_header` responses. Default: `false`. +- `min_bid_eth`: minimum bid in ETH that will be accepted from `get_header`, can be specified as a float or a string for extra precision (e.g. `"0.01"`). Default: `0.0`. +- `extra_validation_enabled`: whether to enable extra validation of `get_header` responses. If enabled, `rpc_url` must also be set to an Execution Layer RPC on the same chain as the sidecar (this is checked at startup). Default: `false`. +- `register_validator_retry_limit`: maximum number of retries for validator registration requests per relay, must be greater than 0. Default: `3`. +- `validator_registration_batch_size`: maximum number of validators to send to relays in a single registration request. Default: unlimited. +- `mux_registry_refresh_interval_seconds`: for registry-based muxes with [dynamic refreshing](./mux-key-loaders.md#lido-registry) enabled, how often to refresh the list of pubkeys from the registry, in seconds. Must be greater than 0. Default: `384` (one epoch). + +:::warning +`validator_registration_batch_size` used to be a per-relay option. It is now obsolete on a per-relay basis and setting it inside a `[[relays]]` entry makes the sidecar fail at startup: move it to the `[pbs]` section instead. +::: + +### Per-relay options + +Each `[[relays]]` entry supports, besides `id` and `url`: + +- `headers`: optional headers to send with each request to this relay. +- `get_params`: optional GET parameters to add to each request URL for this relay. +- `enable_timing_games`: whether to enable timing games for this relay, as tuned by `target_first_request_ms` and `frequency_get_header_ms`. If neither of those is set, this flag has no effect. Timing games should only be used by advanced users: each relay has different latency and timing games setups, and misconfiguration can result in e.g. fetching a lower header value or missing a slot. Default: `false`. +- `target_first_request_ms`: target time in the slot, in milliseconds, at which to send the first `get_header` request. +- `frequency_get_header_ms`: frequency, in milliseconds, at which to send `get_header` requests. + +The same fields are available on `[[mux.relays]]` entries (see [Mux key loaders](./mux-key-loaders.md)). + +## Logs + +Logging is configured via the optional `[logs.stdout]` and `[logs.file]` sections: + +```toml +[logs.stdout] +enabled = true # Whether to enable stdout logging. Default: true +level = "info" # Log level: trace, debug, info, warn, error. Default: "info" +use_json = false # Log in JSON format. Default: false +color = true # Whether to use colors in the output. Default: true + +[logs.file] +enabled = true # Whether to enable file logging. Default: false +level = "info" # Log level: trace, debug, info, warn, error. Default: "info" +use_json = true # Log in JSON format. Default: true +dir_path = "/var/logs/commit-boost" # Directory to store logs. Default: "/var/logs/commit-boost" +max_files = 30 # Maximum number of log files to keep. Default: unlimited +``` + +The `CB_LOGS_DIR` environment variable overrides `dir_path` (see [Binary](./running/binary.md#common)). + +## Signer Service + +Commit-Boost supports both local and remote signers. The Signer Service is responsible for signing the transactions that commit modules generate (***it is not used by the PBS Service***). Please note that only one signer at a time is allowed. ### Local signer -To start a local signer module, you need to include its parameters in the config file +To start a local Signer Service, you need to include its parameters in the config file ```toml [pbs] @@ -221,7 +287,7 @@ All keys have the same password stored in `secrets/password.txt` ### Proxy keys store -Proxy keys can be used to sign transactions with a different key than the one used to sign the block. Proxy keys are generated by the Signer module and authorized by the validator key. Each module have their own proxy keys, that can be BLS or ECDSA. +Proxy keys can be used to sign transactions with a different key than the one used to sign the block. Proxy keys are generated by the Signer Service and authorized by the validator key. Each module can have their own proxy keys, that can be BLS or ECDSA. To persist proxy keys across restarts, you must enable the proxy store in the config file. There are 2 options for this: @@ -307,20 +373,11 @@ Where the `.json` files contain ERC-2335 keystore, the `///`, so accounts found with that pattern will be ignored. +`wallets` is a list of wallets from which the Signer Service will load all accounts as consensus keys. Generated proxy keys will have format `///`, so accounts found with that pattern will be ignored. - `secrets_path` is the path to the folder containing the passwords of the generated proxy accounts, which will be stored in `////.pass`. Additionally, you can set a proxy store so that the delegation signatures for generated proxy keys are stored locally. As these signatures are not sensitive, the only supported store type is `File`: @@ -354,20 +411,18 @@ Additionally, you can set a proxy store so that the delegation signatures for ge proxy_dir = "/path/to/proxy_dir" ``` -Delegation signatures will be stored in files with the format `/delegations//.sig`. +Delegation signatures will be stored in files with the format `/delegations//bls/.sig`. A full example of a config file with Dirk can be found [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/configs/dirk_signer.toml). ### TLS -By default, the Signer service runs in **insecure** mode, so its API service uses HTTP without any TLS encryption. This is sufficient for testing or if you're running locally within your machine's isolated Docker network and only intend to access it within the confines of your machine. However, for larger production setups, it's recommended to enable TLS - especially for traffic that spans across multiple machines. +By default, the Signer Service runs in **insecure** mode, so its API service uses HTTP without any TLS encryption. This is sufficient for testing or if you're running locally within your machine's isolated Docker network and only intend to access it within the confines of your machine. However, for larger production setups, it's recommended to enable TLS - especially for traffic that spans across multiple machines. -The Signer service in TLS mode supports **TLS 1.2** and **TLS 1.3**. Older protocol versions are not supported. +To enable TLS, you must first create a **certificate / key pair**. We **strongly advise** using a well-known Certificate Authority to create and sign the certificate and do not recommend using a self-signed certificate / key pair for production environments. -To enable TLS, you must first create a **certificate / key pair**. We **strongly advise** using a well-known Certificate Authority to create and sign the certificate, such as [Let's Encrypt](https://letsencrypt.org/getting-started/) (a free service) or [Bluehost](https://www.bluehost.com/help/article/how-to-set-up-an-ssl-certificate-for-website-security) (free but requires an account). We do not recommend using a self-signed ceriticate / key pair for production environments. - -When configuring TLS support, the Signer service expects a single folder (which you can specify) that contains the following two files: +When configuring TLS support, the Signer Service expects a single folder containing: - `cert.pem`: The SSL certificate file signed by a certificate authority, in PEM format - `key.pem`: The private key corresponding to `cert.pem` that will be used for signing TLS traffic, in PEM format @@ -387,7 +442,7 @@ type = "certificate" path = "path/to/your/cert/folder" ``` -Where `path` is the aforementioned folder. It defaults to `./certs` but can be replaced with whichever directory your certificate and private key file reside in, as long as they're readable by the Signer service (or its Docker container, if using Docker). +Where `path` is the aforementioned folder. If `[signer.tls_mode]` is omitted, the Signer Service runs in insecure HTTP mode. With `type = "certificate"`, an existing `cert.pem` and `key.pem` must be present at `path` - there is no default path and the files are not auto-generated. ### Rate limit @@ -448,7 +503,7 @@ url = "" [signer] port = 20000 -[signer.loader] +[signer.local.loader] format = "lighthouse" keys_path = "/path/to/keys" secrets_path = "/path/to.secrets" @@ -466,15 +521,15 @@ sleep_secs = 5 A few things to note: -- We now added a `signer` section which will be used to create the Signer module. -- There is now a `[[modules]]` section which at a minimum needs to specify the module `id`, `type` and `docker_image`. For modules with type `commit`, which will be used to access the Signer service and request signatures for preconfs, you will also need to specify the module's unique `signing_id` (see [the propser commitment documentation](../developing/prop-commit-signing.md)). Additional parameters needed for the business logic of the module will also be here. +- We now added a `signer` section which will be used to create the Signer Service. +- There is now a `[[modules]]` section which at a minimum needs to specify the module `id`, `type` and `docker_image`. For modules with type `commit`, which will be used to access the Signer Service and request signatures for preconfs, you will also need to specify the module's unique `signing_id` (see [the proposer commitment documentation](../developing/prop-commit-signing.md)). You can also pass environment variables to the module with `env` (a map of variable name to value) and `env_file` (path to an environment file for the module). Additional parameters needed for the business logic of the module will also be here. -To learn more about developing modules, check out [here](/category/developing). +To learn more about developing modules, check out [here](../developing/commit-modules.md). ## Vouch -[Vouch](https://github.com/attestantio/vouch) is a multi-node validator client built by [Attestant](https://www.attestant.io/). Vouch is particular in that it also integrates an MEV-Boost client to interact with relays. The Commit-Boost PBS module is compatible with the Vouch `blockrelay` since it implements the same Builder-API as relays. For example, depending on your setup and preference, you may want to fetch headers from a given relay using Commit-Boost vs using the built-in Vouch `blockrelay`. +[Vouch](https://github.com/attestantio/vouch) is a multi-node validator client built by [Attestant](https://www.attestant.io/). Vouch is particular in that it also integrates an MEV-Boost client to interact with relays. The Commit-Boost PBS Service is compatible with the Vouch `blockrelay` since it implements the same Builder-API as relays. For example, depending on your setup and preference, you may want to fetch headers from a given relay using Commit-Boost vs using the built-in Vouch `blockrelay`. ### Configuration @@ -512,13 +567,17 @@ This approach could also work if you have a multi-beacon-node setup, where some ## Hot Reload -Commit-Boost supports hot-reloading the configuration file. This means that you can modify the `cb-config.toml` file and apply the changes without needing to restart the modules. To do this, you need to send a `POST` request to the `/reload` endpoint on each module you want to reload the configuration. In the case the module is running in a Docker container without the port exposed (like the signer), you can use the following command: +Commit-Boost supports hot-reloading the configuration file. This means that you can modify the `cb-config.toml` file and apply the changes without needing to restart the modules. To do this, you need to send a `POST` request to the `/reload` endpoint on each module you want to reload the configuration. On the signer, the `/reload` and `/revoke_jwt` endpoints require the admin JWT (`CB_SIGNER_ADMIN_JWT`) as a Bearer token. In the case the module is running in a Docker container without the port exposed (like the signer), you can use the following command: ```bash -docker compose -f cb.docker-compose.yml exec cb_signer curl -X POST http://localhost:20000/reload +docker compose -f cb.docker-compose.yml exec cb_signer curl -X POST -H "Authorization: Bearer $CB_SIGNER_ADMIN_JWT" http://localhost:20000/reload ``` -### Signer module reload +### Automatic reload (PBS only) + +In addition to the manual `/reload` endpoint, the PBS Service watches the config file for changes and automatically reloads the configuration whenever the file is modified — no restart or API call needed. If the reload fails (e.g. because of a misconfigured option), it logs a warning and keeps the previous configuration. + +### Signer Service reload When the signer receives a reload request it re-reads the configuration file and environment variables, rebuilding its internal state to match: @@ -538,7 +597,7 @@ If the body is empty, the signer state is simply synced to match the config. **Add a new module without restarting:** 1. Add the `[[modules]]` entry to `cb-config.toml`. -2. Set the new module's JWT secret in the signer's environment (`CB_SIGNER_JWT_SECRETS`). +2. Set the new module's JWT secret in the signer's environment (`CB_JWTS`, a comma-separated list of `=` pairs). 3. Send `POST /reload` with an empty body. The signer picks up the new module from config. 4. Start the new module container with the matching JWT secret. @@ -556,8 +615,8 @@ Send `POST /revoke_jwt` with the module ID. This removes the module from the sig ### Notes -- The hot reload feature is available for PBS modules (both default and custom) and signer module. +- The hot reload feature is available for PBS Service (both default and custom) and Signer Service. - Changes related to listening hosts and ports will not been applied, as it requires the server to be restarted. - If running in Docker containers, changes in `volumes` will not be applied, as it requires the container to be recreated. Be careful if changing a path to a local file as it may not be accessible from the container. -- Custom PBS modules may override the default behaviour of the hot reload feature to parse extra configuration fields. Check the [examples](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/status_api/src/main.rs) for more details. +- Custom PBS Service may override the default behaviour of the hot reload feature to parse extra configuration fields. Check the [examples](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/status_api/src/main.rs) for more details. - In case the reload fails (most likely because of some misconfigured option), the server will return a 500 error and the previous configuration will be kept. diff --git a/docs/docs/get_started/mux-key-loaders.md b/docs/docs/get_started/mux-key-loaders.md new file mode 100644 index 000000000..436562ce7 --- /dev/null +++ b/docs/docs/get_started/mux-key-loaders.md @@ -0,0 +1,276 @@ +--- +description: Mux (multiplexer) configuration and key loader types +--- + +# Mux key loaders + +The PBS multiplexer (AKA *mux*) lets you route different validators to different relay sets or timing game configurations. Instead of a single `[[relays]]` list for all your validators, you declare one or more `[[mux]]` entries that match specific validator pubkeys to custom relay and timing settings. + +Use a mux when you need: + +- **Different relay sets for different validators** — for example, Lido or SSV node operators who send some validators to an operator-specific relay while the rest use the global relay set. +- **Per-group timing game parameters** — `timeout_get_header_ms` and `late_in_slot_time_ms` can be set per-mux, overriding the PBS defaults for those validators. +- **Dynamic key loading from on-chain or external sources** — the mux key loaders (File, URL, Registry) populate the mux's validator set automatically, so you don't have to list hundreds or thousands of pubkeys by hand. + +Mux entries are an optional addition to the `[[relays]]` section. If you don't need per-validator routing, you can ignore this page entirely. + +--- + +## Mux entry matching + +Each `[[mux]]` entry declares a set of validator pubkeys. The mux system enforces that these sets are **disjoint** — a validator pubkey should appear in at most one mux entry. If a pubkey is duplicated across mux entries, the sidecar will refuse to start. + +Matching uses **first-match semantics**: when the PBS receives a request for a validator, it checks each mux entry in the order they appear in the config file. The first mux whose pubkey set contains the validator's key wins. Validators that don't match any mux entry fall through to the global `[[relays]]` configuration. + +```toml +# Global relays — used for validators not matching any mux +[[relays]] +id = "global-relay" +url = "..." + +# First mux entry — checked first +[[mux]] +id = "timing-sensitive" +validator_pubkeys = [ + "0x80c7f782b2467c5898c5516a8b6595d75623960b4afc4f71ee07d40985d20e117ba35e7cd352a3e75fb85a8668a3b745", +] + +# A relay used by this mux +[[mux.relays]] +id = "fast-relay" +url = "..." + +# Another relay used by this mux +[[mux.relays]] +id = "robust-relay" +url = "..." + +# ... +# Multiple muxes can be defined repeating this pattern +``` + +### Matching rules summary + +| Condition | Behaviour | +|---|---| +| Pubkey matches a mux entry | That mux's relays and timing config are used | +| Pubkey appears in multiple mux entries | Validation error — sidecar fails to start | +| Pubkey doesn't match any entry | Falls through to global `[[relays]]` | +| A mux has no pubkeys (empty set) | Validation error — each mux must have at least one pubkey | + +The pubkey set for a mux can come from two sources combined: +1. **Inline `validator_pubkeys`** — a list of hex-prefixed BLS pubkeys in the config file itself. +2. **A loader plugin** — loads additional keys from a file, URL, or on-chain registry. Keys from the loader are merged into the mux's pubkey set. + +--- + +## Key loaders + +Key loaders are how you populate a mux with validator pubkeys without listing them manually. They are configured via the `loader` field inside a `[[mux]]` entry. + +### File loader + +Loads pubkeys from a flat JSON file on disk. + +**Schema:** A JSON array of hex-prefixed BLS public key strings. + +```json +[ + "0x8160998addda06f2956e5d1945461f33dbc140486e972b96f341ebf2bdb553a0e3feb127451f5332dd9e33469d37ca67", + "0x87b5dc7f78b68a7b5e7f2e8b9c2115f968332cbf6fc2caaaaa2c9dc219a58206b72c924805f2278c58b55790a2c3bf17", + "0x89e2f50fe5cd07ed2ff0a01340b2f717aa65cced6d89a79fdecc1e924be5f4bbe75c11598bb9a53d307bb39b8223bc52" +] +``` + +**Config:** Specify the path relative to the process working directory, or as an absolute path. Note that relative paths are resolved against the directory the sidecar is started from, not the config file's location — absolute paths are recommended for binary deployments. + +```toml +[[mux]] +id = "my-mux" +loader = "./path/to/keys.json" + +[[mux.relays]] +id = "my-relay" +url = "..." +``` + +**Environment variable override:** The path can be overridden at runtime via `CB_MUX_PATH_{id}` where `{id}` is the mux identifier. For a mux with `id = "lido-mux"`, the variable would be `CB_MUX_PATH_lido-mux`. This is useful when you want to keep the config file the same across deployments but point to different key files. + +```bash +export CB_MUX_PATH_lido-mux="/path/to/override.json" +``` + +--- + +### URL loader + +Loads the same JSON schema from an HTTP(S) endpoint. The endpoint must return a JSON array of hex-prefixed BLS public keys (identical format to the File loader). + +```toml +[[mux]] +id = "url-mux" +loader = { url = "https://keys.example.com/validators.json" } + +[[mux.relays]] +id = "my-relay" +url = "..." +``` + +**Security:** HTTPS is recommended. HTTP URLs work but trigger a warning at startup. + +**Request behaviour:** +- One-shot GET request — no retry logic. +- Timeout is controlled by `default_pbs.http_timeout_seconds` (default: 10s). +- The response body is read in full and parsed as JSON. + +--- + +### Registry loader + +Loads validator pubkeys from an on-chain or network registry. This resolves pubkeys automatically from a data source that stays in sync as validators are added or removed. + +Three registries are currently supported: + +| Registry | `registry` value | Key source | Authentication | +|---|---|---|---| +| Lido | `"lido"` | On-chain contract via RPC | RPC URL (from `[pbs]` config) | +| SSV | `"ssv"` | SSV node API or public API | SSV API URLs (from `[pbs]` config) | +| Stader | `"stader"` | On-chain contract via RPC | RPC URL (from `[pbs]` config) | + +#### Lido registry + +Reads validator pubkeys from Lido's on-chain `NodeOperatorsRegistry` or `CSModule registry`, depending on the module type. The sidecar connects to the configured RPC endpoint and calls the contract's `getSigningKeys` method with pagination. + +**Requirements:** `rpc_url` must be set in the `[pbs]` configuration. + +```toml +[pbs] +port = 18550 +rpc_url = "https://ethereum-rpc.publicnode.com" + +[[mux]] +id = "lido-mux" +loader = { registry = "lido", node_operator_id = 8, lido_module_id = 1 } + +[[mux.relays]] +id = "lido-relay" +url = "..." +``` + +**Fields:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `registry` | string | Yes | Must be `"lido"` | +| `node_operator_id` | integer | Yes | Lido node operator ID | +| `lido_module_id` | integer | No (default: `1`) | Lido staking module ID | +| `enable_refreshing` | boolean | No (default: `false`) | Whether to periodically refresh keys at runtime (see below) | + +**Chain support:** + +| Chain | `lido_module_id` | Module type | Contract type | +|---|---|---|---| +| Mainnet | 1 | Curated (NodeOperatorsRegistry) | `NodeOperatorsRegistry` | +| Mainnet | 2 | SimpleDVT | `NodeOperatorsRegistry` | +| Mainnet | 3 | Community Staking (CSM) | `CSModule` | +| Holesky | 1 | Curated (NodeOperatorsRegistry) | `NodeOperatorsRegistry` | +| Holesky | 2 | SimpleDVT | `NodeOperatorsRegistry` | +| Holesky | 3 | Sandbox | `NodeOperatorsRegistry` | +| Holesky | 4 | Community Staking (CSM) | `CSModule` | +| Hoodi | 1 | Curated (NodeOperatorsRegistry) | `NodeOperatorsRegistry` | +| Hoodi | 2 | SimpleDVT | `NodeOperatorsRegistry` | +| Hoodi | 3 | Sandbox | `NodeOperatorsRegistry` | +| Hoodi | 4 | Community Staking (CSM) | `CSModule` | +| Sepolia | 1 | — | `NodeOperatorsRegistry` | + +Module ids 1 and 2 use the `NodeOperatorsRegistry` contract. Module id 3 (Mainnet) and module id 4 (Holesky / Hoodi) use the `CSModule` (Community Staking Module) contract, which has a different ABI. The sidecar detects the module type automatically based on chain and module id. + +**Dynamic refreshing:** When `enable_refreshing = true`, the sidecar periodically re-fetches keys from the on-chain registry at runtime. New validators that register with the node operator are picked up automatically without a restart. This is useful for growing node operator deployments where you don't want to restart the sidecar every time a new validator is added. The refresh interval is controlled by `mux_registry_refresh_interval_seconds` in the `[pbs]` configuration (default: `384` seconds, i.e. one epoch; must be greater than 0), and applies to all registry muxes with refreshing enabled. + +--- + +#### SSV registry + +Loads validator pubkeys from the SSV network. The sidecar first tries to fetch keys from your local SSV node API. If that fails, it falls back to the public SSV API. + +**Requirements:** None — `ssv_node_api_url` and `ssv_public_api_url` are optional in the `[pbs]` configuration and default to `http://localhost:16000/v1/` (node API) and `https://api.ssv.network/api/v4/` (public API). Set them only to point at a different node or API server. + +```toml +[pbs] +port = 18550 +ssv_node_api_url = "http://localhost:16000/v1/" +ssv_public_api_url = "https://api.ssv.network/api/v4/" + +[[mux]] +id = "ssv-mux" +loader = { registry = "ssv", node_operator_id = 200 } + +[[mux.relays]] +id = "ssv-relay" +url = "..." +``` + +**Fields:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `registry` | string | Yes | Must be `"ssv"` | +| `node_operator_id` | integer | Yes | SSV node operator ID | +| `enable_refreshing` | boolean | No (default: `false`) | Whether to periodically refresh keys at runtime | + +**API sources (fallback chain):** + +1. **SSV node API** (preferred): `GET {ssv_node_api_url}validators` with a JSON body `{"operators": [node_operator_id]}`. Response contains a `data` array of validators with hex-encoded `public_key` fields. +2. **Public API** (fallback): `GET {ssv_public_api_url}{chain}/validators/in_operator/{node_operator_id}?perPage=100&page={page}` with pagination. Response contains a `validators` array and `pagination` object. + +If the node API call fails (timeout, connection error, etc.), the sidecar logs a warning and falls back to the public API. + +**Chains supported:** Mainnet, Holesky, and Hoodi. + +--- + +#### Stader registry + +Reads validator pubkeys from Stader's on-chain node registry. The sidecar connects to the configured RPC endpoint and queries the registry contract for the pool you specify. + +**Requirements:** `rpc_url` must be set in the `[pbs]` configuration, and `stader_pool` must be set in the mux config. + +```toml +[pbs] +port = 18550 +rpc_url = "https://ethereum-rpc.publicnode.com" + +[[mux]] +id = "stader-mux" +loader = { registry = "stader", node_operator_id = 200, stader_pool = "permissioned" } + +[[mux.relays]] +id = "stader-relay" +url = "..." +``` + +**Fields:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `registry` | string | Yes | Must be `"stader"` | +| `node_operator_id` | integer | Yes | Stader node operator ID | +| `stader_pool` | string | Yes | Stader staking pool — `"permissioned"` or `"permissionless"` | +| `enable_refreshing` | boolean | No (default: `false`) | Whether to periodically refresh keys at runtime | + +**Chains supported:** Mainnet only. + +--- + +## Reference config + +For a complete working example with multiple mux entries — File loader, Lido registry, SSV registry, and Stader registry — see: + +- [`examples/configs/pbs_mux.toml`](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/configs/pbs_mux.toml) + +--- + +## See also + +- [Configuration reference](./configuration.md) — full config field listing +- [Signer API](../developing/prop-commit-signing.md#api-quickstart) — signing API quickstart and authentication diff --git a/docs/docs/get_started/overview.md b/docs/docs/get_started/overview.md index b57195674..f5dbcef5f 100644 --- a/docs/docs/get_started/overview.md +++ b/docs/docs/get_started/overview.md @@ -4,17 +4,17 @@ description: Initial setup # Overview -Commit-Boost is primarily based on [Docker](https://www.docker.com/) to enable modularity, sandboxing and cross-platform compatibility. It is also possible to run Commit-Boost [natively](/get_started/running/binary) without Docker. +Commit-Boost is primarily based on [Docker](https://www.docker.com/) to enable modularity, sandboxing and cross-platform compatibility. It is also possible to run Commit-Boost [natively](./running/binary.md) without Docker. -Each component roughly maps to a container: from a single `.toml` config file, the node operator can specify which modules they want to run, and Commit-Boost takes care of spinning up the services and creating links between them. +Each component roughly maps to a container: from a single `.toml` config file, the node operator can specify which services they want to run, and Commit-Boost takes care of spinning up the services and creating links between them. Commit-Boost ships with two core services: -- A PBS module which implements the [BuilderAPI](https://ethereum.github.io/builder-specs/) for [MEV Boost](https://docs.flashbots.net/flashbots-mev-boost/architecture-overview/specifications). -- A signer module, which implements the [Signer API](/api) and provides the interface for modules to request proposer commitments. +- A PBS service which implements the [BuilderAPI](https://ethereum.github.io/builder-specs/) for [MEV Boost](https://docs.flashbots.net/flashbots-mev-boost/architecture-overview/specifications). +- A Signer Service, which implements the [Signer API](/api) and provides the interface for modules to request proposer commitments. ## Setup -The Commit-Boost program can create a dynamic `docker-compose` file, with services and ports already set up. +The Commit-Boost binary can create a dynamic `docker-compose` file, with services and ports already set up. Whether you're using Docker or running the binaries natively, you can compile from source directly from the repo, or download binaries and fetch docker images from the official releases. @@ -38,8 +38,8 @@ Run `rustup update` to update Rust and Cargo to the latest version # Pull the repo git clone https://github.com/Commit-Boost/commit-boost-client -# Stable branch has the latest released version -git checkout stable +# Enter the repo +cd commit-boost-client # Init submodules git submodule update --init --recursive @@ -49,12 +49,24 @@ git submodule update --init --recursive If you get an `openssl` related error try running: `apt-get update && apt-get install -y openssl ca-certificates libssl3 libssl-dev build-essential pkg-config` ::: -Now, build the binary, which will be stored in `build//`, for example `build/206658b/linux_amd64/`: +Each Commit-Boost release commit is located as a versioned file in the `./releases` folder. For example `.releases/v0.10.0-rc1.yml` contains: +```yml +commit: "efda6a67f43b0ddb400c454a65b055d59acc7d6c" +reason: "Substantial change to harden security in the signer service, improve build and release process, quality of life improvements to logging, and more support for SSV integrations. Contains breaking changes to the signer service and how the CLI is invoked." +``` + +To locally build that release version, checkout the commit: ```bash +# Switch the the specific release +git checkout efda6a67f43b0ddb400c454a65b055d59acc7d6c + +# Build the binary just build-bin $(git rev-parse --short HEAD) ``` +The binary will be stored in `build//`, for example `build/efda6a6/linux_amd64/`: + You can confirm the binary was built successfully by navigating to the build directory and checking its version: ```bash ./commit-boost --version @@ -62,11 +74,14 @@ You can confirm the binary was built successfully by navigating to the build dir ### Docker -Building the service images requires the binary to be built using the above instructions first, since it will be copied into those images. Once it's built, create the images with the following: +Building the service images requires the binary to be built using the above instructions first, since it will be copied into those images. The `build-all` command compiles the binary and then creates the image in one step: ```bash -just build-pbs-img $(git rev-parse --short HEAD) -just build-signer-img $(git rev-parse --short HEAD) +# Switch the the specific release +git checkout efda6a67f43b0ddb400c454a65b055d59acc7d6c + +# Build the binary and create the image +just build-all $(git rev-parse --short HEAD) ``` -This will create two local images called `commit_boost/pbs:` and `commit_boost/signer:` for the PBS and Signer services respectively. Make sure to use these images in the `docker_image` field in the `[pbs]` and `[signer]` sections of the `.toml` config file, respectively. +This will create a local image called `commit-boost/commit-boost:` that can be used to run the PBS and Signer services, as well as the CLI. Make sure to use this image in the `docker_image` field in the `[pbs]` and `[signer]` sections of the `.toml` config file. diff --git a/docs/docs/get_started/running/binary.md b/docs/docs/get_started/running/binary.md index 8f51fe657..af97e0e9e 100644 --- a/docs/docs/get_started/running/binary.md +++ b/docs/docs/get_started/running/binary.md @@ -12,35 +12,38 @@ Running the modules natively means you opt out of the security guarantees made b Get the binary of the module either by compiling from source or by downloading a [published release](https://github.com/Commit-Boost/commit-boost-client/releases). -Modules need some environment variables to work correctly. +Services need environment variables to work correctly. ### Common - `CB_CONFIG`: required, path to the `.toml` config file. -- `CHAIN_SPEC_ENV`: optional, path to a chain spec file. This will override the `[chain]` field in the `.toml` config. +- `CB_CHAIN_SPEC`: optional, path to a chain spec file. This will override the `[chain]` field in the `.toml` config. - `CB_METRICS_PORT`: optional, port where to expose the `/metrics` endpoint for Prometheus. - `CB_LOGS_DIR`: optional, directory to store logs. This will override the directory in the `.toml` config. -### PBS Module +### PBS Service -- `CB_PBS_ENDPOINT`: optional, override to specify the `IP:port` endpoint where the PBS module will open the port for the beacon node. +- `CB_PBS_ENDPOINT`: optional, override to specify the `IP:port` endpoint where the PBS Service will open the port for the beacon node. - `CB_MUX_PATH_{ID}`: optional, override where to load mux validator keys for mux with `id=\{ID\}`. -### Signer Module +### Signer Service -- `CB_SIGNER_ADMIN_JWT`: secret to use for admin JWT. +- `CB_JWTS`: required (the signer service will not start without it), comma-separated list of `module_id=jwt_secret` pairs for module authentication. +- `CB_SIGNER_ADMIN_JWT`: required, secret to use for admin JWT. +- `CB_SIGNER_JWT_AUTH_FAIL_LIMIT`: optional, override the number of failed JWT auth attempts before rate-limiting a client (default: `3`). +- `CB_SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS`: optional, override the rate-limit timeout window in seconds (default: `300`). - `CB_SIGNER_ENDPOINT`: optional, override to specify the `IP:port` endpoint to bind the signer server to. - `CB_SIGNER_TLS_CERTIFICATES`: path to the TLS certificates for the server. - For loading keys we currently support: - `CB_SIGNER_LOADER_FILE`: path to a `.json` with plaintext keys (for testing purposes only). - - `CB_SIGNER_LOADER_FORMAT`, `CB_SIGNER_LOADER_KEYS_DIR` and `CB_SIGNER_LOADER_SECRETS_DIR`: paths to the `keys` and `secrets` directories or files (ERC-2335 style keystores, see [Signer config](../configuration/#signer-module) for more info). + - `CB_SIGNER_LOADER_KEYS_DIR` and `CB_SIGNER_LOADER_SECRETS_DIR`: paths to the `keys` and `secrets` directories or files (ERC-2335 style keystores, see [Signer config](../configuration/#signer-service) for more info). - For storing proxy keys we currently support: - `CB_PROXY_STORE_DIR`: directory where proxy keys and delegations will be saved in plaintext (for testing purposes only). - `CB_PROXY_KEYS_DIR` and `CB_PROXY_SECRETS_DIR`: paths to the `keys` and `secrets` directories or files (ERC-2335 style keystores, see [Proxy keys store](../configuration/#proxy-keys-store) for more info). - For Dirk remote signer the following envs are available (see [Dirk config](../configuration/#dirk) for more info): - - `CB_SIGNER_DIRK_CERT_FILE`: required, path to the client certificate file. - - `CB_SIGNER_DIRK_KEY_FILE`: required, path to the client key file. - - `CB_SIGNER_DIRK_SECRETS_DIR`: required, path to the secrets directory. + - `CB_SIGNER_DIRK_CERT_FILE`: optional, override of the `cert_path` in the `[signer.dirk]` config, path to the client certificate file. + - `CB_SIGNER_DIRK_KEY_FILE`: optional, override of the `key_path` in the `[signer.dirk]` config, path to the client key file. + - `CB_SIGNER_DIRK_SECRETS_DIR`: optional, override of the `secrets_path` in the `[signer.dirk]` config, path to the secrets directory. - `CB_SIGNER_DIRK_CA_CERT_FILE`: optional, path to the CA certificate file. ### Modules @@ -49,7 +52,7 @@ Modules need some environment variables to work correctly. #### Commit modules -- `CB_SIGNER_URL`: required, url to the signer module server. +- `CB_SIGNER_URL`: required, url to the Signer Service server. - `CB_SIGNER_JWT`: required, jwt to use for signature requests. Modules might also have additional envs required, which should be detailed by the maintainers. diff --git a/docs/docs/get_started/running/docker.md b/docs/docs/get_started/running/docker.md index 81fd9f850..9d1d82605 100644 --- a/docs/docs/get_started/running/docker.md +++ b/docs/docs/get_started/running/docker.md @@ -11,10 +11,9 @@ First run: ```bash commit-boost init --config cb-config.toml ``` -This will create up to three files: +This will create two files: - `cb.docker-compose.yml` which contains the full setup of the Commit-Boost services. -- `.cb.env` with local env variables, including JWTs for modules, only created if the signer module is enabled. -- `target.json` which enables dynamic discovery of services for metrics scraping via Prometheus, only created if metrics are enabled. +- `.cb.env` with local env variables, including JWTs for modules, only created if the Signer Service is enabled. ## Start @@ -23,7 +22,11 @@ To start Commit-Boost run: docker compose --env-file ".cb.env" -f ".cb.docker-compose.yml" up -d ``` -This will run all the configured services, including PBS, signer and modules (if any). +:::note +If only the PBS service is configured, no `.cb.env` file is generated and the `--env-file ".cb.env"` flag must be omitted (Docker Compose errors if the file doesn't exist). In general, use the exact command that `commit-boost init` prints. The same applies to the `logs` and `down` commands below. +::: + +This will run all the configured services, including PBS, signer and commit modules (if any). The MEV-Boost server will be exposed at `pbs.port` from the config, `18550` in our example. You'll need to point your CL/Validator client to this port to be able to source blocks from the builder market. @@ -32,7 +35,7 @@ To check the logs, run: ```bash docker compose --env-file ".cb.env" -f ".cb.docker-compose.yml" logs -f ``` -This will currently show all logs from the different services via the Docker logs interface. Logs are also optionally saved to file, depending on your `[logs]` configuration. +This will currently show all logs from the different services via the Docker logs interface. Logs are also optionally saved to file, depending on your [`[logs]` configuration](../configuration.md#logs). ## Stop @@ -55,7 +58,7 @@ Below is a simple configuration for running only the PBS service on the Hoodi ne chain = "Hoodi" [pbs] -docker_image = "ghcr.io/commit-boost/commit-boost:v0.8.0" +docker_image = "ghcr.io/commit-boost/commit-boost:v0.9.6" relay_check = true wait_all_registrations = true @@ -68,7 +71,7 @@ id = "def" url = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@def.xyz" ``` -Note that there are many more parameters that Commit-Boost supports, but they are all omitted and thus will use their default options. For a full description of the default options within the config file, go to the [annotated configuration example](../../../../config.example.toml). +Note that there are many more parameters that Commit-Boost supports, but they are all omitted and thus will use their default options. For a full description of the default options within the config file, go to the [annotated configuration example](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml). The relays here are placeholder for the sake of the example; for a list of actual relays, visit [the EthStaker relay list](https://github.com/eth-educators/ethstaker-guides/blob/main/MEV-relay-list.md). @@ -80,15 +83,13 @@ Run `commit-boost init --config cb-config.toml` with the above configuration, th ``` services: cb_pbs: - command: - - pbs healthcheck: test: curl -f http://localhost:18550/eth/v1/builder/status interval: 30s timeout: 5s retries: 3 start_period: 5s - image: ghcr.io/commit-boost/commit-boost:v0.8.0 + image: ghcr.io/commit-boost/commit-boost:v0.9.6 container_name: cb_pbs ports: - 127.0.0.1:18550:18550 @@ -97,6 +98,8 @@ services: CB_PBS_ENDPOINT: 0.0.0.0:18550 volumes: - ./cb-config.toml:/cb-config.toml:ro + command: + - pbs ``` This will run the PBS service in a container named `cb_pbs`. @@ -106,7 +109,7 @@ This will run the PBS service in a container named `cb_pbs`. The program creates a read-only volume binding for the config file, which the PBS service needs to run. The Docker compose file that it creates with the `init` command, `cb.docker-compose.yml`, will be placed into your current working directory when you run the program. The volume source will be specified as a *relative path* to that working directory, so it's ideal if the config file is directly within your working directory (or a subdirectory). If you need to specify an absolute path for the config file, you can adjust the `volumes` entry within the Docker compose file manually after its creation. -Since this is a volume, the PBS service container will reload the file from disk any time it's restarted. That means you can change the file any time after the Docker compose file is created to tweak PBS's parameters, but it also means the config file must stay in the same location; if you move it, the PBS container won't be able to mount it anymore and fail to start unless you manually adjust the volume's source location. +Since this is a volume, the PBS service sees changes to the file: it watches the config file and [automatically reloads the configuration](../configuration.md#automatic-reload-pbs-only) whenever the file is modified, without needing a restart. That means you can change the file any time after the Docker compose file is created to tweak PBS's parameters, but it also means the config file must stay in the same location; if you move it, the PBS container won't be able to mount it anymore and fail to start unless you manually adjust the volume's source location. ### Networking @@ -120,8 +123,8 @@ host = "0.0.0.0" to the `[pbs]` section in the configuration. This will cause the resulting `ports` entry in the Docker compose file to become: ``` - ports: - - 0.0.0.0:18550:18550 +ports: + - 0.0.0.0:18550:18550 ``` though you will need to add an entry to your local machine's firewall software (if applicable) for other machines to access it. @@ -129,13 +132,13 @@ though you will need to add an entry to your local machine's firewall software ( Currently, the program will always export the PBS service's API port in one of these two ways. If you don't want to expose it at all, so it can only be accessed by other Docker containers running within Docker's internal network, you will need to manually remove the `ports` entry from the Docker compose file after it's been created: ``` - ports: [] +ports: [] ``` -## Example with PBS, Signer, and a Signer Module +## Example with PBS, Signer, and a Commit Module -In this scenario we will be running the PBS service, the Signer service, and a module (`DA_COMMIT`) that interacts with the Signer service's API. +In this scenario we will be running the PBS service, the Signer service, and a commit module (`DA_COMMIT`) that interacts with the Signer service's API. All of both PBS's and the Signer's parameters are controlled via the [Commit-Boost TOML configuration file](../configuration.md); the services cannot currently be controlled with command-line arguments. Therefore it is crucial to ensure that you have a configuration file present with all of the settings you require *before* starting the services, as this file will be mounted within the Docker containers as a volume in read-only mode. @@ -145,7 +148,7 @@ Below is a simple configuration for running only the three modules on the Hoodi chain = "Hoodi" [pbs] -docker_image = "ghcr.io/commit-boost/commit-boost:v0.8.0" +docker_image = "ghcr.io/commit-boost/commit-boost:v0.9.6" relay_check = true wait_all_registrations = true @@ -158,6 +161,7 @@ id = "def" url = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@def.xyz" [signer] +docker_image = "ghcr.io/commit-boost/commit-boost:v0.9.6" port = 20000 [signer.local.loader] @@ -169,15 +173,18 @@ secrets_path = "./secrets" id = "DA_COMMIT" type = "commit" docker_image = "test_da_commit" +signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" sleep_secs = 5 ``` -Note that there are many more parameters that Commit-Boost supports, but they are all omitted and thus will use their default options. For a full description of the default options within the config file, go to the [annotated configuration example](../../../../config.example.toml). +Note that there are many more parameters that Commit-Boost supports, but they are all omitted and thus will use their default options. For a full description of the default options within the config file, go to the [annotated configuration example](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml). The relays here are placeholder for the sake of the example; for a list of actual relays, visit [the EthStaker relay list](https://github.com/eth-educators/ethstaker-guides/blob/main/MEV-relay-list.md). In this scenario there are two folders in the same directory as the configuration file (the working directory): `keys` and `secrets`. These correspond to the folders containing the [EIP-2335 keystores](../configuration.md#local-signer) and secrets in Lighthouse format. For your own keys, adjust the `format` parameter within the configuration and directory paths accordingly. +Note that if either the `docker_image` under the `[signer]` or `[pbs]` is left unspecified it will default to `ghcr.io/commit-boost/commit-boost:latest`. Make sure to specify both if you intend to use versions other than the latest release. + ### Commit-Boost Init Output @@ -206,15 +213,13 @@ services: cb_signer: condition: service_healthy cb_pbs: - command: - - pbs healthcheck: test: curl -f http://localhost:18550/eth/v1/builder/status interval: 30s timeout: 5s retries: 3 start_period: 5s - image: ghcr.io/commit-boost/commit-boost:latest + image: ghcr.io/commit-boost/commit-boost:v0.9.6 container_name: cb_pbs ports: - 127.0.0.1:18550:18550 @@ -223,22 +228,24 @@ services: CB_PBS_ENDPOINT: 0.0.0.0:18550 volumes: - ./cb-config.toml:/cb-config.toml:ro - cb_signer: command: - - signer + - pbs + cb_signer: healthcheck: - test: curl -f http://localhost:20000/status + test: curl -k -f http://cb_signer:20000/status interval: 30s timeout: 5s retries: 3 start_period: 5s - image: ghcr.io/commit-boost/commit-boost:latest + image: ghcr.io/commit-boost/commit-boost:v0.9.6 container_name: cb_signer ports: - 127.0.0.1:20000:20000 environment: CB_CONFIG: /cb-config.toml CB_JWTS: ${CB_JWTS} + CB_SIGNER_ADMIN_JWT: ${CB_SIGNER_ADMIN_JWT} + CB_SIGNER_TLS_CERTIFICATES: /certs CB_SIGNER_ENDPOINT: 0.0.0.0:20000 CB_SIGNER_LOADER_KEYS_DIR: /keys CB_SIGNER_LOADER_SECRETS_DIR: /secrets @@ -248,10 +255,11 @@ services: - ./secrets:/secrets:ro networks: - signer_network + command: + - signer networks: signer_network: driver: bridge - ``` This will create three Docker containers when executed: @@ -263,11 +271,12 @@ This will create three Docker containers when executed: Finally, the `.cb.env` file produced will look like this: ``` -CB_JWT_DA_COMMIT=mwDSSr7chwy9eFf7RhedBoyBtrwFUjSQ -CB_JWTS=DA_COMMIT=mwDSSr7chwy9eFf7RhedBoyBtrwFUjSQ +CB_JWT_DA_COMMIT=hJ0bV40pTMShsRb9QS7fVinAsL9Roxkc +CB_JWTS=DA_COMMIT=hJ0bV40pTMShsRb9QS7fVinAsL9Roxkc +CB_SIGNER_ADMIN_JWT=WbdxlH32hNOMkfc6BfBHaV1WZj3vgODA ``` -The Signer service needs JWT authentication from each of its modules. The program creates these and embeds them into the containers via environment variables automatically for convenience. This is demonstrated for the Signer module within the `environment` compose block: the `CB_JWTS: ${CB_JWTS}` forwards the `CB_JWTS` environment variable that's present when running Docker compose. The program requests that you do so via the command `docker compose --env-file "./.cb.env" -f "./cb.docker-compose.yml" up -d`; the `--env-file "./.cb.env"` handles loading the program's JWT output into this environment variable. +The Signer service needs JWT authentication from each of its modules. The program creates these and embeds them into the containers via environment variables automatically for convenience. This is demonstrated for the Signer Service within the `environment` compose block: the `CB_JWTS: ${CB_JWTS}` forwards the `CB_JWTS` environment variable that's present when running Docker compose. The program requests that you do so via the command `docker compose --env-file "./.cb.env" -f "./cb.docker-compose.yml" up -d`; the `--env-file "./.cb.env"` handles loading the program's JWT output into this environment variable. Similarly, for the `cb_da_commit` module, the `CB_SIGNER_JWT: ${CB_JWT_DA_COMMIT}` line within its `environment` block will set the JWT that it should use to authenticate with the Signer service. @@ -288,16 +297,16 @@ host = "0.0.0.0" to both the `[pbs]` and `[signer]` sections in the configuration. This will cause the resulting `ports` entries in the Docker compose file to become: ``` - cb_pbs: - ... - ports: - - 0.0.0.0:18550:18550 +cb_pbs: + ... + ports: + - 0.0.0.0:18550:18550 - cb_signer: - ... - ports: - - 0.0.0.0:20000:20000 +cb_signer: + ... + ports: + - 0.0.0.0:20000:20000 ``` though you will need to add entries to your local machine's firewall software (if applicable) for other machines to access them. @@ -305,5 +314,5 @@ though you will need to add entries to your local machine's firewall software (i Currently, the program will always export the PBS and Signer services' API ports in one of these two ways. If you don't want to expose them at all, so they can only be accessed by other Docker containers running within Docker's internal network, you will need to manually remove the `ports` entries from the Docker compose files after they've been created: ``` - ports: [] +ports: [] ``` diff --git a/docs/docs/get_started/running/k8s.md b/docs/docs/get_started/running/k8s.md new file mode 100644 index 000000000..ab6681751 --- /dev/null +++ b/docs/docs/get_started/running/k8s.md @@ -0,0 +1,75 @@ +--- +description: Deploy Commit-Boost on Kubernetes +--- + +# Kubernetes + +Commit-Boost can be deployed on Kubernetes using the [Helm chart](https://helm.sh/) available in the repository's `provisioning/k8s/commit-boost/` directory. + +## Scope limitation + +:::warning +The current Helm chart supports only the **PBS Service**. It does **not** support the Signer Service or custom commit modules. If you need Signer or module support, please use the [Docker](./docker.md) or [Binary](./binary.md) deployment methods instead. +::: + +## Prerequisites + +- A Kubernetes cluster +- [Helm](https://helm.sh/docs/intro/install/) installed (v3+) + +## Installation + +1. Clone the repository or navigate to the chart directory: + +```bash +git clone https://github.com/Commit-Boost/commit-boost-client.git +cd commit-boost-client/provisioning/k8s/commit-boost +``` + +2. Edit the `values.yaml` file to configure the PBS service according to your needs. The key configuration options are described in the [Values table](#values) below. + +3. Install the chart: + +```bash +helm install commit-boost . -f values.yaml +``` + +This will deploy the Commit-Boost PBS service. By default, the PBS service is available on port `18550`. Point your beacon nodes and validator clients to this port. + +## Values + +The PBS service is configured through the `values.yaml` file. The chart exposes the following key configuration options under the `commitBoost.pbs` section: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `commitBoost.pbs.enable` | bool | `true` | Enable the PBS service | +| `commitBoost.pbs.image.repository` | string | `ghcr.io/commit-boost/commit-boost` | PBS container image repository | +| `commitBoost.pbs.image.tag` | string | `v0.4.0` | PBS container image tag | +| `commitBoost.pbs.config.chain` | string | `Hoodi` | Ethereum network (e.g. Holesky, Hoodi) | +| `commitBoost.pbs.config.pbs.port` | int | `18550` | PBS service port | +| `commitBoost.pbs.config.relays` | list | `[]` | List of relays to connect to | +| `commitBoost.pbs.config.mux` | list | `[]` | Multiplexer configuration for validator-specific relay routing | +| `commitBoost.pbs.config.metrics.server_port` | int | `10000` | Metrics server port. Note: the config key Commit-Boost actually reads is `metrics.start_port`, so changing `server_port` is currently a no-op (the port stays at the built-in default of `10000`) | +| `replicaCount` | int | `1` | Number of PBS pod replicas | +| `service.type` | string | `ClusterIP` | Kubernetes service type | +| `service.pbs_port` | int | `18550` | Service port for PBS | +| `resources` | object | `{}` | Pod resource requests and limits | +| `autoscaling.enabled` | bool | `false` | Enable horizontal pod autoscaling | + +For the full list of available values and their descriptions, see the [README.md](https://github.com/Commit-Boost/commit-boost-client/blob/main/provisioning/k8s/commit-boost/README.md) in the chart directory. + +## Upgrading + +To upgrade an existing release after modifying `values.yaml`: + +```bash +helm upgrade commit-boost . -f values.yaml +``` + +## Uninstalling + +To uninstall the release: + +```bash +helm uninstall commit-boost +``` diff --git a/docs/docs/get_started/running/metrics-catalog.md b/docs/docs/get_started/running/metrics-catalog.md new file mode 100644 index 000000000..88525ed19 --- /dev/null +++ b/docs/docs/get_started/running/metrics-catalog.md @@ -0,0 +1,55 @@ +--- +sidebar_label: "Metrics catalog" +--- + +# Metrics catalog + +This page lists every metric emitted by the Commit-Boost PBS and Signer services together with the runtime-registered build-info metric from the shared telemetry crate. Use this as a reference when building dashboards or writing alerting rules. + +--- + +## PBS metrics + +PBS metrics use a custom Prometheus registry with namespace prefix `cb_pbs_`. The registry is created via `Registry::new_custom(Some("cb_pbs"), None)` in `crates/pbs/src/metrics.rs`. All wire names shown below include this prefix. + +| Metric name (wire) | Type | Labels | Description | +|---|---|---|---| +| `cb_pbs_relay_status_code_total` | Counter | `http_status_code`, `endpoint`, `relay_id` | HTTP status code received by relay. Incremented after each relay HTTP response; `http_status_code` may be `"555"` (the value of `TIMEOUT_ERROR_CODE_STR`) for timeouts. Endpoint values: `get_header`, `register_validator`, `submit_blinded_block`, `status`. | +| `cb_pbs_relay_latency` | Histogram | `endpoint`, `relay_id` | HTTP latency (duration in seconds) by relay. Records duration of relay HTTP requests. Endpoint values: `get_header`, `register_validator`, `submit_blinded_block`, `status`. | +| `cb_pbs_relay_last_slot` | Gauge | `relay_id` | Latest slot for which a relay delivered a header. Only updated in the `get_header` handler. Set to the current slot on each successful header from that relay. | +| `cb_pbs_relay_header_value` | Gauge | `relay_id` | Header value in gwei delivered by a relay. Converted from raw wei (÷ 1e9) in the `get_header` handler. | +| `cb_pbs_beacon_node_status_code_total` | Counter | `http_status_code`, `endpoint` | HTTP status code returned to the beacon node. Tracks what status codes the PBS returns for beacon node-facing requests. Endpoint values: `get_header`, `register_validator`, `submit_blinded_block`, `status`, `reload`. Error status codes (`502` for `NoResponse`/`NoPayload`, `500` for `Internal`) are set via `PbsClientError`. | + +--- + +## Signer metrics + +Signer metrics use a custom Prometheus registry with namespace prefix `cb_signer_`. The registry is created via `Registry::new_custom(Some("cb_signer"), None)` in `crates/signer/src/metrics.rs`. Wire names include this prefix. + +| Metric name (wire) | Type | Labels | Description | +|---|---|---|---| +| `cb_signer_signer_status_code_total` | Counter | `http_status_code`, `endpoint` | HTTP status code returned by signer endpoints. Incremented as responses are sent. Endpoint values: `get_pubkeys`, `generate_proxy_key`, `request_signature_bls`, `request_signature_proxy_bls`, `request_signature_proxy_ecdsa`, and `unknown endpoint` (emitted for the admin routes `/reload` and `/revoke_jwt`, which are matched by the router but not mapped to a named tag). | + +--- + +## Build-info metric (all services) + +When each service starts its metrics HTTP server (via the `MetricsProvider` from the `cb-metrics` crate), a runtime-registered gauge is added to its registry: + +| Metric name (wire) | Type | Labels | Description | +|---|---|---|---| +| `info` | Gauge | `version`, `commit`, `network` | Always `1`. Carries build metadata as Prometheus const labels. The `version` label is the crate version (`CARGO_PKG_VERSION`), `commit` is the Git hash at build time (`GIT_HASH`), and `network` is the chain name (e.g. `Mainnet`, `Holesky`, `Sepolia`, `Hoodi`, or `Custom` for custom chain specs). | + +This metric appears under the service's own registry prefix — for example, the PBS instance exposes it as `cb_pbs_info{version="...",commit="...",network="..."}` and the Signer exposes it as `cb_signer_info{version="...",commit="...",network="..."}`. + +--- + +## Custom module metrics + +Commit-Modules can register their own metrics via the `prometheus` crate. Each module receives a `ModuleMetricsConfig` at init time which includes the `server_port` for its metrics HTTP server. To expose custom metrics: + +1. Create a custom `Registry` (optionally with a namespace prefix). +2. Register your metrics on that registry. +3. Pass the registry to `MetricsProvider::new()` or `MetricsProvider::load_and_run()` to serve them on the module's `/metrics` endpoint. + +All module metrics are served on a separate port and are **not** aggregated into the PBS or Signer registries. To collect them, add the module's metrics port as an additional scrape target in your Prometheus configuration. diff --git a/docs/docs/get_started/running/metrics.md b/docs/docs/get_started/running/metrics.md index 582001959..9a92d3c2a 100644 --- a/docs/docs/get_started/running/metrics.md +++ b/docs/docs/get_started/running/metrics.md @@ -11,8 +11,10 @@ Make sure to add the `[metrics]` section to your config file: ```toml [metrics] enabled = true +host = "127.0.0.1" # Host for metrics servers. Default: 127.0.0.1 +start_port = 10000 # Port the first service listens on for Prometheus scrapes; following services use port+1, port+2, etc. Default: 10000 ``` -If the section is missing, metrics collection will be disabled. If you generated the `docker-compose.yml` file with `commit-boost init`, metrics ports will be automatically configured, and a sample `target.json` file will be created. If you're running the binaries directly, you will need to set the correct environment variables, as described in the [previous section](/get_started/running/binary#common). +If the section is missing, metrics collection will be disabled. If you generated the `docker-compose.yml` file with `commit-boost init`, metrics ports will be automatically configured. If you're running the binaries directly, you will need to set the correct environment variables, as described in the [previous section](./binary.md#common). ## Example setup @@ -90,5 +92,3 @@ datasources: ``` Once Grafana is running, you can [import](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/import-dashboards/) the Commit-Boost dashboards from [here](https://github.com/Commit-Boost/commit-boost-client/tree/main/provisioning/grafana), making sure to select the correct `Prometheus` datasource. - - diff --git a/docs/docs/get_started/troubleshooting.md b/docs/docs/get_started/troubleshooting.md index bb8623b85..500eda70e 100644 --- a/docs/docs/get_started/troubleshooting.md +++ b/docs/docs/get_started/troubleshooting.md @@ -4,8 +4,204 @@ description: Common issues # Troubleshooting -Commit-Boost was recently audited and going through a phased approach for validators to move to production. If you find any or have any question, please reach out on [X (Twitter)](https://x.com/Commit_Boost). If there are any security related items, please see [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/SECURITY.md). +If you find any or have any question, please reach out on [X (Twitter)](https://x.com/Commit_Boost). If there are any security related items, please see [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/SECURITY.md). +--- + +## Symptom → service decision matrix + +Real failures often cascade across service boundaries. Before diving into a specific section, use this table to identify the most likely culprit from the observable symptom. + +| If you see… | Likely culprit | Start in section | +|---|---|---| +| Container won't start / exits immediately | Docker (volume, port, image) or missing env vars | [Docker / networking](#docker--networking) | +| HTTP 401 from `POST /signer/*` | JWT auth failure — shared secret mismatch | [Signer Service > JWT auth failures](#jwt-auth-failures) | +| Signer logs a key loading error at startup | Key loading path, format mismatch, or permission error | [Signer Service > Key loading](#key-loading) | +| Module log says `connection refused` reaching signer | Docker networking: wrong URL, port, or network | [Docker / networking > container-to-container connectivity](#container-to-container-connectivity) | +| `POST /reload` returns HTTP 500 or 400 | Reload failure — invalid config (500) or bad body override (400) | [Hot reload > Reload failures](#reload-failures) | +| `POST /reload` reverts a previous `POST /revoke_jwt` | Body override not persisted | [Hot reload > Body overrides and footguns](#body-overrides-and-footguns) | +| Module starts but fails all signature requests | Shared secret mismatch, module missing from the signer's config, or clock skew beyond the 10s leeway | [Signer Service > JWT auth failures](#jwt-auth-failures) | +| Module container runs but PBS returns no headers | Relays unreachable or timing game expiring too early | [PBS](#pbs) | +| `docker compose` exits with `no such file` | Missing or misnamed config file or env file | [Docker / networking > Init failures](#init-failures) | + +--- + +## Docker / networking + +### Init failures + +`commit-boost init --config cb-config.toml` produces `cb.docker-compose.yml`, and `.cb.env`. If you see `no such file` when running Docker Compose: + +1. **Missing config file** — verify `cb-config.toml` exists in the working directory and is TOML-valid. +2. **Missing env file** — if the Signer Service is enabled, `.cb.env` is created alongside the compose file. Pass it with `--env-file ./.cb.env`. +3. **Wrong path** — the volume bindings in the compose file are relative to the working directory. If you moved the config file after `init`, update the `volumes` entry. + +See the [configuration reference](./configuration.md) for a full field listing and [Docker setup](./running/docker.md#init) for init details. + +### Container won't start / exits immediately + +If a container exits immediately after `docker compose up`: + +1. **Port conflict** — try a different `[pbs] port` or `[signer] port` in the config, or stop whatever is already using the port. The `docker compose logs` output will show a `bind: address already in use` error. +2. **Missing image** — the config's `docker_image` field must point to a valid image. For local development images (e.g. `test_da_commit`), build them first with `just docker-build-test-modules`. +3. **Volume mount failure** — the config file, keys, and secrets paths must be accessible at runtime. If a path is wrong, the container will exit. Check logs for `file not found`. +4. **Missing environment variables** — services that need `CB_CONFIG`, `CB_SIGNER_JWT`, or `CB_MODULE_ID` will fail to start if these aren't set. Docker containers get these from `.cb.env` (via `--env-file`); native binaries set them on the command line. + +Check `docker compose logs` for the specific error message. + +### Container-to-container connectivity + +Modules connect to the Signer Service over an internal Docker bridge network. If a module logs `connection refused`: + +1. **Wrong URL** — modules receive `CB_SIGNER_URL` as an env var. The default is `http://cb_signer:20000`. If you override this, verify the hostname matches the Signer container name (`cb_signer` by default) and the port matches `[signer] port`. +2. **Network isolation** — verify the module's compose service is on the `signer_network` (or whatever network the signer is on). The `init` command sets this up automatically; manual compose edits can break it. +3. **Signer not healthy** — the compose file sets `depends_on: cb_signer: condition: service_healthy`. If the signer fails its health check (e.g., because it can't load keys), dependent modules will never start. Check `docker compose logs cb_signer` first. + +--- + +## Signer Service + +If the signer logs an error at startup or signature requests fail at runtime, the likely causes fall into three categories. + +### JWT auth failures + +A `401` response from any `POST /signer/*` endpoint means the request's JWT was rejected. + +1. **Shared secret mismatch (most common)** — each module authenticates with a JWT derived from a shared secret. The signer's `CB_JWTS` env var and the module's `CB_SIGNER_JWT` env var must carry the **same secret for the same module ID**. Common pitfalls: + - Typo in the module ID or secret string. + - The `.cb.env` file was regenerated (e.g., by re-running `init`) but the running containers still use the old env file. + - A manual override was applied via [`POST /reload` body overrides](#body-overrides-and-footguns) but the environment variable was not updated — after a restart the override is lost. +2. **Clock skew** — the only time-based claim JWT validation checks is `exp` (expiration), with a 10-second leeway. If the signer's system clock differs from the module's clock by more than the leeway, the JWT may appear invalid. +3. **Admin endpoint auth failure** — `POST /reload` and `POST /revoke_jwt` require the admin JWT secret (`CB_SIGNER_ADMIN_JWT` environment variable or `admin_secret` body override). If you get a 401 on these endpoints, check that the admin secret matches. + +### Key loading + +If the signer fails to start with errors about keys: + +- **Wrong format** — the `[signer.local.loader] format` must match the actual keystore layout. See the [Signer configuration](./configuration.md#local-signer) for supported formats and their expected file structures. +- **Wrong path** — `keys_path` and `secrets_path` are relative to the container's filesystem, not the host. In Docker, these are volume-mounted from the host; verify the mount paths match what the loader expects. +- **Permission denied** — the signer process runs as a non-root user inside the container. Ensure the mounted keys and secrets are readable by the container user. +- **Proxy store path missing** — if `[signer.local.store]` is configured, the proxy directory must exist and be writable. The signer will fail to start if it cannot create proxy key files. +- **Remote signer unavailable** — for Dirk, the remote signer must be reachable at startup. A timeout or connection error during the initial handshake will cause the signer to exit. + +See the [Signer configuration](./configuration.md#signer-service) for a full reference and [Docker setup](./running/docker.md#example-with-pbs-signer-and-a-commit-module) for a working example. + +### TLS + +If you enable TLS and the signer fails to start: + +1. **Missing certificate files** — the directory set by `path` in `[signer.tls_mode]` (required when `type = "certificate"`; mounted at `/certs` inside the Docker container) must contain `cert.pem` and `key.pem`. They are not generated automatically. See the [TLS section](./configuration.md#tls) for details. +2. **Self-signed certificate** — recommended for testing only. Production setups should use a well-known CA. +3. **Certificate permissions** — the key file must be readable by the signer process (non-root user inside the container). + +--- + +## Modules + +### Signer connectivity + +If a commit module logs errors when calling the signer: + +1. **Wrong JWT** — the module's `CB_SIGNER_JWT` must match the signer's entry for that module ID. See [JWT auth failures](#jwt-auth-failures) above. +2. **Wrong signer URL** — verify `CB_SIGNER_URL` points to the correct host and port. In Docker, the host is the signer container name (`cb_signer` by default); with native binaries, it is the signer's host IP. +3. **Signer not started** — modules depend on the signer via Docker Compose `depends_on`. If the signer fails to start (e.g., key loading error), dependent modules will never leave the `created` state. +4. **Proxy key generation fails** — if using proxy keys, the signer must have the proxy store configured and writable. Check the signer logs for proxy store errors (e.g. `failed reading proxy dir: ...`). + +### Module ID mismatch + +If the signer responds with `401` `unauthorized` to a module's requests (or `404` `module id not found` on `POST /revoke_jwt`): + +- The `[[modules]]` entry in `cb-config.toml` uses a different `id` than what the module was started with (`CB_MODULE_ID` env var). These must match exactly. +- After adding a new module to the config, you must send [`POST /reload`](#hot-reload) to the signer before starting the module container. Until then, the signer has no record of the new module and will reject its requests. + +--- + +## Hot reload + +Commit-Boost supports hot-reloading the configuration without restarting containers. The mechanism is fully documented in the [configuration page](./configuration.md#hot-reload); this section covers what to do when it breaks. + +### Reload failures + +If `POST /reload` returns `500`: + +1. **Invalid TOML** — the config file changed on disk since the service started. If the new content has syntax errors, the reload is rejected and the previous configuration is kept. Check `docker compose logs` for the parse error. +2. **Permission denied** — the service may not be able to re-read the config file if its permissions changed after startup (e.g., file was moved or ownership changed). + +If `POST /reload` returns `400` ("bad request"): + +- **Body override references a non-existent module** — the body fields `jwt_secrets` and `admin_secret` (the "body overrides") accept optional overrides applied on top of the config. If the body references a module ID that does not exist in the config file, the entire reload is rejected. + +### Body overrides and footguns + +The request body for `POST /reload` accepts two optional fields — collectively called **body overrides** — that are applied on top of the config at runtime but **never persisted to disk**: + +- `jwt_secrets`: a comma-separated list of `=` pairs to override specific module secrets. +- `admin_secret`: a string to override the admin JWT secret. + +Because these are in-memory only, they are lost on container restart. If you rotate a JWT secret via a body override, the environment variable (`CB_JWTS` or the module's `CB_SIGNER_JWT`) still holds the old value. After any restart the signer will fall back to the old secret and authentication will fail until you update the environment variable to match. + +Similarly, if you revoke a module with `POST /revoke_jwt` but leave it in the config, the next `POST /reload` (without a body override) re-adds the module from the config. Always remove revoked modules from `[[modules]]` in the config to make the revocation permanent. + +See the [Hot Reload section in the configuration page](./configuration.md#footguns) for the full list of footguns. + +### Hot reload and custom PBS + +Custom PBS services may override the default reload behaviour to parse extra configuration fields. If a custom PBS returns `500` on reload, check the module's documentation for custom reload handling. See the [custom module examples](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/status_api/src/main.rs) for details. + +--- + +## Cascading diagnostics + +Failures in one service often propagate to others. When debugging, always check the **upstream dependency first**: + +### Scenario 1: Signer fails to load keys → all modules fail + +``` +Signer can't read keystore + ↓ +Signer health check fails + ↓ +Docker Compose never marks cb_signer as healthy + ↓ +Modules (depends_on: condition: service_healthy) never start + ↓ +Modules that need proposer commitments (proxy key generation, signature requests) get connection refused +``` + +**Diagnosis:** Start with the signer log. A key loading error at the top (e.g. `failed reading proxy dir: ...` or a keystore parse failure) means all downstream failures are consequences. Fix the key loading, then restart. + +### Scenario 2: Config file becomes stale after a restart + +``` +Admin rotates JWT secrets via POST /reload body overrides + (overrides are in-memory only) + ↓ +Container crashes or is restarted + ↓ +Signer starts with the old secrets from .cb.env / config file + ↓ +Modules still hold the rotated JWT → 401 on every request +``` + +**Diagnosis:** Look for a pattern where everything worked before a restart, then all modules fail with 401. The fix is to update the environment variable (`.cb.env` or the shell env) to match the rotated secret, then restart cleanly. + +### Scenario 3: Relay timeout causes no-payload cascade + +``` +One relay becomes slow or unresponsive + ↓ +PBS times out waiting for that relay's header + ↓ +No relay returns a valid bid → PBS returns 204 (no content) to the CL + ↓ +CL falls back to local execution payload → no MEV reward +``` + +If the request itself fails (rather than simply yielding no bids), PBS instead returns `502` (`no payload from relays`). + +**Diagnosis:** Check the PBS logs for relay timeout errors (status code `555` or `TIMEOUT_ERROR_CODE_STR`) on a specific relay. Remove or replace that relay in the `[[relays]]` config, then `POST /reload` the PBS. + +--- If you started the modules correctly you should see the following logs. diff --git a/docs/docs/res/img/consensus-key-sign.png b/docs/docs/res/img/consensus-key-sign.png new file mode 100644 index 0000000000000000000000000000000000000000..118903f688bfc490783ff39f0ae03097b433efbc GIT binary patch literal 103080 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 literal 0 HcmV?d00001 diff --git a/docs/docs/res/img/proxy-key-sign.png b/docs/docs/res/img/proxy-key-sign.png new file mode 100644 index 0000000000000000000000000000000000000000..e665d24cf96990c01ff591a44095cd5452d538b9 GIT binary patch literal 111225 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

j4h z-l6LXg$0#wAEnCAKE5{_b&ft_JlDbQCLU)OBVtE1859LlgcYS#cfl^EJi-cywGo^( zm{J~FFjhT+diXsp@p*z+v3u}{(u3kry5r)S#h#rjf7FI4b&*N`89PEB%N<6CFR9a; zWhN8CMlL;j$kWL8x$1P~_h)NTDjT$G=F*GtP;f;Qu0yYDP2a|%9$Gj=bVIleX|OXL zTy$vH{gA|yLXTJKZHH%<=%{iMk<`88{|k;P(W z)?C-!9T9VB*)GU%A~8~ro4&O9F5we{jvw!yq$Cp9_boFYdOJvr?&FY=Lb8}e->TaM z@hGa?++b~MS}nd=JkBw?A@+PWWS)vbJ7k3oYkeJitrwecb|gY$s22y;z$~N%K9>4k z-Imto>)a8s>7{lh*X(hfxgFMTIyVwDyHMGph%RFte?WgIZ6q)sY=M2gmBM1Waw$W_ ze=ahc60)181lG#zK@%H7VkJSqrbenoK59r_a!sjeWwce;xPSWN#|qPhk-0+Qxoo7- znolT6#LHU2?*N4IOCaRgR-)%r*(TpeZfNp0=SnhOF3&eaP6@8f4jY@x3nIhFrUp+VZQNwTtxB&q#sEW<`}+PB>QgFCOyw)ki

!#(Rp!$Xh* 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 Date: Tue, 11 Aug 2026 14:03:36 -0700 Subject: [PATCH 02/21] config: merge the #472 terminology pass into main's config.example.toml main and PR #472 both touched this file, so it is merged by hand rather than replaced. Kept from main: the [[relays]] get_header stream block, the fuller Stader loader wording, and the fulu_fork_slot example. Taken from the PR: the module -> service terminology rename, the "All fields are required" note on the inline custom chain, and the #signer-service docs anchor. Also corrected against the v0.10.0 code while merging: - [signer.tls_mode] claimed a default of "certificate" with self-signed auto-generation. default_tls_mode() returns Insecure, and the cert path is read with std::fs::read (crates/common/src/config/signer.rs), so the files must already exist. - [signer.remote] is listed as a supported signer type but StartSignerConfig bails with "Remote signer configured" at startup. Marked NOT IMPLEMENTED and dropped from the supported list, matching configuration.md ("for now, one remote signer is supported: Dirk"). - [[signer.dirk.hosts]] used `accounts`; DirkHostConfig calls the field `wallets`. --- config.example.toml | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/config.example.toml b/config.example.toml index 4b6b2853f..838312480 100644 --- a/config.example.toml +++ b/config.example.toml @@ -4,15 +4,15 @@ # Chain spec ID. Supported values: # A network ID. Supported values: Mainnet, Holesky, Sepolia, Hoodi. Lower case values e.g. "mainnet" are also accepted # A custom object, e.g., chain = { genesis_time_secs = 1695902400, path = "/path/to/spec.json" }, with a path to a chain spec file, either in .json format (e.g., as returned by the beacon endpoint /eth/v1/config/spec), or in .yml format (see examples in tests/data). -# A custom object, e.g., chain = { genesis_time_secs = 1695902400, slot_time_secs = 12, genesis_fork_version = "0x01017000", fulu_fork_slot = 5283840, chain_id = 17000 }. +# A custom object, e.g., chain = { genesis_time_secs = 1695902400, slot_time_secs = 12, genesis_fork_version = "0x01017000", fulu_fork_slot = 5283840, chain_id = 17000 }. All fields are required. chain = "Holesky" -# Configuration for the PBS module +# Configuration for the PBS service [pbs] -# Docker image to use for the PBS module. +# Docker image to use for the PBS service. # OPTIONAL, DEFAULT: ghcr.io/commit-boost/commit-boost:latest docker_image = "ghcr.io/commit-boost/commit-boost:latest" -# Whether to enable the PBS module to request signatures from the Signer module (not used in the default PBS image) +# Whether to enable the PBS service to request signatures from the Signer service (not used in the default PBS image) # OPTIONAL, DEFAULT: false with_signer = false # Host to receive BuilderAPI calls from beacon node @@ -62,7 +62,7 @@ extra_validation_enabled = false # a fallback if the user's own SSV node is not reachable. # OPTIONAL, DEFAULT: "https://api.ssv.network/api/v4/" # ssv_public_api_url = "https://api.ssv.network/api/v4/" -# Timeout for any HTTP requests sent from the PBS module to other services, in seconds +# Timeout for any HTTP requests sent from the PBS service to other services, in seconds # OPTIONAL, DEFAULT: 10 http_timeout_seconds = 10 # Maximum number of retries for validator registrations per relay @@ -79,7 +79,7 @@ validator_registration_batch_size = "" # OPTIONAL, DEFAULT: 384 mux_registry_refresh_interval_seconds = 384 -# The PBS module needs one or more [[relays]] as defined below. +# The PBS service needs one or more [[relays]] as defined below. [[relays]] # Relay ID to use in telemetry # OPTIONAL, DEFAULT: URL hostname @@ -180,14 +180,13 @@ timeout_get_header_ms = 900 id = "mux-relay-1" url = "http://0xa119589bb33ef52acbb8116832bec2b58fca590fe5c85eac5d3230b44d5bc09fe73ccd21f88eab31d6de16194d17782e@def.xyz" -# Configuration for the Signer Module, only required if any `commit` module is present, or if `pbs.with_signer = true` -# Currently three types of Signer modules are supported (only one can be used at a time): -# - Remote: a remote Web3Signer instance +# Configuration for the Signer service, only required if any `commit` module is present, or if `pbs.with_signer = true` +# Currently two types of Signer service are supported (only one can be used at a time): # - Dirk: a remote Dirk instance -# - Local: a local Signer module -# More details on the docs (https://commit-boost.github.io/commit-boost-client/get_started/configuration/#signer-module) +# - Local: a local Signer service +# More details on the docs (https://commit-boost.github.io/commit-boost-client/get_started/configuration/#signer-service) [signer] -# Docker image to use for the Signer module. +# Docker image to use for the Signer service. # OPTIONAL, DEFAULT: ghcr.io/commit-boost/commit-boost:latest docker_image = "ghcr.io/commit-boost/commit-boost:latest" # Host to bind the Signer API server to @@ -221,12 +220,14 @@ jwt_auth_fail_timeout_seconds = 300 # [signer.tls_mode] # How to use TLS for the Signer's HTTP server; two modes are supported: # - type = "insecure": disable TLS, so the server runs in HTTP mode (not recommended for production). -# - type = "certificate": Use TLS. Include a property named "path" below this with the provided path; `path` should be a directory containing `cert.pem` and `key.pem` files to use. If they don't exist, they'll be automatically generated in self-signed mode. +# - type = "certificate": Use TLS. Include a property named "path" below this with the provided path; `path` should be a directory containing existing `cert.pem` and `key.pem` files to use. They are NOT auto-generated: the Signer fails to start if either file is missing. # OPTIONAL, DEFAULT: -# type = "certificate" -# path = "./certs" +# type = "insecure" # For Remote signer: +# NOT IMPLEMENTED. The `[signer.remote]` (Web3Signer) variant parses, but the Signer service +# rejects it at startup with `Remote signer configured`. Dirk is currently the only supported +# remote signer; use `[signer.dirk]` below. # [signer.remote] # URL of the Web3Signer instance # url = "https://remote.signer.url" @@ -253,20 +254,20 @@ jwt_auth_fail_timeout_seconds = 300 # Complete URL of a Dirk gateway # url = "https://localhost:8881" # Wallets to load consensus keys from -# accounts = ["Wallet1", "DistributedWallet"] +# wallets = ["Wallet1", "DistributedWallet"] # [[signer.dirk.hosts]] # server_name = "localhost-2" # url = "https://localhost:8882" -# accounts = ["Wallet2", "DistributedWallet"] +# wallets = ["Wallet2", "DistributedWallet"] -# Configuration for how the Signer module should store proxy delegations. +# Configuration for how the Signer service should store proxy delegations. # OPTIONAL # [signer.dirk.store] # proxy_dir = "/path/to/proxies" # For Local signer: -# Configuration for how the Signer module should load validator keys. Currently two types of loaders are supported: +# Configuration for how the Signer service should load validator keys. Currently two types of loaders are supported: # - File: load keys from a plain text file (unsafe, use only for testing purposes) # - ValidatorsDir: load keys from a `keys` and `secrets` file/folder (ERC-2335 style keystores). More details can be found in the docs (https://commit-boost.github.io/commit-boost-client/get_started/configuration/) [signer.local.loader] @@ -288,7 +289,7 @@ key_path = "./tests/data/keys.example.json" # For lodestar, it's the path to the file containing the decryption password. # For nimbus, it's the path to the directory where the `` files are located. # secrets_path = "" -# Configuration for how the Signer module should store proxy delegations. Supported types of store are: +# Configuration for how the Signer service should store proxy delegations. Supported types of store are: # - File: store keys and delegations from a plain text file (unsafe, use only for testing purposes) # - ERC2335: store keys and delegations safely using ERC-2335 style keystores. More details can be found in the docs (https://commit-boost.github.io/commit-boost-client/get_started/configuration#proxy-keys-store) # OPTIONAL, if missing proxies are lost on restart From ac808bc4ef619398248b05aabcbb1e274450ebac Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 11 Aug 2026 14:03:53 -0700 Subject: [PATCH 03/21] docs: accuracy fixes against the v0.10.0 code Findings from an audit of the ported pages against tag v0.10.0. running/docker.md - The generated compose file is `cb.docker-compose.yml`, not `.cb.docker-compose.yml`; only the env file has a leading dot (crates/cli/src/docker_init.rs: CB_COMPOSE_FILE vs CB_ENV_FILE). - Bumped the six pinned `ghcr.io/commit-boost/commit-boost:v0.9.6` image tags to v0.10.0. developing/extending-pbs.md - The entry-point snippet used an unbound `config_path`. Bound it to `PathBuf::new()` to match examples/status_api/src/main.rs, and added a note that PbsService::run only starts the config-file watcher when that path is non-empty (crates/pbs/src/service.rs), so the example as written does not hot-reload. - The same caveat is now cross-linked from the auto-reload sections of configuration.md and running/docker.md, which claimed auto-reload unconditionally. get_started/mux-key-loaders.md - Dropped the "first-match semantics / checked in order" paragraph. All mux pubkeys are flattened into a single lookup map, and a pubkey in two muxes is a hard startup error ("duplicate validator pubkey in muxes", crates/common/src/config/mux.rs) rather than a first-wins fallback. Config order is irrelevant. - `default_pbs.http_timeout_seconds` is an internal field name; the user-facing key is `http_timeout_seconds` under `[pbs]`. running/binary.md - CB_CHAIN_SPEC overrides the `path` of the top-level `chain` key (not a `[chain]` section), and only for the { genesis_time_secs, path } form. It is silently ignored for a network name or the fully inline custom object (crates/common/src/config/mod.rs from_env_path). get_started/configuration.md - The minimal examples used `url = ""` for a relay. RelayEntry deserializes the url with Url::deserialize and parses the userinfo as a BLS pubkey, so an empty string never starts. Replaced with a parseable placeholder plus a warning. - Noted that the inline custom-chain object has no optional fields. get_started/building.md - Refreshed the stale sample log output: version 0.7.0 / 0.8.0-rc.1, and the `events_subs` field that no longer exists in the PBS startup log. Regenerated from the actual log statements in crates/pbs/src/service.rs, crates/pbs/src/routes/router.rs and crates/signer/src/service.rs, including the fields the signer now logs and the TLS warning the sample config actually produces. get_started/overview.md - `.releases/v0.10.0-rc1.yml` no longer describes the current release; pointed at `.releases/v0.10.0.yml` and its commit. --- docs/docs/developing/extending-pbs.md | 17 +++++++++++++++ docs/docs/get_started/building.md | 26 +++++++++++++++-------- docs/docs/get_started/configuration.md | 27 ++++++++++++++++++++---- docs/docs/get_started/mux-key-loaders.md | 21 ++++++++++-------- docs/docs/get_started/overview.md | 20 +++++++++--------- docs/docs/get_started/running/binary.md | 2 +- docs/docs/get_started/running/docker.md | 22 ++++++++++--------- 7 files changed, 92 insertions(+), 43 deletions(-) diff --git a/docs/docs/developing/extending-pbs.md b/docs/docs/developing/extending-pbs.md index baaff8cc2..72ef13045 100644 --- a/docs/docs/developing/extending-pbs.md +++ b/docs/docs/developing/extending-pbs.md @@ -97,11 +97,28 @@ impl BuilderApi for MyBuilderApi { 4. Load config and run: ```rust +use std::path::PathBuf; + let (pbs_config, extra) = load_pbs_custom_config::().await?; + +// The second argument is the path PBS watches for config hot-reloads. +// An empty path disables the watcher — see the note below. +let config_path = PathBuf::new(); + let state = PbsState::new(pbs_config, config_path).with_data(MyBuilderState::from_config(extra)); PbsService::run::(state).await ``` +:::note Config hot-reload is opt-in for custom binaries + +`PbsService::run` only spawns the config file watcher when the `config_path` handed to +`PbsState::new` is a non-empty path. `examples/status_api` passes `PathBuf::new()`, so that example +does **not** hot-reload: config changes need a restart. + +To get the same auto-reload behavior as the stock PBS binary, pass the real path of your config file +(typically the value of `CB_CONFIG`) instead of an empty `PathBuf`. +::: + ### Running Compile and run your binary. Set the same environment variables as the default PBS (see [Running with binary](../get_started/running/binary.md)). Your custom PBS handles the same BuilderAPI endpoints plus any extra routes you added. diff --git a/docs/docs/get_started/building.md b/docs/docs/get_started/building.md index 049dbef21..5486cb2e1 100644 --- a/docs/docs/get_started/building.md +++ b/docs/docs/get_started/building.md @@ -138,13 +138,17 @@ CB_CONFIG=cb-config.toml ./build///commit-boost pbs If it works, you should see output like this: ``` -2025-05-07T21:09:17.407245Z WARN No metrics server configured -2025-05-07T21:09:17.407257Z INFO starting PBS service version="0.7.0" commit_hash="58082edb1213596667afe8c3950cd997ab85f4f3" addr=127.0.0.1:18550 events_subs=0 chain=Hoodi -2025-05-07T21:09:17.746855Z INFO : new request ua="" relay_check=true method=/eth/v1/builder/status req_id=5c405c33-0496-42ea-a35d-a7a01dbba356 -2025-05-07T21:09:17.896196Z INFO : relay check successful method=/eth/v1/builder/status req_id=5c405c33-0496-42ea-a35d-a7a01dbba356 +2025-11-04T14:22:03.118427Z WARN No metrics server configured +2025-11-04T14:22:03.118512Z INFO starting PBS service version="0.10.0" commit_hash="f05eefbf652ac5442088bd2b20390d29c23b1c5d" addr=127.0.0.1:18550 chain=Hoodi +2025-11-04T14:22:03.372184Z INFO : new request ua="" relay_check=true method=/eth/v1/builder/status req_id=5c405c33-0496-42ea-a35d-a7a01dbba356 +2025-11-04T14:22:03.521903Z INFO : relay check successful method=/eth/v1/builder/status req_id=5c405c33-0496-42ea-a35d-a7a01dbba356 +2025-11-04T14:22:03.522015Z INFO : Responded with 200 OK in 149 ms method=/eth/v1/builder/status req_id=5c405c33-0496-42ea-a35d-a7a01dbba356 ``` -If you do, then the PBS service works. +The exact timestamps, request ids and commit hash will of course differ; what matters is the +`starting PBS service` line and the successful relay check that follows it. + +If you see that, then the PBS service works. ### Verifying the Signer Module @@ -166,9 +170,13 @@ CB_CONFIG=cb-config.toml CB_JWTS="test=dummy" CB_SIGNER_ADMIN_JWT="dummy_admin" You should see output like this: ``` -2025-06-03T04:57:19.815702Z WARN Proxy store not configured. Proxies keys and delegations will not be persisted -2025-06-03T04:57:19.818193Z INFO Starting signing service version="0.8.0-rc.1" commit_hash="3eed5268f07803c55cca7d7e2e14a7017098f797" modules=["test"] endpoint=127.0.0.1:20000 loaded_consensus=0 loaded_proxies=0 -2025-06-03T04:57:19.818229Z WARN No metrics server configured +2025-11-04T14:31:44.815702Z WARN Proxy store not configured. Proxies keys and delegations will not be persisted +2025-11-04T14:31:44.818193Z INFO Starting signing service version="0.10.0" commit_hash="f05eefbf652ac5442088bd2b20390d29c23b1c5d" modules=["test"] endpoint=127.0.0.1:20000 loaded_consensus=0 loaded_proxies=0 jwt_auth_fail_limit=3 jwt_auth_fail_timeout=300s reverse_proxy=None +2025-11-04T14:31:44.818229Z WARN No metrics server configured +2025-11-04T14:31:44.818305Z WARN Running in insecure HTTP mode, no TLS certificates provided ``` -If you do, then the binary works. +The `insecure HTTP mode` warning is expected here: the default `tls_mode` is `insecure`, and this +config does not set one. See [TLS](./configuration.md#tls) for how to enable it. + +If you see that, then the binary works. diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index 8df8a5bd3..78b431533 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -18,12 +18,19 @@ chain = "Hoodi" port = 18550 [[relays]] -url = "" +# Replace this with your relay's own URL, in the form scheme://@ +url = "https://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@relay.example.com" [metrics] enabled = true ``` +:::warning +The relay `url` is not a placeholder you can leave blank. It must be a full absolute URL whose +userinfo part is the relay's BLS public key: an empty string fails to parse, and a URL without a +pubkey is rejected with `invalid BLS pubkey`. Either way the sidecar refuses to start. +::: + You can find a list of MEV-Boost Hoodi relays [here](https://github.com/ethstaker/ethstaker-guides/blob/main/MEV-relay-list.md#mev-relay-list-for-hoodi-testnet). After the sidecar is started, it will expose a port (`18550` in this example), that you need to point your CL to. This may be different depending on which CL you're running, check out [here](https://docs.flashbots.net/flashbots-mev-boost/getting-started/system-requirements#consensus-client-configuration-guides) for a list of configuration guides. @@ -47,7 +54,9 @@ chain = { genesis_time_secs = 1695902400, path = "/path/to/spec.json" } chain = { genesis_time_secs = 1695902400, slot_time_secs = 12, genesis_fork_version = "0x01017000", fulu_fork_slot = 5283840, chain_id = 17000 } ``` -When using the spec-file form, the `CB_CHAIN_SPEC` environment variable can be set to override the spec file path at runtime (see [Binary](./running/binary.md#common)). +All fields of the inline form are required; there are no defaults, and omitting one makes the whole `chain` value fail to parse. + +When using the spec-file form, the `CB_CHAIN_SPEC` environment variable can be set to override the spec file path at runtime (see [Binary](./running/binary.md#common)). It has no effect on the other two forms. ## PBS safety and tuning options @@ -498,7 +507,8 @@ The `cb-config.toml` file needs to be updated as follows: port = 18550 [[relays]] -url = "" +# Replace this with your relay's own URL, in the form scheme://@ +url = "https://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@relay.example.com" [signer] port = 20000 @@ -577,6 +587,15 @@ docker compose -f cb.docker-compose.yml exec cb_signer curl -X POST -H "Authoriz In addition to the manual `/reload` endpoint, the PBS Service watches the config file for changes and automatically reloads the configuration whenever the file is modified — no restart or API call needed. If the reload fails (e.g. because of a misconfigured option), it logs a warning and keeps the previous configuration. +:::caution Custom PBS binaries + +The file watcher is only started when the PBS service is given a non-empty config path. The stock +PBS binary always passes one, so automatic reload works out of the box. A **custom PBS binary** only +gets it if it passes the real config path to `PbsState::new` — `examples/status_api` passes an empty +`PathBuf`, which disables the watcher. See [Extending PBS](../developing/extending-pbs.md#entry-point). +Note that the manual `POST /reload` endpoint works either way. +::: + ### Signer Service reload When the signer receives a reload request it re-reads the configuration file and environment variables, rebuilding its internal state to match: @@ -615,7 +634,7 @@ Send `POST /revoke_jwt` with the module ID. This removes the module from the sig ### Notes -- The hot reload feature is available for PBS Service (both default and custom) and Signer Service. +- The hot reload feature is available for PBS Service (both default and custom) and Signer Service. Note that the *automatic* file-watching reload additionally requires a non-empty config path (see the caution above); the `/reload` endpoint itself is always available. - Changes related to listening hosts and ports will not been applied, as it requires the server to be restarted. - If running in Docker containers, changes in `volumes` will not be applied, as it requires the container to be recreated. Be careful if changing a path to a local file as it may not be accessible from the container. - Custom PBS Service may override the default behaviour of the hot reload feature to parse extra configuration fields. Check the [examples](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/status_api/src/main.rs) for more details. diff --git a/docs/docs/get_started/mux-key-loaders.md b/docs/docs/get_started/mux-key-loaders.md index 436562ce7..cd77f4300 100644 --- a/docs/docs/get_started/mux-key-loaders.md +++ b/docs/docs/get_started/mux-key-loaders.md @@ -18,9 +18,11 @@ Mux entries are an optional addition to the `[[relays]]` section. If you don't n ## Mux entry matching -Each `[[mux]]` entry declares a set of validator pubkeys. The mux system enforces that these sets are **disjoint** — a validator pubkey should appear in at most one mux entry. If a pubkey is duplicated across mux entries, the sidecar will refuse to start. +Each `[[mux]]` entry declares a set of validator pubkeys. At startup the sidecar resolves every mux (running its loader, if any), then flattens all of them into a **single pubkey -> mux** lookup table. Matching is therefore a direct lookup on the validator pubkey, not an ordered scan: the order in which `[[mux]]` entries appear in the config file does not matter. -Matching uses **first-match semantics**: when the PBS receives a request for a validator, it checks each mux entry in the order they appear in the config file. The first mux whose pubkey set contains the validator's key wins. Validators that don't match any mux entry fall through to the global `[[relays]]` configuration. +Because there is only one entry per pubkey, the mux sets must be **disjoint**. This is enforced before the lookup table is built: if the same validator pubkey appears in two different mux entries — whether listed inline or pulled in by a loader — startup fails with `duplicate validator pubkey in muxes: 0x...`. There is no "first mux wins" fallback; you must fix the config. + +Validators that don't appear in any mux fall through to the global `[[relays]]` configuration. ```toml # Global relays — used for validators not matching any mux @@ -28,7 +30,7 @@ Matching uses **first-match semantics**: when the PBS receives a request for a v id = "global-relay" url = "..." -# First mux entry — checked first +# A mux entry — its pubkeys must not appear in any other mux [[mux]] id = "timing-sensitive" validator_pubkeys = [ @@ -53,14 +55,15 @@ url = "..." | Condition | Behaviour | |---|---| -| Pubkey matches a mux entry | That mux's relays and timing config are used | -| Pubkey appears in multiple mux entries | Validation error — sidecar fails to start | -| Pubkey doesn't match any entry | Falls through to global `[[relays]]` | -| A mux has no pubkeys (empty set) | Validation error — each mux must have at least one pubkey | +| Pubkey belongs to exactly one mux | That mux's relays and timing config are used | +| Pubkey appears in more than one mux | Startup error: `duplicate validator pubkey in muxes` | +| Pubkey doesn't belong to any mux | Falls through to global `[[relays]]` | +| A mux has no pubkeys (empty set) | Startup error — each mux must have at least one pubkey | +| A mux has no relays | Startup error — each mux must have at least one relay | The pubkey set for a mux can come from two sources combined: 1. **Inline `validator_pubkeys`** — a list of hex-prefixed BLS pubkeys in the config file itself. -2. **A loader plugin** — loads additional keys from a file, URL, or on-chain registry. Keys from the loader are merged into the mux's pubkey set. +2. **A loader plugin** — loads additional keys from a file, URL, or on-chain registry. Keys from the loader are merged into the mux's pubkey set *before* the disjointness check runs, so a key pulled in by a loader can collide with one you listed inline in another mux. --- @@ -120,7 +123,7 @@ url = "..." **Request behaviour:** - One-shot GET request — no retry logic. -- Timeout is controlled by `default_pbs.http_timeout_seconds` (default: 10s). +- Timeout is controlled by `http_timeout_seconds` in the `[pbs]` section (default: 10s). - The response body is read in full and parsed as JSON. --- diff --git a/docs/docs/get_started/overview.md b/docs/docs/get_started/overview.md index f5dbcef5f..55c3fc34f 100644 --- a/docs/docs/get_started/overview.md +++ b/docs/docs/get_started/overview.md @@ -49,23 +49,23 @@ git submodule update --init --recursive If you get an `openssl` related error try running: `apt-get update && apt-get install -y openssl ca-certificates libssl3 libssl-dev build-essential pkg-config` ::: -Each Commit-Boost release commit is located as a versioned file in the `./releases` folder. For example `.releases/v0.10.0-rc1.yml` contains: +Each Commit-Boost release commit is located as a versioned file in the `.releases` folder. For example `.releases/v0.10.0.yml` contains: ```yml -commit: "efda6a67f43b0ddb400c454a65b055d59acc7d6c" -reason: "Substantial change to harden security in the signer service, improve build and release process, quality of life improvements to logging, and more support for SSV integrations. Contains breaking changes to the signer service and how the CLI is invoked." +commit: "eeff25750c01f4adfc95fc08d69d541ace8e4087" +reason: "Final release including rc1-rc4 changes" ``` To locally build that release version, checkout the commit: ```bash -# Switch the the specific release -git checkout efda6a67f43b0ddb400c454a65b055d59acc7d6c - -# Build the binary +# Switch to the specific release +git checkout eeff25750c01f4adfc95fc08d69d541ace8e4087 + +# Build the binary just build-bin $(git rev-parse --short HEAD) ``` -The binary will be stored in `build//`, for example `build/efda6a6/linux_amd64/`: +The binary will be stored in `build//`, for example `build/eeff257/linux_amd64/`: You can confirm the binary was built successfully by navigating to the build directory and checking its version: ```bash @@ -77,8 +77,8 @@ You can confirm the binary was built successfully by navigating to the build dir Building the service images requires the binary to be built using the above instructions first, since it will be copied into those images. The `build-all` command compiles the binary and then creates the image in one step: ```bash -# Switch the the specific release -git checkout efda6a67f43b0ddb400c454a65b055d59acc7d6c +# Switch to the specific release +git checkout eeff25750c01f4adfc95fc08d69d541ace8e4087 # Build the binary and create the image just build-all $(git rev-parse --short HEAD) diff --git a/docs/docs/get_started/running/binary.md b/docs/docs/get_started/running/binary.md index af97e0e9e..f65d598aa 100644 --- a/docs/docs/get_started/running/binary.md +++ b/docs/docs/get_started/running/binary.md @@ -17,7 +17,7 @@ Services need environment variables to work correctly. ### Common - `CB_CONFIG`: required, path to the `.toml` config file. -- `CB_CHAIN_SPEC`: optional, path to a chain spec file. This will override the `[chain]` field in the `.toml` config. +- `CB_CHAIN_SPEC`: optional, path to a chain spec file. It overrides the `path` of the top-level `chain` key in the `.toml` config, and **only** when `chain` uses the spec-file form `chain = { genesis_time_secs = ..., path = "..." }` (see [Custom chains](../configuration.md#custom-chains)). If `chain` is a network name (e.g. `chain = "Holesky"`) or the fully-inline custom object, this variable is silently ignored. - `CB_METRICS_PORT`: optional, port where to expose the `/metrics` endpoint for Prometheus. - `CB_LOGS_DIR`: optional, directory to store logs. This will override the directory in the `.toml` config. diff --git a/docs/docs/get_started/running/docker.md b/docs/docs/get_started/running/docker.md index 9d1d82605..bb07a4956 100644 --- a/docs/docs/get_started/running/docker.md +++ b/docs/docs/get_started/running/docker.md @@ -19,7 +19,7 @@ This will create two files: To start Commit-Boost run: ```bash -docker compose --env-file ".cb.env" -f ".cb.docker-compose.yml" up -d +docker compose --env-file ".cb.env" -f "cb.docker-compose.yml" up -d ``` :::note @@ -33,7 +33,7 @@ The MEV-Boost server will be exposed at `pbs.port` from the config, `18550` in o ## Logs To check the logs, run: ```bash -docker compose --env-file ".cb.env" -f ".cb.docker-compose.yml" logs -f +docker compose --env-file ".cb.env" -f "cb.docker-compose.yml" logs -f ``` This will currently show all logs from the different services via the Docker logs interface. Logs are also optionally saved to file, depending on your [`[logs]` configuration](../configuration.md#logs). @@ -41,7 +41,7 @@ This will currently show all logs from the different services via the Docker log To stop all the services and cleanup, simply run: ```bash -docker compose --env-file ".cb.env" -f ".cb.docker-compose.yml" down +docker compose --env-file ".cb.env" -f "cb.docker-compose.yml" down ``` This will wind down all services and clear internal networks and file mounts. @@ -58,7 +58,7 @@ Below is a simple configuration for running only the PBS service on the Hoodi ne chain = "Hoodi" [pbs] -docker_image = "ghcr.io/commit-boost/commit-boost:v0.9.6" +docker_image = "ghcr.io/commit-boost/commit-boost:v0.10.0" relay_check = true wait_all_registrations = true @@ -89,7 +89,7 @@ services: timeout: 5s retries: 3 start_period: 5s - image: ghcr.io/commit-boost/commit-boost:v0.9.6 + image: ghcr.io/commit-boost/commit-boost:v0.10.0 container_name: cb_pbs ports: - 127.0.0.1:18550:18550 @@ -109,7 +109,9 @@ This will run the PBS service in a container named `cb_pbs`. The program creates a read-only volume binding for the config file, which the PBS service needs to run. The Docker compose file that it creates with the `init` command, `cb.docker-compose.yml`, will be placed into your current working directory when you run the program. The volume source will be specified as a *relative path* to that working directory, so it's ideal if the config file is directly within your working directory (or a subdirectory). If you need to specify an absolute path for the config file, you can adjust the `volumes` entry within the Docker compose file manually after its creation. -Since this is a volume, the PBS service sees changes to the file: it watches the config file and [automatically reloads the configuration](../configuration.md#automatic-reload-pbs-only) whenever the file is modified, without needing a restart. That means you can change the file any time after the Docker compose file is created to tweak PBS's parameters, but it also means the config file must stay in the same location; if you move it, the PBS container won't be able to mount it anymore and fail to start unless you manually adjust the volume's source location. +Since this is a volume, the PBS service sees changes to the file: the stock PBS image watches the config file and [automatically reloads the configuration](../configuration.md#automatic-reload-pbs-only) whenever the file is modified, without needing a restart. That means you can change the file any time after the Docker compose file is created to tweak PBS's parameters, but it also means the config file must stay in the same location; if you move it, the PBS container won't be able to mount it anymore and fail to start unless you manually adjust the volume's source location. + +If you replaced `pbs.docker_image` with a **custom PBS image**, the automatic watcher is only active if that binary passes a non-empty config path to `PbsState::new` — see [Extending PBS](../../developing/extending-pbs.md#entry-point). The manual `POST /reload` endpoint works regardless. ### Networking @@ -148,7 +150,7 @@ Below is a simple configuration for running only the three modules on the Hoodi chain = "Hoodi" [pbs] -docker_image = "ghcr.io/commit-boost/commit-boost:v0.9.6" +docker_image = "ghcr.io/commit-boost/commit-boost:v0.10.0" relay_check = true wait_all_registrations = true @@ -161,7 +163,7 @@ id = "def" url = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@def.xyz" [signer] -docker_image = "ghcr.io/commit-boost/commit-boost:v0.9.6" +docker_image = "ghcr.io/commit-boost/commit-boost:v0.10.0" port = 20000 [signer.local.loader] @@ -219,7 +221,7 @@ services: timeout: 5s retries: 3 start_period: 5s - image: ghcr.io/commit-boost/commit-boost:v0.9.6 + image: ghcr.io/commit-boost/commit-boost:v0.10.0 container_name: cb_pbs ports: - 127.0.0.1:18550:18550 @@ -237,7 +239,7 @@ services: timeout: 5s retries: 3 start_period: 5s - image: ghcr.io/commit-boost/commit-boost:v0.9.6 + image: ghcr.io/commit-boost/commit-boost:v0.10.0 container_name: cb_signer ports: - 127.0.0.1:20000:20000 From 83b22d3ebe5a95ecaebe3e69a0ecfa9ca603c931 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 11 Aug 2026 14:08:20 -0700 Subject: [PATCH 04/21] docs: refresh the remaining stale examples found while auditing Two things the ported content did not cover. troubleshooting.md The "PBS" section at the end still carried pre-0.8 log output: a `Starting PBS service address=... events_subs=0` startup line (the `events_subs` field no longer exists) and `status{req_id=...}:` style spans. The request span is now created by tracing_middleware with an empty name and carries `method` and `req_id` plus the per-route fields recorded by each handler, so every line renders as `INFO : `. Regenerated all four samples from the handlers in crates/pbs/src/routes/, added the TraceLayer "Responded with" line, and documented the 204/202 outcomes the handlers return. running/k8s.md The values table correctly reports `image.tag` defaulting to v0.4.0, which really is what provisioning/k8s/commit-boost/values.yaml pins. That image predates the unified `commit-boost pbs` CLI the same chart invokes via `command`/`args`, so installing the chart unmodified cannot work. Added a warning to set the tag explicitly. --- docs/docs/get_started/running/k8s.md | 6 +++++ docs/docs/get_started/troubleshooting.md | 32 ++++++++++++++++-------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/docs/get_started/running/k8s.md b/docs/docs/get_started/running/k8s.md index ab6681751..4259f4554 100644 --- a/docs/docs/get_started/running/k8s.md +++ b/docs/docs/get_started/running/k8s.md @@ -56,6 +56,12 @@ The PBS service is configured through the `values.yaml` file. The chart exposes | `resources` | object | `{}` | Pod resource requests and limits | | `autoscaling.enabled` | bool | `false` | Enable horizontal pod autoscaling | +:::warning +The chart's default `commitBoost.pbs.image.tag` is `v0.4.0`, which is far behind the current release +and predates the unified `commit-boost pbs` CLI the chart invokes. Always set the tag explicitly to +the release you want (e.g. `v0.10.0`) before installing. +::: + For the full list of available values and their descriptions, see the [README.md](https://github.com/Commit-Boost/commit-boost-client/blob/main/provisioning/k8s/commit-boost/README.md) in the chart directory. ## Upgrading diff --git a/docs/docs/get_started/troubleshooting.md b/docs/docs/get_started/troubleshooting.md index 500eda70e..36b81081f 100644 --- a/docs/docs/get_started/troubleshooting.md +++ b/docs/docs/get_started/troubleshooting.md @@ -206,9 +206,10 @@ If the request itself fails (rather than simply yielding no bids), PBS instead r If you started the modules correctly you should see the following logs. ## PBS -After the module started correctly you should see: + +After the service started correctly you should see: ```bash -2024-09-16T19:27:16.004643Z INFO Starting PBS service address=0.0.0.0:18550 events_subs=0 +2025-11-04T14:22:03.118512Z INFO starting PBS service version="0.10.0" commit_hash="f05eefbf652ac5442088bd2b20390d29c23b1c5d" addr=0.0.0.0:18550 chain=Hoodi ``` To check that the setup is correct and you are connected to relays, you can trigger manually the `/status` endpoint, by running: @@ -226,7 +227,7 @@ curl http://0.0.0.0:18550/eth/v1/builder/status -vvv * Mark bundle as not supporting multiuse < HTTP/1.1 200 OK < content-length: 0 -< date: Mon, 16 Sep 2024 19:32:07 GMT +< date: Tue, 04 Nov 2025 14:32:07 GMT < * Connection #0 to host 0.0.0.0 left intact ``` @@ -234,31 +235,40 @@ curl http://0.0.0.0:18550/eth/v1/builder/status -vvv if now you check the logs, you should see: ```bash -2024-09-16T19:32:07.634966Z INFO status{req_id=62f1c0db-f277-49fa-91e7-a9a1c2b2a6d3}: ua="curl/7.81.0" relay_check=true -2024-09-16T19:32:07.642992Z INFO status{req_id=62f1c0db-f277-49fa-91e7-a9a1c2b2a6d3}: relay check successful +2025-11-04T14:32:07.634966Z INFO : new request ua="curl/7.81.0" relay_check=true method=/eth/v1/builder/status req_id=62f1c0db-f277-49fa-91e7-a9a1c2b2a6d3 +2025-11-04T14:32:07.642992Z INFO : relay check successful method=/eth/v1/builder/status req_id=62f1c0db-f277-49fa-91e7-a9a1c2b2a6d3 +2025-11-04T14:32:07.643104Z INFO : Responded with 200 OK in 8 ms method=/eth/v1/builder/status req_id=62f1c0db-f277-49fa-91e7-a9a1c2b2a6d3 ``` +The leading `:` is the (deliberately unnamed) request span; the fields after the message are that +span's fields, so every line belonging to one request carries the same `req_id`. + If the sidecar is setup correctly, it will receive and process calls from the CL: + #### Register validator This should happen periodically, depending on your validator setup. ```bash -2024-09-16T19:28:37.976534Z INFO register_validators{req_id=296f662f-0e7a-4f15-be75-55b8ca19ffc0}: ua="Lighthouse/v5.2.1-9e12c21" num_registrations=500 -2024-09-16T19:28:38.819591Z INFO register_validators{req_id=296f662f-0e7a-4f15-be75-55b8ca19ffc0}: register validator successful +2025-11-04T14:28:37.976534Z INFO : new request ua="Lighthouse/v5.2.1-9e12c21" num_registrations=500 method=/eth/v1/builder/validators req_id=296f662f-0e7a-4f15-be75-55b8ca19ffc0 +2025-11-04T14:28:38.819591Z INFO : register validator successful method=/eth/v1/builder/validators req_id=296f662f-0e7a-4f15-be75-55b8ca19ffc0 ``` #### Get header This will only happen if some of your validators have a proposal slot coming up. ```bash -2024-09-16T19:30:24.135376Z INFO get_header{req_id=74126c5f-69e6-4961-86a6-6c2597bf15f5 slot=2551052}: ua="Lighthouse/v5.2.1-9e12c21" parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea validator_pubkey=0x84fc20b09496341f24abfcb6f407e916ecc317497c5b1bba4970e50e96cf5e731b88e51753064c30cb221453bd71aebf ms_into_slot=135 -2024-09-16T19:30:25.089477Z INFO get_header{req_id=74126c5f-69e6-4961-86a6-6c2597bf15f5 slot=2551052}: received header block_hash=0x0139686e8d251f010153875270256fce6f298d7b3f3f9129179fb86297dffad3 value_eth="0.001399518501462470" +2025-11-04T14:30:24.135376Z INFO : new request ua="Lighthouse/v5.2.1-9e12c21" ms_into_slot=135 method=/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey} req_id=74126c5f-69e6-4961-86a6-6c2597bf15f5 slot=2551052 parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea validator=0x84fc20b09496341f24abfcb6f407e916ecc317497c5b1bba4970e50e96cf5e731b88e51753064c30cb221453bd71aebf +2025-11-04T14:30:25.089477Z INFO : received header value_eth="0.001399518501462470" block_hash=0x0139686e8d251f010153875270256fce6f298d7b3f3f9129179fb86297dffad3 method=/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey} req_id=74126c5f-69e6-4961-86a6-6c2597bf15f5 slot=2551052 parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea validator=0x84fc20b09496341f24abfcb6f407e916ecc317497c5b1bba4970e50e96cf5e731b88e51753064c30cb221453bd71aebf ``` +If no relay returns a usable bid you will see `no header available for slot` instead, and PBS answers the beacon node with a `204`. + #### Submit block This will only happen if you received a header in the previous call, and if the header is higher than the locally built block. ```bash -2024-09-16T14:38:01.409075Z INFO submit_blinded_block{req_id=6eb9a04d-6f79-4295-823f-c054582b3599 slot=2549590}: ua="Lighthouse/v5.2.1-9e12c21" slot_uuid=16186e06-0cd0-47bc-9758-daa1b66eff5c ms_into_slot=1409 block_hash=0xfa135ae6f2bfb32b0a47368f93d69e0a2b3f8b855d917ec61d78e78779edaae6 -2024-09-16T14:38:02.910974Z INFO submit_blinded_block{req_id=6eb9a04d-6f79-4295-823f-c054582b3599 slot=2549590}: received unblinded block +2025-11-04T14:38:01.409075Z INFO : new request ua="Lighthouse/v5.2.1-9e12c21" ms_into_slot=1409 method=/eth/v1/builder/blinded_blocks req_id=6eb9a04d-6f79-4295-823f-c054582b3599 slot=2549590 block_hash=0xfa135ae6f2bfb32b0a47368f93d69e0a2b3f8b855d917ec61d78e78779edaae6 block_number=2549123 parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea +2025-11-04T14:38:02.910974Z INFO : received unblinded block (v1) method=/eth/v1/builder/blinded_blocks req_id=6eb9a04d-6f79-4295-823f-c054582b3599 slot=2549590 block_hash=0xfa135ae6f2bfb32b0a47368f93d69e0a2b3f8b855d917ec61d78e78779edaae6 block_number=2549123 parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea ``` + +A beacon node calling the v2 route (`POST /eth/v2/builder/blinded_blocks`) logs `received unblinded block (v2)` instead, and PBS answers with a `202` and an empty body. From ef1da4e3953fabc3948b08bddab1394de3d31a66 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 11 Aug 2026 15:53:40 -0700 Subject: [PATCH 05/21] docs: document the remaining [pbs] timeout and toggle keys --- docs/docs/get_started/configuration.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index 78b431533..9081fff24 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -62,8 +62,15 @@ When using the spec-file form, the `CB_CHAIN_SPEC` environment variable can be s Beyond the basics shown above, the `[pbs]` section supports some additional knobs (see the [annotated config example](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml) for the full list): +- `relay_check`: whether to forward `get_status` calls to the relays on startup, or skip the check and return `200` immediately. Default: `true`. +- `wait_all_registrations`: whether to wait for all relays to respond to a validator registration before returning, or return after the first successful registration. Default: `true`. +- `timeout_get_header_ms`: timeout, in milliseconds, for the `get_header` call to relays. Must be greater than 0, and must be less than `late_in_slot_time_ms`. Note that the CL also has a timeout (e.g. 1 second), so this should be lower than that to leave some margin for overhead. Default: `950`. +- `timeout_get_payload_ms`: timeout, in milliseconds, for the `submit_blinded_block` (get payload) call to relays. Must be greater than 0. Default: `4000`. +- `timeout_register_validator_ms`: timeout, in milliseconds, for the `register_validator` call to relays. Must be greater than 0. Default: `3000`. - `skip_sigverify`: whether to skip verification of the relay signature and pubkey in `get_header` responses. Default: `false`. - `min_bid_eth`: minimum bid in ETH that will be accepted from `get_header`, can be specified as a float or a string for extra precision (e.g. `"0.01"`). Default: `0.0`. +- `late_in_slot_time_ms`: how late, in milliseconds into the slot, is considered "late". This shortens `get_header` timeouts to make sure a header is returned within this deadline; if the CL request arrives later in the slot, fetching headers is skipped to force local building and minimize the risk of a missed slot (see also the timing games notes). Must be greater than 0. Default: `2000`. +- `http_timeout_seconds`: timeout, in seconds, for any HTTP request sent from the PBS module to other services. Default: `10`. - `extra_validation_enabled`: whether to enable extra validation of `get_header` responses. If enabled, `rpc_url` must also be set to an Execution Layer RPC on the same chain as the sidecar (this is checked at startup). Default: `false`. - `register_validator_retry_limit`: maximum number of retries for validator registration requests per relay, must be greater than 0. Default: `3`. - `validator_registration_batch_size`: maximum number of validators to send to relays in a single registration request. Default: unlimited. @@ -106,6 +113,23 @@ max_files = 30 # Maximum number of log files to keep. Default: unlimited The `CB_LOGS_DIR` environment variable overrides `dir_path` (see [Binary](./running/binary.md#common)). +## Metrics + +Prometheus metrics are configured via the optional `[metrics]` section: + +```toml +[metrics] +enabled = true # Whether to collect metrics. Default: true +host = "127.0.0.1" # Host to expose the metrics servers on. Default: 127.0.0.1 +start_port = 10000 # First port to expose metrics on. Default: 10000 +``` + +- `enabled`: whether to collect metrics. Default: `true`. +- `host`: host to expose the metrics servers on. Default: `127.0.0.1`. +- `start_port`: the first port that services listen on for Prometheus scrapes. Each service uses this port, then `start_port + 1`, `start_port + 2`, and so on. Default: `10000`. + +The `CB_METRICS_PORT` environment variable overrides the port used by a module at runtime. + ## Signer Service Commit-Boost supports both local and remote signers. The Signer Service is responsible for signing the transactions that commit modules generate (***it is not used by the PBS Service***). Please note that only one signer at a time is allowed. From a6188d1b45fca760d8d39ee151c4fc8e52e5895e Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 11 Aug 2026 16:51:00 -0700 Subject: [PATCH 06/21] docs: readability polish from review pass Signer Module -> Service heading consistency, correct a copy-pasted image alt, disambiguate the three 'Overview' page titles, PBS capitalization, normalize configuration anchor links, code-format a config value, and give the troubleshooting healthy-logs block its own heading. --- docs/docs/architecture/overview.md | 2 +- docs/docs/developing/prop-commit-signing.md | 2 +- docs/docs/get_started/building.md | 2 +- docs/docs/get_started/configuration.md | 2 +- docs/docs/get_started/overview.md | 2 +- docs/docs/get_started/running/binary.md | 6 +++--- docs/docs/get_started/troubleshooting.md | 4 +++- docs/docs/overview.md | 2 +- 8 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/docs/architecture/overview.md b/docs/docs/architecture/overview.md index 48eaebde9..67089afed 100644 --- a/docs/docs/architecture/overview.md +++ b/docs/docs/architecture/overview.md @@ -7,7 +7,7 @@ description: Overview of the architecture of Commit-Boost Below is schematic overview of Commit-Boost. Commit-Boost runs as a single sidecar composed of multiple modules: -- Pbs Service with the [BuilderAPI](https://ethereum.github.io/builder-specs/) for [MEV Boost](https://docs.flashbots.net/flashbots-mev-boost/architecture-overview/specifications) +- PBS Service with the [BuilderAPI](https://ethereum.github.io/builder-specs/) for [MEV Boost](https://docs.flashbots.net/flashbots-mev-boost/architecture-overview/specifications) - A Signer Service implementing the SignerAPI - Commit Modules that implement some custom commit protocol logic - Telemetry modules like Prometheus and Grafana diff --git a/docs/docs/developing/prop-commit-signing.md b/docs/docs/developing/prop-commit-signing.md index ab67d605a..d8fd00c49 100644 --- a/docs/docs/developing/prop-commit-signing.md +++ b/docs/docs/developing/prop-commit-signing.md @@ -159,7 +159,7 @@ For a complete working example, see [`examples/da_commit/`](https://github.com/C ## Common workflows ### Requesting a BLS consensus signature -![Generating and using a proxy key](../res/img/consensus-key-sign.png) +![Requesting a BLS consensus signature](../res/img/consensus-key-sign.png) ### Generating and using a proxy key ![Generating and using a proxy key](../res/img/proxy-key-sign.png) diff --git a/docs/docs/get_started/building.md b/docs/docs/get_started/building.md index 5486cb2e1..4307011df 100644 --- a/docs/docs/get_started/building.md +++ b/docs/docs/get_started/building.md @@ -150,7 +150,7 @@ The exact timestamps, request ids and commit hash will of course differ; what ma If you see that, then the PBS service works. -### Verifying the Signer Module +### Verifying the Signer Service To verify the Signer service works, create [a TOML configuration](./configuration.md) for the Signer module (e.g., `cb-config.toml`). We'll use the example in the PBS section above. diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index 9081fff24..668c45efe 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -490,7 +490,7 @@ jwt_auth_fail_timeout_seconds = 300 # The time window in seconds The rate limit is applied to the IP address of the client making the request. By default, the IP is extracted directly from the TCP connection. If you're running the Signer service behind a reverse proxy (e.g. Nginx), you can configure it to extract the IP from a custom HTTP header instead. There're two options: -- unique: Provides an HTTP header that contains the IP. This header is expected to appear only once in the request. This is common when using `X-Real-IP`, `True-Client-IP`, etc. If a request has multiple values for this header, it will be considered invalid and rejected. +- `unique`: Provides an HTTP header that contains the IP. This header is expected to appear only once in the request. This is common when using `X-Real-IP`, `True-Client-IP`, etc. If a request has multiple values for this header, it will be considered invalid and rejected. - `rightmost`: Provides an HTTP header that contains a comma-separated list of IPs. The nth rightmost IP in the list is used. If the header appears multiple times, the last occurrence is used. This is common when using `X-Forwarded-For`. Examples: diff --git a/docs/docs/get_started/overview.md b/docs/docs/get_started/overview.md index 55c3fc34f..58cc98765 100644 --- a/docs/docs/get_started/overview.md +++ b/docs/docs/get_started/overview.md @@ -2,7 +2,7 @@ description: Initial setup --- -# Overview +# Getting started Commit-Boost is primarily based on [Docker](https://www.docker.com/) to enable modularity, sandboxing and cross-platform compatibility. It is also possible to run Commit-Boost [natively](./running/binary.md) without Docker. diff --git a/docs/docs/get_started/running/binary.md b/docs/docs/get_started/running/binary.md index f65d598aa..b0788b1cb 100644 --- a/docs/docs/get_started/running/binary.md +++ b/docs/docs/get_started/running/binary.md @@ -36,11 +36,11 @@ Services need environment variables to work correctly. - `CB_SIGNER_TLS_CERTIFICATES`: path to the TLS certificates for the server. - For loading keys we currently support: - `CB_SIGNER_LOADER_FILE`: path to a `.json` with plaintext keys (for testing purposes only). - - `CB_SIGNER_LOADER_KEYS_DIR` and `CB_SIGNER_LOADER_SECRETS_DIR`: paths to the `keys` and `secrets` directories or files (ERC-2335 style keystores, see [Signer config](../configuration/#signer-service) for more info). + - `CB_SIGNER_LOADER_KEYS_DIR` and `CB_SIGNER_LOADER_SECRETS_DIR`: paths to the `keys` and `secrets` directories or files (ERC-2335 style keystores, see [Signer config](../configuration.md#signer-service) for more info). - For storing proxy keys we currently support: - `CB_PROXY_STORE_DIR`: directory where proxy keys and delegations will be saved in plaintext (for testing purposes only). - - `CB_PROXY_KEYS_DIR` and `CB_PROXY_SECRETS_DIR`: paths to the `keys` and `secrets` directories or files (ERC-2335 style keystores, see [Proxy keys store](../configuration/#proxy-keys-store) for more info). -- For Dirk remote signer the following envs are available (see [Dirk config](../configuration/#dirk) for more info): + - `CB_PROXY_KEYS_DIR` and `CB_PROXY_SECRETS_DIR`: paths to the `keys` and `secrets` directories or files (ERC-2335 style keystores, see [Proxy keys store](../configuration.md#proxy-keys-store) for more info). +- For Dirk remote signer the following envs are available (see [Dirk config](../configuration.md#dirk) for more info): - `CB_SIGNER_DIRK_CERT_FILE`: optional, override of the `cert_path` in the `[signer.dirk]` config, path to the client certificate file. - `CB_SIGNER_DIRK_KEY_FILE`: optional, override of the `key_path` in the `[signer.dirk]` config, path to the client key file. - `CB_SIGNER_DIRK_SECRETS_DIR`: optional, override of the `secrets_path` in the `[signer.dirk]` config, path to the secrets directory. diff --git a/docs/docs/get_started/troubleshooting.md b/docs/docs/get_started/troubleshooting.md index 36b81081f..125c824e5 100644 --- a/docs/docs/get_started/troubleshooting.md +++ b/docs/docs/get_started/troubleshooting.md @@ -203,9 +203,11 @@ If the request itself fails (rather than simply yielding no bids), PBS instead r --- +## Expected healthy logs + If you started the modules correctly you should see the following logs. -## PBS +### PBS After the service started correctly you should see: ```bash diff --git a/docs/docs/overview.md b/docs/docs/overview.md index 5005bda89..4c38943a1 100644 --- a/docs/docs/overview.md +++ b/docs/docs/overview.md @@ -2,7 +2,7 @@ sidebar_position: 2 --- -# Overview +# Why Commit-Boost ## Background - Proposer commitments have been an important part of Ethereum’s history. Today, we already see the power of commitments where over 90% of validators give up their autonomy and make a wholesale commitment that outsources block building to a sophisticated actor called a block builder. From d6e937812458c0d2568919333158667b0165f1ba Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 11 Aug 2026 16:58:52 -0700 Subject: [PATCH 07/21] docs: make building.md the canonical build guide Wire building.md into the sidebar (it was orphaned), fix its dead `git checkout stable` (stable is retired) to the release-commit pattern, and replace the duplicated build steps in the getting-started overview with a pointer, so there is one build guide instead of two diverging copies. --- docs/docs/get_started/building.md | 5 ++- docs/docs/get_started/overview.md | 62 +------------------------------ docs/sidebars.js | 1 + 3 files changed, 6 insertions(+), 62 deletions(-) diff --git a/docs/docs/get_started/building.md b/docs/docs/get_started/building.md index 4307011df..5054d7f62 100644 --- a/docs/docs/get_started/building.md +++ b/docs/docs/get_started/building.md @@ -66,10 +66,11 @@ With the prerequisites set up, pull the repository: git clone https://github.com/Commit-Boost/commit-boost-client ``` -Check out the `stable` branch which houses the latest release: +Check out the release you want to build. Each release is pinned in `.releases/` +(e.g. `.releases/v0.10.0.yml` names its `commit:`); check out that commit: ```bash -cd commit-boost-client && git checkout stable +cd commit-boost-client && git checkout ``` Finally, update the submodules: diff --git a/docs/docs/get_started/overview.md b/docs/docs/get_started/overview.md index 58cc98765..ac096b629 100644 --- a/docs/docs/get_started/overview.md +++ b/docs/docs/get_started/overview.md @@ -24,64 +24,6 @@ Find the latest releases at https://github.com/Commit-Boost/commit-boost-client/ The services are also published at [each release](https://github.com/orgs/Commit-Boost/packages?repo_name=commit-boost-client). -### From source +## Build from source -Requirements: - -- Rust 1.91 - -:::note -Run `rustup update` to update Rust and Cargo to the latest version -::: - -```bash -# Pull the repo -git clone https://github.com/Commit-Boost/commit-boost-client - -# Enter the repo -cd commit-boost-client - -# Init submodules -git submodule update --init --recursive -``` - -:::note -If you get an `openssl` related error try running: `apt-get update && apt-get install -y openssl ca-certificates libssl3 libssl-dev build-essential pkg-config` -::: - -Each Commit-Boost release commit is located as a versioned file in the `.releases` folder. For example `.releases/v0.10.0.yml` contains: -```yml -commit: "eeff25750c01f4adfc95fc08d69d541ace8e4087" -reason: "Final release including rc1-rc4 changes" -``` - -To locally build that release version, checkout the commit: - -```bash -# Switch to the specific release -git checkout eeff25750c01f4adfc95fc08d69d541ace8e4087 - -# Build the binary -just build-bin $(git rev-parse --short HEAD) -``` - -The binary will be stored in `build//`, for example `build/eeff257/linux_amd64/`: - -You can confirm the binary was built successfully by navigating to the build directory and checking its version: -```bash -./commit-boost --version -``` - -### Docker - -Building the service images requires the binary to be built using the above instructions first, since it will be copied into those images. The `build-all` command compiles the binary and then creates the image in one step: - -```bash -# Switch to the specific release -git checkout eeff25750c01f4adfc95fc08d69d541ace8e4087 - -# Build the binary and create the image -just build-all $(git rev-parse --short HEAD) -``` - -This will create a local image called `commit-boost/commit-boost:` that can be used to run the PBS and Signer services, as well as the CLI. Make sure to use this image in the `docker_image` field in the `[pbs]` and `[signer]` sections of the `.toml` config file. +To build the binary and Docker images yourself, see [Building from source](./building.md). diff --git a/docs/sidebars.js b/docs/sidebars.js index a05bfc0b8..3fcef6cfc 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -29,6 +29,7 @@ const sidebars = { collapsed: false, items: [ 'get_started/overview', + 'get_started/building', 'get_started/configuration', 'get_started/mux-key-loaders', { From 8ce7482ca98c1ad83a0f43ab1cc684a6ee7b3fd4 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 11:06:27 -0700 Subject: [PATCH 08/21] Create AGENTS.md --- AGENTS.md | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8f81a9b70 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,166 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## What Commit-Boost is + +Commit-Boost is an Ethereum validator sidecar that standardizes how proposers make commitments +to third-party protocols, MEV-boost-style block building being the primary one. It is a Rust +workspace whose release artifact is the `commit-boost` binary, one subcommand per service, run +either as generated Docker services or as native processes. + +The services cooperate, wired together by one TOML config file: + +- **PBS service**: implements the Builder API toward the consensus client (header retrieval, + blinded-block submission, validator registration, status) and fans requests out to the + configured relays, applying selection, validation, and timing logic. Custom PBS builds can + replace the request/response logic while reusing the service scaffolding. +- **Signer service**: holds validator (consensus) keys and module-requested proxy keys, + generated behind the signer boundary. It exposes an authenticated HTTP API for signing + commitment data and never releases private keys; modules receive signatures and signed + delegation objects only. +- **Commit modules**: separate processes, first- or third-party, that implement commitment + protocols. They consume the SDK (the workspace's prelude library) to load config, talk to + the signer, and register metrics. Modules authenticate to the signer with per-module + pre-shared HMAC secrets from which short-lived JWTs are minted; separate admin credentials + protect the signer's administrative endpoints. + +`commit-boost init` reads the TOML config and generates a Docker Compose setup (services, +networks, an env file carrying generated secrets); each service also runs directly via its own +subcommand, with configuration passed through `CB_*` environment variables. Every service +exposes Prometheus metrics. + +## Repository map + +The authoritative workspace layout is `[workspace].members` in the root `Cargo.toml`. Roles: + +- `bin/`: the `commit-boost` binary and the prelude library that modules import. +- `crates/`: the library crates. Shared config, types, signing, and wire formats live in the + crate every other crate depends on (`common`); service logic lives in per-service crates; + compose generation lives in the CLI crate. +- `tests/`: the workspace integration suite (mock relays, validators, signer service). + Service-behavior regression tests go here; `just test` runs them. +- `benches/`: microbenchmarks and a Docker-based PBS load-benchmark harness (see the + justfile bench recipes). +- `examples/`: runnable module examples and config presets; docs snippets mirror these, so + changing an example implies checking the docs that quote it. +- `provisioning/`: Dockerfiles, the build container, the Helm chart, Grafana dashboards. +- `docs/`: the Docusaurus site. +- `api/`: the signer OpenAPI spec. The docs' API page renders it with SwaggerUI fetching the + main-branch copy from GitHub raw at page load, so spec changes are user-visible on merge and + the docs build does not validate the spec; unreleased behavior needs its marker inside the + spec's own description text. +- `.releases/`: one YAML per release naming the released commit; the anchor for every + version-line question (see Ground truth). +- `justfile`: the canonical developer commands; prefer its recipes over hand-rolled + invocations. + +## Working on the code + +- Format with `just fmt`, lint with `just clippy`, run the suite with `just test` (the + justfile pins its own toolchain and mirrors CI's lint invocation; the recipes are the source + of truth). Building requires `protoc`; `just install-protoc` provides it. `just build-bin + ` builds through the Docker build container, not the local toolchain; a plain local + build is `cargo build --release`. +- Match the existing code style: comments are minimal and state constraints, not narration; + error-binding names differ per crate, so follow the surrounding crate; struct field names + match wire names. +- Config keys are user-facing API. Only relay entries reject unknown keys (`[[relays]]`, + including relay entries inside `[[mux]]`); every other config section silently ignores + them, so a typo or an unreleased key outside a + relay entry does nothing rather than failing. Adding, renaming, or defaulting a key is a + compatibility decision, and `config.example.toml` plus the owning docs page change in the + same commit. +- Wire-visible strings (error bodies, log lines users are told to grep for, metric names and + labels) are documented surface: changing one means updating the docs pages and OpenAPI specs + that quote it. + +## Ground truth + +- The code is the source of truth. A documentation claim is wrong until the code proves it; + when reviewing or writing docs, ground every behavioral claim in a specific code location. +- Two lines always exist: the **released line** and **main**. The released commit is the + `commit:` field of the semver-newest file in `.releases/` (file names are exact tags, + including `-rcN` pre-releases; order by semantic version, never lexically or by mtime, since + `v0.9.*` sorts after `v0.10.*` as a string; naming rules in `.releases/README.md`). The docs + voice tracks the newest stable (non-rc) release. Release pins live on release-branch + lineages and are generally **not ancestors of main**, so never use `git merge-base + --is-ancestor` to decide whether a feature is released. Probe content instead: + `git show :`. +- While the docs site is unversioned (no `versioned_docs/` under `docs/`), one tree serves + users of the released binaries, so docs speak in the **released version's voice**. Behavior + that exists only on main is marked with a `:::info Unreleased` admonition (for sections) or + an inline marker naming the first version that will ship it, `(unreleased, from vX.Y)`, + stated once and tersely. When that release ships, deleting its markers is the whole docs + update. If the site gains versioned docs, per-version trees supersede this policy. +- `config.example.toml` is copied verbatim by users of released binaries. Unreleased keys + appear there **commented out**, with the unreleased marker in the comment: an uncommented + unreleased key either breaks released binaries at startup (inside relay entries) or is + silently ignored (everywhere else), and both mislead. +- Example and log values must be reproducible by a real binary: version strings exactly as + binaries self-report them, commit hashes equal to the release commit (annotated tag objects + are never printable by any build), timestamps/slots/block numbers arithmetically consistent + with each other, placeholder hosts from RFC 2606 (`example.com`), `https://` schemes, and + secrets shown truncated and obviously non-functional. + +## Writing style (docs and prose) + +- Plain operator prose, American English. No em dashes. No "Note that" lead-ins. No marketing + or filler vocabulary (leverage, seamlessly, robust, powerful, comprehensive, utilize). No + sentences that announce what the reader is about to read. +- Don't explain absences. When removing content, splice the neighbors and move on; never add + text justifying why something is no longer there. +- Terminology: "PBS service", "Signer service"; "commit module(s)" in running prose ("Commit + Modules" only in headings; never hyphenated). Heading case follows the page's dominant style. +- **One home per fact.** Every contract or nuance gets one full statement on the page that + owns its topic; every other mention is a one-line pointer to that home. Before adding a + fact, find where existing mentions of the topic already point and follow them. As of + writing: `configuration.md` owns config semantics (including hot reload, TLS, rate limiting, + keystore layouts); `mux-key-loaders.md` owns mux mechanics; `running/binary.md` owns + environment variables; `metrics-catalog.md` owns per-metric reference rows; + `troubleshooting.md` owns log walkthroughs and symptom tables; `developing/*` own SDK and + module-API contracts. `config.example.toml` comments stay one line per key plus a docs link: + it is the annotated reference for values, not a second prose home. +- Long runs of bold-label bullets are hard to scan; prefer prose or a real table. + +## Verifying changes + +- Docs build gate: run the docs build the way CI does (`.github/workflows/`; today + `npm install && npm run build` in `docs/`). Broken links fail the build; broken + anchors only warn, so also grep the build output case-insensitively for "broken anchor". +- Parse every fenced `toml`/`yaml`/`json` block in changed pages, and `config.example.toml` + itself (python `tomllib` / `yaml.safe_load`). +- Spellcheck prose with code spans and fences stripped. +- Examples must run. Prefer executing a documented flow (generate the compose setup from the + documented config, perform the documented auth round-trip) over reading it; executed + walkthroughs find breaks that reading does not. Check Rust snippets against the real SDK + signatures in the workspace or the `examples/` crates they mirror. + +## Trap classes + +Recurring bug shapes in this repository. Check for the class, not just the past instance. + +- **Released-vs-main drift**: a behavior claim may hold on only one line. Any claim about wire + formats, error bodies, config keys, or metrics needs checking against both the released + commit and main; cite the code location that supports it. +- **Secret vs token**: several environment variables hold HMAC *secrets* from which + short-lived tokens are minted. Calling them tokens produces curl examples that can never + authenticate. Verify what the receiving middleware actually validates before documenting an + auth flow. +- **Startup-frozen environment**: environment variables are read at process start; reload + endpoints cannot observe new values. Verify the actual data flow before documenting any + hot-rotation or reload pattern, and state what a reload can and cannot pick up. +- **Silently ignored config**: outside relay entries, unknown config keys are accepted and + dropped, so a misplaced or misspelled key looks configured while doing nothing. Verify a key + is consumed at the nesting level where the docs place it. +- **Dead knobs**: config fields, chart values, or flags consumed by no code path (Helm + templates are especially prone). Verify each documented option is actually read before + listing it. +- **Documented intent, not behavior**: comments and older docs sometimes describe features + that were planned but never implemented. Trust only the code path you can cite. + +## Maintenance of this file + +This file holds only orientation, conventions, methods, and trap classes, so it stays valid as +features are added. If an edit records a fact about a specific version or feature, that fact +belongs in the docs themselves, a code comment, or the issue tracker instead. From 1757b9f4748ceaa09f48c43d7375987415548007 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 11:17:09 -0700 Subject: [PATCH 09/21] typos + service<>module clarifiers --- docs/docs/architecture/overview.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/architecture/overview.md b/docs/docs/architecture/overview.md index 67089afed..c00e09a2b 100644 --- a/docs/docs/architecture/overview.md +++ b/docs/docs/architecture/overview.md @@ -4,12 +4,12 @@ description: Overview of the architecture of Commit-Boost # Overview -Below is schematic overview of Commit-Boost. +Below is a schematic overview of Commit-Boost. Commit-Boost runs as a single sidecar composed of multiple modules: -- PBS Service with the [BuilderAPI](https://ethereum.github.io/builder-specs/) for [MEV Boost](https://docs.flashbots.net/flashbots-mev-boost/architecture-overview/specifications) -- A Signer Service implementing the SignerAPI -- Commit Modules that implement some custom commit protocol logic +- A PBS service with the [BuilderAPI](https://ethereum.github.io/builder-specs/) for [MEV Boost](https://docs.flashbots.net/flashbots-mev-boost/architecture-overview/specifications) +- A Signer service implementing the SignerAPI +- Commit modules that implement some custom commit protocol logic - Telemetry modules like Prometheus and Grafana ![architecture](./img/architecture.png) From 6c9ab0a54787132f875e40b9bd95cc6b31c21990 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 11:27:21 -0700 Subject: [PATCH 10/21] styling, code snippet compilation, clarifications --- docs/docs/developing/commit-modules.md | 52 ++++++++++++++------------ 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/docs/docs/developing/commit-modules.md b/docs/docs/developing/commit-modules.md index 9913ecb35..bf01271f8 100644 --- a/docs/docs/developing/commit-modules.md +++ b/docs/docs/developing/commit-modules.md @@ -4,7 +4,7 @@ sidebar_position: 1 # Commit Modules -Commit-Boost provides an open platform for developers to create and distribute commitment protocol sidecars. **Commit Modules** are the primary way to add custom logic — they run as sidecar processes alongside the PBS and Signer services, and can request signatures from the proposer. +Commit-Boost provides an open platform for developers to create and distribute commitment protocol sidecars. **Commit Modules** are the primary way to add custom logic: they run as sidecar processes alongside the PBS and Signer services, and can request signatures from the proposer. > **For system context**, see the [Architecture Overview](../architecture/overview.md). @@ -23,7 +23,7 @@ signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b | Field | Description | |---|---| | `id` | A unique identifier for the module (used for JWT scoping and container naming). | -| `type` | **Must be `"commit"`.** This is the only valid value. | +| `type` | Must be `"commit"`. | | `docker_image` | The Docker image to run for this module. | | `signing_id` | A 32-byte identifier used to scope signatures to this module (see [Signing structure](#signing-structure)). | | `env` | Optional map of environment variables for the module. | @@ -60,18 +60,18 @@ struct ExtraConfig { sleep_secs: u64, } -let config = load_commit_module_config::().unwrap(); +let mut config = load_commit_module_config::().unwrap(); let to_sleep = config.extra.sleep_secs; ``` The returned `StartCommitModuleConfig` also provides: -- `id` — unique module ID -- `chain` — chain spec -- `signer_client` — a pre-configured `SignerClient` to call the [SignerAPI](/api) +- `id`: unique module ID +- `chain`: chain spec +- `signer_client`: a pre-configured `SignerClient` to call the [SignerAPI](/api) ### Requesting signatures -At its core, the Signer Service provides a signature on a 32-byte data digest. Signatures are provided using either the validator keys (BLS) or a proxy key (BLS or ECDSA), both on the [Commit-Boost domain](#signing-structure). +At its core, the Signer service provides a signature on a 32-byte data digest. Signatures are provided using either the validator keys (BLS) or a proxy key (BLS or ECDSA), both on the [Commit-Boost domain](#signing-structure). Use `TreeHash` to create a digest from a custom struct: @@ -82,23 +82,25 @@ struct Datagram { } ``` -To request a signature, you need a public key. Get available keys: +To request a signature, you need a public key. `get_pubkeys` returns a `GetPubkeysResponse` whose `keys` field maps each consensus key to its proxy keys; pick the consensus pubkey to sign with: ```rust let pubkeys = config.signer_client.get_pubkeys().await.unwrap(); +let pubkey = pubkeys.keys.first().unwrap().consensus.clone(); ``` -JWT tokens are created and refreshed internally by `SignerClient` — each method generates a fresh token with the correct `route`, `exp`, and `payload_hash` claims automatically. No manual token management is needed. +JWT tokens are created and refreshed internally by `SignerClient`: each method generates a fresh token with the correct `route`, `exp`, and `payload_hash` claims automatically. No manual token management is needed. #### Consensus key signatures ```rust let datagram = Datagram { data: 1 }; -let request = SignConsensusRequest::builder(pubkey).with_msg(&datagram); -let signature = config.signer_client.request_consensus_signature(request).await.unwrap(); +let request = SignConsensusRequest::builder(pubkey.clone()).with_msg(&datagram); +let response = config.signer_client.request_consensus_signature(request).await.unwrap(); +let signature = response.signature; ``` -Where `pubkey` is the validator (consensus) public key. +The response also carries the `nonce` and `module_signing_id` needed to verify the signature. #### Proxy key signatures @@ -106,11 +108,11 @@ First, generate a proxy key for a given consensus key. We support BLS and ECDSA: ```rust // BLS proxy -let proxy_delegation = config.signer_client.generate_proxy_key_bls(pubkey).await?; +let proxy_delegation = config.signer_client.generate_proxy_key_bls(pubkey.clone()).await?; let proxy_pubkey = proxy_delegation.message.proxy; // ECDSA proxy -let proxy_delegation = config.signer_client.generate_proxy_key_ecdsa(pubkey).await?; +let proxy_delegation = config.signer_client.generate_proxy_key_ecdsa(pubkey.clone()).await?; let proxy_address = proxy_delegation.message.proxy; ``` @@ -120,17 +122,19 @@ Then request a signature using the proxy key: // BLS proxy let datagram = Datagram { data: 1 }; let request = SignProxyRequest::builder(proxy_pubkey).with_msg(&datagram); -let signature = config.signer_client.request_proxy_signature_bls(request).await.unwrap(); +let response = config.signer_client.request_proxy_signature_bls(request).await.unwrap(); +let signature = response.signature; // ECDSA proxy let datagram = Datagram { data: 1 }; let request = SignProxyRequest::builder(proxy_address).with_msg(&datagram); -let signature = config.signer_client.request_proxy_signature_ecdsa(request).await.unwrap(); +let response = config.signer_client.request_proxy_signature_ecdsa(request).await.unwrap(); +let signature = response.signature; ``` ### Signing structure -For details on the signing structure — including domain separation, nonces, SSZ Merkle tree construction, and the signing ID format — see [Requesting Proposer Commitment Signatures](./prop-commit-signing.md). +For details on the signing structure, including domain separation, nonces, SSZ Merkle tree construction, and the signing ID format, see [Requesting Proposer Commitment Signatures](./prop-commit-signing.md). ## Metrics @@ -138,21 +142,23 @@ Modules can record custom metrics that are automatically scraped by Prometheus. ### Define metrics -Use the `prometheus` crate: +Use the `prometheus` crate, with the statics wrapped in `lazy_static!` (from the `lazy_static` crate): ```rust -static ref MY_CUSTOM_REGISTRY: Registry = Registry::new_custom(Some("da_commit".to_string()), None).unwrap(); -static ref SIG_RECEIVED_COUNTER: IntCounter = IntCounter::new("signature_received", "successful signature requests received").unwrap(); +lazy_static! { + static ref MY_CUSTOM_REGISTRY: Registry = Registry::new_custom(Some("da_commit".to_string()), None).unwrap(); + static ref SIG_RECEIVED_COUNTER: IntCounter = IntCounter::new("signature_received", "successful signature requests received").unwrap(); +} ``` ### Start the metrics provider ```rust MY_CUSTOM_REGISTRY.register(Box::new(SIG_RECEIVED_COUNTER.clone())).unwrap(); -MetricsProvider::load_and_run(config.chain, MY_CUSTOM_REGISTRY.clone()); +MetricsProvider::load_and_run(config.chain, MY_CUSTOM_REGISTRY.clone()).unwrap(); ``` -This starts a server with a `/metrics` endpoint on the port set by the `CB_METRICS_PORT` env var (assigned from `[metrics].start_port`, default `10000`, by `commit-boost init`). +This starts a server with a `/metrics` endpoint on the port from `CB_METRICS_PORT` (see [Running with binary](../get_started/running/binary.md)). ### Record metrics @@ -160,4 +166,4 @@ This starts a server with a `/metrics` endpoint on the port set by the `CB_METRI SIG_RECEIVED_COUNTER.inc(); ``` -For a full reference of available metrics, see the [Metrics catalog](../get_started/running/metrics-catalog.md). The Prometheus scrape target is already configured by the docker-init setup. +For a full reference of available metrics, see the [Metrics catalog](../get_started/running/metrics-catalog.md). When `[metrics]` is enabled, `commit-boost init` prints the scrape targets for each service; add them to your own Prometheus config (see [Metrics](../get_started/running/metrics.md)). From 2897b2b2b65bd9a1ed69b4f6d8491173e85acc28 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 11:28:50 -0700 Subject: [PATCH 11/21] styling, clarifications --- docs/docs/developing/extending-pbs.md | 36 ++++++++++++--------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/docs/docs/developing/extending-pbs.md b/docs/docs/developing/extending-pbs.md index 72ef13045..07b787b3b 100644 --- a/docs/docs/developing/extending-pbs.md +++ b/docs/docs/developing/extending-pbs.md @@ -4,18 +4,16 @@ sidebar_position: 2 # Extending PBS -The PBS binary that ships with Commit-Boost can be extended with custom logic. This is **not** a config-level module declaration like commit modules — instead you replace the PBS binary entirely by implementing the `BuilderApi` trait (the default implementation is the `DefaultBuilderApi` struct). +The PBS binary that ships with Commit-Boost can be extended with custom logic. This is **not** a config-level module declaration like commit modules: you replace the PBS binary entirely by implementing the `BuilderApi` trait (the default implementation is the `DefaultBuilderApi` struct). For system context on how the PBS service fits into the Commit-Boost architecture, see the [Architecture Overview](../architecture/overview.md). ## Before you extend PBS | You want to... | Use... | |---|---| -| Request signatures from the proposer's validator keys (BLS/ECDSA) | [Commit Module](./commit-modules.md) — runs as a sidecar alongside PBS | -| Add custom constraints to `get_header`, `submit_block`, or other BuilderAPI methods | Extend PBS — implement `BuilderApi` on your own struct | -| Run custom logic that triggers on each slot but does not modify relay interaction | Commit Module — cheaper to maintain and deploy independently | -| Add new HTTP routes alongside the standard BuilderAPI | Extend PBS — implement `extra_routes()` on your custom `BuilderApi` | - -**Rule of thumb:** if you need to change how relay responses are filtered, validated, or transformed, extend PBS. If you want to request signatures or run slot-triggered logic independently, write a Commit Module. +| Request signatures from the proposer's validator keys (BLS) or proxy keys (BLS/ECDSA) | [Commit module](./commit-modules.md): runs as a sidecar alongside PBS | +| Add custom constraints to `get_header`, `submit_block`, or other BuilderAPI methods | Extend PBS: implement `BuilderApi` on your own struct | +| Run custom logic that triggers on each slot but does not modify relay interaction | Commit module: cheaper to maintain and deploy independently | +| Add new HTTP routes alongside the standard BuilderAPI | Extend PBS: implement `extra_routes()` on your custom `BuilderApi` | ## How it works @@ -23,12 +21,12 @@ The PBS binary ships with the [`DefaultBuilderApi`](https://github.com/Commit-Bo The trait covers: -- `get_header` — fetch the best header from relays -- `get_status` — check relay health -- `submit_block` — publish blinded blocks -- `register_validator` — register validators with relays -- `reload` — hot-reload configuration -- `extra_routes` — add custom HTTP endpoints +- `get_header`: fetch the best header from relays +- `get_status`: check relay health +- `submit_block`: publish blinded blocks +- `register_validator`: register validators with relays +- `reload`: hot-reload configuration +- `extra_routes`: add custom HTTP endpoints PBS serves the submit-block route on both `POST /eth/v1/builder/blinded_blocks` and `POST /eth/v2/builder/blinded_blocks`; both are handled by the same `submit_block` method, which receives an `api_version: BuilderApiVersion` parameter (`V1` or `V2`) telling it which route was called. @@ -41,7 +39,7 @@ use commit_boost::prelude::*; get_status(req_headers, state).await ``` -Note that the default `reload` handler is not re-exported in the prelude, so a `reload` override must rebuild its state itself. +The default `reload` handler is not re-exported in the prelude, so a `reload` override must rebuild its state itself. ### Reference example @@ -50,7 +48,7 @@ See [`examples/status_api/`](https://github.com/Commit-Boost/commit-boost-client 1. Defines a custom `ExtraConfig` struct with additional TOML fields (`inc_amount`). 2. Creates a custom `BuilderApiState` (`MyBuilderState`) to hold runtime state. 3. Implements `BuilderApi` that overrides `get_status` with custom logging and a counter, and adds a `/check` route via `extra_routes()`. -4. Loads config with `load_pbs_custom_config::()` and starts the service with `PbsService::run::<_, MyBuilderApi>(state)`. +4. Loads config with `load_pbs_custom_config::()` and starts the service with `PbsService::run::(state)`. ## Building and running a custom PBS binary @@ -62,6 +60,8 @@ Add the `commit-boost` crate to your `Cargo.toml`: commit-boost = { git = "https://github.com/Commit-Boost/commit-boost-client", version = "..." } ``` +The snippets below also need `serde` (with the `derive` feature) and `async-trait` in `Cargo.toml`, since the prelude does not re-export them; import them with `use serde::Deserialize;` and `use async_trait::async_trait;`. + ### Entry point Your `main.rs` should: @@ -102,7 +102,7 @@ use std::path::PathBuf; let (pbs_config, extra) = load_pbs_custom_config::().await?; // The second argument is the path PBS watches for config hot-reloads. -// An empty path disables the watcher — see the note below. +// An empty path disables the watcher; see the note below. let config_path = PathBuf::new(); let state = PbsState::new(pbs_config, config_path).with_data(MyBuilderState::from_config(extra)); @@ -122,7 +122,3 @@ To get the same auto-reload behavior as the stock PBS binary, pass the real path ### Running Compile and run your binary. Set the same environment variables as the default PBS (see [Running with binary](../get_started/running/binary.md)). Your custom PBS handles the same BuilderAPI endpoints plus any extra routes you added. - -## Cross-reference - -For system context on how PBS fits into the Commit-Boost architecture, see [Architecture Overview](../architecture/overview.md). From ce84720d461f238910e72023d417cf2b3236fba2 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 11:30:17 -0700 Subject: [PATCH 12/21] styling, clarifications --- docs/docs/developing/prop-commit-signing.md | 83 +++++++++++---------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/docs/docs/developing/prop-commit-signing.md b/docs/docs/developing/prop-commit-signing.md index d8fd00c49..44b5792c9 100644 --- a/docs/docs/developing/prop-commit-signing.md +++ b/docs/docs/developing/prop-commit-signing.md @@ -1,55 +1,55 @@ -# Requesting Proposer Commitment Signatures with Commit-Boost +# Requesting proposer commitment signatures with Commit-Boost When you create a new validator on the Ethereum network, one of the steps is the generation of a new BLS private key (commonly known as the "validator key" or the "signer key") and its corresponding BLS public key (the "validator pubkey", used as an identifier). Typically this private key will be used by an Ethereum consensus client to sign things such as attestations and blocks for publication on the Beacon chain. These signatures prove that you, as the owner of that private key, approve of the data being signed. However, as general-purpose private keys, they can also be used to sign *other* arbitrary messages not destined for the Beacon chain. -Commit-Boost takes advantage of this by offering a standard known as **proposer commitments**. These are arbitrary messages (albeit with some important rules), similar to the kind used on the Beacon chain, that have been signed by one of the owner's private keys. Modules interested in leveraging Commit-Boost's proposer commitments can construct their own data in whatever format they like and request that Commit-Boost's **signer service** generate a signature for it with a particular private key. The module can then use that signature to verify the data was signed by that user. +Commit-Boost takes advantage of this by offering a standard known as **proposer commitments**. These are arbitrary messages (albeit with some important rules), similar to the kind used on the Beacon chain, that have been signed by one of the owner's private keys. Modules that use Commit-Boost's proposer commitments can construct their own data in whatever format they like and request that Commit-Boost's **Signer service** generate a signature for it with a particular private key. The module can then use that signature to verify the data was signed by that user. Commit-Boost supports proposer commitment signatures for both BLS private keys (identified by their public key) and ECDSA private keys (identified by their Ethereum address). -## Rules of Proposer Commitment Signatures +## Rules of proposer commitment signatures -Proposer commitment signatures produced by Commit-Boost's signer service conform to the following rules: +Proposer commitment signatures produced by Commit-Boost's Signer service conform to the following rules: - Signatures are **unique** to a given EVM chain (identified by its [chain ID](https://chainlist.org/)). Signatures generated for one chain will not work on a different chain. -- Signatures are **unique** to Commit-Boost proposer commitments. The signer service **cannot** be used to create signatures that could be used for other applications, such as for attestations on the Beacon chain. While the signer service has access to the same validator private keys used to attest on the Beacon chain, it cannot create signatures that would get you slashed on the Beacon chain. -- Signatures are **unique** to a particular module. One module cannot, for example, request an identical payload as another module and effectively "forge" a signature for the second module; identical payloads from two separate modules will result in two separate signatures. -- The data payload being signed must be a **32-byte array**, typically serialized as a 64-character hex string with an optional `0x` prefix. The value itself is arbitrary, as long as it has meaning to the requester - though it is typically the 256-bit hash of some kind of data. +- Signatures are **unique** to Commit-Boost proposer commitments. The Signer service **cannot** be used to create signatures that could be used for other applications, such as for attestations on the Beacon chain. While the Signer service has access to the same validator private keys used to attest on the Beacon chain, it cannot create signatures that would get you slashed on the Beacon chain. +- Signatures are **unique** to a particular module; identical payloads from two modules produce two different signatures (see [The signing ID](#the-signing-id)). +- The data payload being signed must be a **32-byte array**, typically serialized as a 64-character hex string with an optional `0x` prefix. The value itself is arbitrary, as long as it has meaning to the requester, though it is typically the 256-bit hash of some kind of data. - If requesting a signature from a BLS key, the resulting signature will be a standard BLS signature (96 bytes in length). - If requesting a signature from an ECDSA key, the resulting signature will be a standard Ethereum RSV signature (65 bytes in length). -- Signatures **may** be **unique** per request, using the required `nonce` field in their requests (send `0` if unused) to indicate a unique sequence that this signature belongs to. +- The `nonce` field can make signatures unique per request (see [Nonces](#nonces)). -## Configuring a Module for Proposer Commitments +## Configuring a module for proposer commitments -Commit-Boost's signer service must be configured prior to launching to expect requests from your module. There are two main parts: +Commit-Boost's Signer service must be configured prior to launching to expect requests from your module. There are two main parts: 1. An entry for your module into [Commit-Boost's configuration file](../get_started/configuration.md#custom-module). This must include a unique ID for your module, the line `type = "commit"`, and include a unique [signing ID](#the-signing-id) for your module. Generally you should provide values for these in your documentation, so your users can reference it when configuring their own Commit-Boost node. -2. A JWT secret used by your module to authenticate with the signer in HTTP requests. This must be a string that both the Commit-Boost signer can read and your module can read, but no other modules should be allowed to access it. The user should be responsible for determining an appropriate secret and providing it to the Commit-Boost signer service securely; your module will need some way to accept this, typically via a command line argument that accepts a path to a file with the secret or as an environment variable. +2. A JWT secret used by your module to authenticate with the signer in HTTP requests. This must be a string that both the Commit-Boost signer can read and your module can read, but no other modules should be allowed to access it. The user should be responsible for determining an appropriate secret and providing it to the Commit-Boost Signer service securely; your module will need some way to accept this, typically via a command line argument that accepts a path to a file with the secret or as an environment variable. -Once the user has configured both Commit-Boost and your module with these settings, your module will be able to authenticate with the signer service and request signatures. +Once the user has configured both Commit-Boost and your module with these settings, your module will be able to authenticate with the Signer service and request signatures. -## The Signing ID +## The signing ID Your module's signing ID is a 32-byte value that is used as a unique identifier within the signing process. Proposer commitment signatures incorporate this value along with the data being signed as a way to create signatures that are exclusive to your module, so other modules can't maliciously construct signatures that appear to be from your module. Your module must have this ID incorporated into itself ahead of time, and the user must include this same ID within their Commit-Boost configuration file section for your module. Commit-Boost does not maintain a global registry of signing IDs, so this is a value you should provide to your users in your documentation. -The Signing ID is decoupled from your module's human-readable name (the `module_id` field in the Commit-Boost configuration file) so that any changes to your module name will not invalidate signatures from previous versions. Similarly, if you don't change the module ID but *want* to invalidate previous signatures, you can modify the signing ID and it will do so. Just ensure your users are made aware of the change, so they can update it in their Commit-Boost configuration files accordingly. +The Signing ID is decoupled from your module's human-readable name (the `id` field of the `[[modules]]` entry in the Commit-Boost configuration file) so that any changes to your module name will not invalidate signatures from previous versions. Similarly, if you don't change the module ID but *want* to invalidate previous signatures, you can modify the signing ID and it will do so. Just ensure your users are made aware of the change, so they can update it in their Commit-Boost configuration files accordingly. ## Nonces -Your module has the option of using **Nonces** for each of its signature requests. Nonces are intended to be unique values that establish a sequence of signature requests, distinguishing one signature from another - even if all of their other payload information is identical. When making a request for a signature, you may include a unique nonce as part of the request; the signature will include it in its data, ensuring that things like replay attacks cannot be used for that signature. +Your module has the option of using **Nonces** for each of its signature requests. Nonces are intended to be unique values that establish a sequence of signature requests, distinguishing one signature from another, even if all of their other payload information is identical. When making a request for a signature, you may include a unique nonce as part of the request; the signature will include it in its data, ensuring that things like replay attacks cannot be used for that signature. -If you want to use them within your module, your module (or whatever remote backend system it connects to) **will be responsible** for storing, comparing, validating, and otherwise using the nonces. Commit-Boost's signer service by itself **does not** store nonces or track which ones have already been used by a given module. +If you want to use them within your module, your module (or whatever remote backend system it connects to) **will be responsible** for storing, comparing, validating, and otherwise using the nonces. Commit-Boost's Signer service by itself **does not** store nonces or track which ones have already been used by a given module. -In terms of implementation, the nonce format conforms to the specification in [EIP-2681](https://eips.ethereum.org/EIPS/eip-2681). It is an unsigned 64-bit integer, with a minimum value of 0 and a maximum value of `2^64-2`. The field is required and is always mixed into the signing root. Modules that do not use nonces for replay protection should always send `0`; modules that do should use a monotonically increasing value per key. +In terms of implementation, the nonce is an unsigned 64-bit integer. Per the convention in [EIP-2681](https://eips.ethereum.org/EIPS/eip-2681) the maximum value is `2^64-2`, though the Signer service does not enforce this cap. The field is required and is always mixed into the signing root. Modules that do not use nonces for replay protection should always send `0`; modules that do should use a monotonically increasing value per key. -## Structure of a Signature +## Structure of a signature -The form proposer commitment signatures take depends on the type of signature being requested. BLS signatures take the [standard form](https://eth2book.info/latest/part2/building_blocks/signatures/) (96-byte values). ECDSA (Ethereum EL) signatures take the [standard Ethereum ECDSA `r,s,v` signature form](https://forum.openzeppelin.com/t/sign-it-like-you-mean-it-creating-and-verifying-ethereum-signatures/697). In both cases, the data being signed is a 32-byte hash - the root hash of a composite two-stage [SSZ Merkle tree](https://thogiti.github.io/2024/05/02/Merkleization.html), described below: +The form proposer commitment signatures take depends on the type of signature being requested. BLS signatures take the [standard form](https://eth2book.info/latest/part2/building_blocks/signatures/) (96-byte values). ECDSA (Ethereum EL) signatures take the [standard Ethereum ECDSA `r,s,v` signature form](https://forum.openzeppelin.com/t/sign-it-like-you-mean-it-creating-and-verifying-ethereum-signatures/697). In both cases, the data being signed is a 32-byte hash: the root hash of a composite two-stage [SSZ Merkle tree](https://thogiti.github.io/2024/05/02/Merkleization.html), described below: ![signature structure](../res/img/prop_commit_tree.png) @@ -58,15 +58,15 @@ where, for the sub-tree in blue: - `Request Data` is a 32-byte array that serves as the data you want to sign. This is typically a hash of some more complex data on its own that your module constructs. -- `Signing ID` is your module's 32-byte signing ID. The signer service will load this for your module from its configuration file. +- `Signing ID` is your module's 32-byte signing ID. The Signer service will load this for your module from its configuration file. -- `Nonce` is the nonce value for the signature request. This field is required. Modules that do not use replay protection should always send `0`; modules that do should use a monotonically increasing value per key. Conforming with the tree specification, it must be added as a 256-bit unsigned little-endian integer. Most libraries will be able to do this conversion automatically if you specify the field as the language's primitive for 64-bit unsigned integers (e.g., `uint64`, `u64`, `ulong`, etc.). +- `Nonce` is the request's nonce (see [Nonces](#nonces)). Conforming with the tree specification, it must be added as a 256-bit unsigned little-endian integer. Most libraries will be able to do this conversion automatically if you specify the field as the language's primitive for 64-bit unsigned integers (e.g., `uint64`, `u64`, `ulong`, etc.). - `Chain ID` is the ID of the chain that the Signer service is currently configured to use, as indicated by the [Commit-Boost configuration file](../get_started/configuration.md). This must also be a 256-bit unsigned little-endian integer. A Merkle tree must be constructed from these four leaf nodes, and its root hash calculated according to the standard SSZ hash computation rules. This result will be called the "sub-tree root". With this, a second Merkle tree is created using this sub-tree root and a value called the Domain: -- `Domain` is the 32-byte output of the [compute_domain()](https://eth2book.info/capella/part2/building_blocks/signatures/#domain-separation-and-forks) function in the Beacon specification. The 4-byte domain type in this case is not a standard Beacon domain type, but rather Commit Boost's own domain type: `0x6D6D6F43`. +- `Domain` is the 32-byte output of the [compute_domain()](https://eth2book.info/capella/part2/building_blocks/signatures/#domain-separation-and-forks) function in the Beacon specification. The 4-byte domain type in this case is not a standard Beacon domain type, but rather Commit-Boost's own domain type: `0x6D6D6F43`. The data signed in a proposer commitment is the 32-byte hash root of this new tree (the green `Root` box). @@ -76,7 +76,7 @@ Many languages provide libraries for computing the root of an SSZ Merkle tree, s ## Authentication -Every request to the Signer Service (except the health-check endpoint) must present a Bearer token in the `Authorization` header. +Every request to the Signer service (except the health-check endpoint) must present a Bearer token in the `Authorization` header. ### Module JWT @@ -91,34 +91,34 @@ Modules authenticate with a **signed JWT** using the pre-shared secret (`CB_SIGN The `payload_hash` claim prevents JWT replay attacks: a token issued for one POST request body cannot be reused with a different body on the same route. -**Token lifecycle:** Expiry is 5 minutes (`SIGNER_JWT_EXPIRATION` crate constant). Refresh is **client-side** — there is no refresh endpoint. The module generates a new JWT locally using the pre-shared secret. The SDK's `SignerClient` creates a fresh token on every request automatically. +**Token lifecycle:** Expiry is 5 minutes (`SIGNER_JWT_EXPIRATION` crate constant). Refresh is **client-side**: there is no refresh endpoint. The module generates a new JWT locally using the pre-shared secret. The SDK's `SignerClient` creates a fresh token on every request automatically. ### Admin token -Administrative endpoints (`/reload`, `/revoke_jwt`) authenticate with a **separate JWT** signed with the `CB_SIGNER_ADMIN_JWT` secret (env var). Same HS256 algorithm, includes `admin: true` in its claims. +Administrative endpoints (`/reload`, `/revoke_jwt`) authenticate with a **separate JWT** signed with the `CB_SIGNER_ADMIN_JWT` secret (env var), using the same HS256 algorithm. Its claims are the module claims minus `module`, plus an admin flag: -### Rate limiting - -The signer rate-limits by IP address. Default: **3 failed authentications within 5 minutes** locks a client out. Configurable via `[signer]` in `cb-config.toml`: +| Claim | Type | Required | Description | +|-------|------|----------|-------------| +| `admin` | boolean | always | Must be `true`. | +| `route` | string | always | The exact request path, `/reload` or `/revoke_jwt`. | +| `exp` | integer | always | UNIX timestamp for when the token expires. | +| `payload_hash` | string | always in practice | Keccak-256 hash of the JSON-encoded request body, with `0x` prefix. Both admin endpoints require a JSON body, so every admin request needs this claim. | -```toml -[signer] -jwt_auth_fail_limit = 3 -jwt_auth_fail_timeout_seconds = 300 -``` +### Rate limiting -If running behind a reverse proxy, configure the [reverse proxy header setup](../get_started/configuration.md#rate-limit) so the correct client IP is extracted. +The Signer service rate-limits failed authentications by client IP (default 3 per 5 minutes); limits and reverse-proxy IP extraction are configured in `[signer]`, see [Configuration > Rate limit](../get_started/configuration.md#rate-limit). --- -## API Quickstart +## API quickstart -Below is a walkthrough of the full Signer API flow using the Rust SDK. The `SignerClient` (returned by `load_commit_module_config`) handles JWT creation, payload hashing, and token refresh automatically — you never craft JWTs by hand. +Below is a walkthrough of the full Signer API flow using the Rust SDK. The `SignerClient` (returned by `load_commit_module_config`) handles token management for you (see [Module JWT](#module-jwt)). Besides `commit-boost`, your `Cargo.toml` needs `serde` with the `derive` feature for the config struct, `tokio` with the `macros` and `rt-multi-thread` features for the async runtime, and `eyre` for the error type. ```rust use commit_boost::prelude::*; +use serde::Deserialize; -// 1. Load the module config — this gives you a pre-configured SignerClient +// 1. Load the module config; this gives you a pre-configured SignerClient #[derive(Debug, Deserialize)] struct ExtraConfig { /* your module's custom fields */ } @@ -164,6 +164,8 @@ For a complete working example, see [`examples/da_commit/`](https://github.com/C ### Generating and using a proxy key ![Generating and using a proxy key](../res/img/proxy-key-sign.png) +The proxy private key never leaves the signer, despite the diagram's "store proxy key securely" note. The module receives only the `SignedProxyDelegation` (the delegator and proxy identifiers plus the delegation signature) and stores that, not the key. + :::tip ECDSA proxy signing with Dirk ECDSA proxy signing is not available when the signer is using the Dirk backend. Dirk only supports BLS operations. ::: @@ -176,9 +178,10 @@ All error responses return a plain-text body with a human-readable description o | HTTP Status | Meaning | |-------------|---------| -| `400` | Malformed request body, invalid pubkey format, missing signing ID, or operation not supported by current backend (e.g. ECDSA proxy with Dirk). | -| `401` | Missing or invalid JWT. Token may be expired, signed with wrong secret, or missing required claims. | +| `400` | Missing or malformed `Authorization` header, unreadable or oversized request body, missing signing ID, or operation not supported by the current backend (e.g. ECDSA proxy with Dirk). | +| `401` | Invalid JWT: expired, signed with the wrong secret, or claims that do not match the request. A missing or malformed `Authorization` header returns `400` instead. | | `404` | Requested consensus signer, proxy signer, or module ID does not exist. | -| `429` | Too many failed authentication attempts — retry after the timeout period. | +| `422` | Request body failed deserialization, e.g. a pubkey that is not valid hex of the expected length. | +| `429` | Too many failed authentication attempts. Retry after the timeout period. | | `500` | Internal server error. The request was valid but could not be fulfilled. | | `502` | Signer is running in Dirk mode but Dirk is unreachable. | From c08c8c60a586217b59429d13e340877fd709c8bb Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 11:37:46 -0700 Subject: [PATCH 13/21] styling, clarifications, typos --- docs/docs/get_started/running/binary.md | 24 ++++---- docs/docs/get_started/running/docker.md | 77 +++++++++---------------- 2 files changed, 40 insertions(+), 61 deletions(-) diff --git a/docs/docs/get_started/running/binary.md b/docs/docs/get_started/running/binary.md index b0788b1cb..006e14b0b 100644 --- a/docs/docs/get_started/running/binary.md +++ b/docs/docs/get_started/running/binary.md @@ -5,7 +5,7 @@ description: Run Commit-Boost modules natively # Binary :::warning -Running the modules natively means you opt out of the security guarantees made by Docker and it's up to you how to setup and ensure the modules run safely. +Running the modules natively means you opt out of the security guarantees made by Docker, and it is up to you to set up the modules and ensure they run safely. ::: ## Setup @@ -23,17 +23,17 @@ Services need environment variables to work correctly. ### PBS Service -- `CB_PBS_ENDPOINT`: optional, override to specify the `IP:port` endpoint where the PBS Service will open the port for the beacon node. -- `CB_MUX_PATH_{ID}`: optional, override where to load mux validator keys for mux with `id=\{ID\}`. +- `CB_PBS_ENDPOINT`: optional, override to specify the `IP:port` endpoint where the PBS service will open the port for the beacon node. +- `CB_MUX_PATH_{ID}`: optional, override where to load mux validator keys for mux with `id={ID}`. ### Signer Service -- `CB_JWTS`: required (the signer service will not start without it), comma-separated list of `module_id=jwt_secret` pairs for module authentication. +- `CB_JWTS`: required (the Signer service will not start without it), comma-separated list of `module_id=jwt_secret` pairs for module authentication. - `CB_SIGNER_ADMIN_JWT`: required, secret to use for admin JWT. - `CB_SIGNER_JWT_AUTH_FAIL_LIMIT`: optional, override the number of failed JWT auth attempts before rate-limiting a client (default: `3`). - `CB_SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS`: optional, override the rate-limit timeout window in seconds (default: `300`). - `CB_SIGNER_ENDPOINT`: optional, override to specify the `IP:port` endpoint to bind the signer server to. -- `CB_SIGNER_TLS_CERTIFICATES`: path to the TLS certificates for the server. +- `CB_SIGNER_TLS_CERTIFICATES`: optional, override of the TLS certificates directory (must contain `cert.pem` and `key.pem`). Only used when the signer's `tls_mode` is set to a certificate path. - For loading keys we currently support: - `CB_SIGNER_LOADER_FILE`: path to a `.json` with plaintext keys (for testing purposes only). - `CB_SIGNER_LOADER_KEYS_DIR` and `CB_SIGNER_LOADER_SECRETS_DIR`: paths to the `keys` and `secrets` directories or files (ERC-2335 style keystores, see [Signer config](../configuration.md#signer-service) for more info). @@ -52,19 +52,23 @@ Services need environment variables to work correctly. #### Commit modules -- `CB_SIGNER_URL`: required, url to the Signer Service server. -- `CB_SIGNER_JWT`: required, jwt to use for signature requests. +- `CB_SIGNER_URL`: required, url to the Signer service server. +- `CB_SIGNER_JWT`: required, the module's pre-shared JWT secret, from which the module mints per-request tokens. Must be identical to this module's entry in the signer's `CB_JWTS`; generate one per module (e.g. `openssl rand -hex 32`). See [Module JWT](../../developing/prop-commit-signing.md#module-jwt). Modules might also have additional envs required, which should be detailed by the maintainers. ## Start -After creating the `cb-config.toml` file, setup the required envs and run the binary. For example: +After creating the `cb-config.toml` file, set up the required envs and run the binary. For example: ```bash CB_CONFIG=./cb-config.toml commit-boost pbs ``` -## Security +Or for the Signer service: -Running the modules natively means you opt out of the security guarantees made by Docker and it's up to you how to setup and ensure the modules run safely. +```bash +CB_CONFIG=./cb-config.toml CB_JWTS="MY_MODULE=" CB_SIGNER_ADMIN_JWT="" commit-boost signer +``` + +For a worked signer startup, see [Verifying the Signer Service](../building.md#verifying-the-signer-service). diff --git a/docs/docs/get_started/running/docker.md b/docs/docs/get_started/running/docker.md index bb07a4956..23139656b 100644 --- a/docs/docs/get_started/running/docker.md +++ b/docs/docs/get_started/running/docker.md @@ -3,7 +3,7 @@ description: Run Commit-Boost with Docker --- # Docker -The Commit-Boost program generates a dynamic `docker-compose.yml` file using the provided `.toml` config file. This is the recommended approach as Docker provides sandboxing of the containers from the rest of your system. +The Commit-Boost program generates a `cb.docker-compose.yml` file from the provided `.toml` config file. This is the recommended approach as Docker provides sandboxing of the containers from the rest of your system. ## Init @@ -11,9 +11,11 @@ First run: ```bash commit-boost init --config cb-config.toml ``` +The optional `-o`/`--output` flag sets where the files are written (default: the current directory). + This will create two files: - `cb.docker-compose.yml` which contains the full setup of the Commit-Boost services. -- `.cb.env` with local env variables, including JWTs for modules, only created if the Signer Service is enabled. +- `.cb.env` with local env variables, including JWTs for modules, only created if the Signer service is enabled. ## Start @@ -48,9 +50,9 @@ This will wind down all services and clear internal networks and file mounts. ## Example with PBS Only -This section provides an example of a configuration where only the PBS service is run with its default configuration, and the Docker compose file produced by that configuration. +A minimal configuration that runs only the PBS service with its defaults, and the Docker compose file `init` produces from it. -All of PBS's parameters are controlled via the [Commit-Boost TOML configuration file](../configuration.md); the service cannot currently be controlled with command-line arguments. Therefore it is crucial to ensure that you have a configuration file present with all of the settings you require *before* starting the service, as this file will be mounted within the Docker container as a volume in read-only mode. +All of PBS's parameters are controlled via the [Commit-Boost TOML configuration file](../configuration.md); the service cannot currently be controlled with command-line arguments. Make sure the configuration file exists with all of the settings you require *before* starting the service, as it is mounted into the Docker container as a read-only volume. Below is a simple configuration for running only the PBS service on the Hoodi network with two relays: @@ -64,21 +66,21 @@ wait_all_registrations = true [[relays]] id = "abc" -url = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz" +url = "https://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.example.com" [[relays]] id = "def" -url = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@def.xyz" +url = "https://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@def.example.com" ``` -Note that there are many more parameters that Commit-Boost supports, but they are all omitted and thus will use their default options. For a full description of the default options within the config file, go to the [annotated configuration example](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml). +There are many more parameters that Commit-Boost supports, but they are all omitted here and thus will use their default options. For a full description of the default options within the config file, go to the [annotated configuration example](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml). -The relays here are placeholder for the sake of the example; for a list of actual relays, visit [the EthStaker relay list](https://github.com/eth-educators/ethstaker-guides/blob/main/MEV-relay-list.md). +The relays here are placeholders for the sake of the example; for a list of actual relays, visit [the EthStaker relay list](https://github.com/ethstaker/ethstaker-guides/blob/main/MEV-relay-list.md). ### Commit-Boost Init Output -Run `commit-boost init --config cb-config.toml` with the above configuration, the program will produce the following Docker Compose file: +Run `commit-boost init --config cb-config.toml` with the above configuration. The program will produce the following Docker Compose file: ``` services: @@ -109,9 +111,9 @@ This will run the PBS service in a container named `cb_pbs`. The program creates a read-only volume binding for the config file, which the PBS service needs to run. The Docker compose file that it creates with the `init` command, `cb.docker-compose.yml`, will be placed into your current working directory when you run the program. The volume source will be specified as a *relative path* to that working directory, so it's ideal if the config file is directly within your working directory (or a subdirectory). If you need to specify an absolute path for the config file, you can adjust the `volumes` entry within the Docker compose file manually after its creation. -Since this is a volume, the PBS service sees changes to the file: the stock PBS image watches the config file and [automatically reloads the configuration](../configuration.md#automatic-reload-pbs-only) whenever the file is modified, without needing a restart. That means you can change the file any time after the Docker compose file is created to tweak PBS's parameters, but it also means the config file must stay in the same location; if you move it, the PBS container won't be able to mount it anymore and fail to start unless you manually adjust the volume's source location. +Since this is a volume, the PBS service sees changes to the file: the stock PBS image watches the config file and [automatically reloads the configuration](../configuration.md#automatic-reload-pbs-only) whenever the file is modified, without needing a restart. That means you can change the file any time after the Docker compose file is created to tweak PBS's parameters, but it also means the config file must stay in the same location; if you move it, the PBS container won't be able to mount it anymore and will fail to start unless you manually adjust the volume's source location. -If you replaced `pbs.docker_image` with a **custom PBS image**, the automatic watcher is only active if that binary passes a non-empty config path to `PbsState::new` — see [Extending PBS](../../developing/extending-pbs.md#entry-point). The manual `POST /reload` endpoint works regardless. +Custom PBS images may not auto-reload; see [Extending PBS](../../developing/extending-pbs.md#entry-point). ### Networking @@ -142,7 +144,7 @@ ports: [] In this scenario we will be running the PBS service, the Signer service, and a commit module (`DA_COMMIT`) that interacts with the Signer service's API. -All of both PBS's and the Signer's parameters are controlled via the [Commit-Boost TOML configuration file](../configuration.md); the services cannot currently be controlled with command-line arguments. Therefore it is crucial to ensure that you have a configuration file present with all of the settings you require *before* starting the services, as this file will be mounted within the Docker containers as a volume in read-only mode. +The same configuration-file rules as the [PBS-only example](#example-with-pbs-only) apply. Below is a simple configuration for running only the three modules on the Hoodi network with two relays, extended from the prior scenario above: @@ -156,11 +158,11 @@ wait_all_registrations = true [[relays]] id = "abc" -url = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz" +url = "https://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.example.com" [[relays]] id = "def" -url = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@def.xyz" +url = "https://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@def.example.com" [signer] docker_image = "ghcr.io/commit-boost/commit-boost:v0.10.0" @@ -179,18 +181,14 @@ signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b sleep_secs = 5 ``` -Note that there are many more parameters that Commit-Boost supports, but they are all omitted and thus will use their default options. For a full description of the default options within the config file, go to the [annotated configuration example](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml). - -The relays here are placeholder for the sake of the example; for a list of actual relays, visit [the EthStaker relay list](https://github.com/eth-educators/ethstaker-guides/blob/main/MEV-relay-list.md). - In this scenario there are two folders in the same directory as the configuration file (the working directory): `keys` and `secrets`. These correspond to the folders containing the [EIP-2335 keystores](../configuration.md#local-signer) and secrets in Lighthouse format. For your own keys, adjust the `format` parameter within the configuration and directory paths accordingly. -Note that if either the `docker_image` under the `[signer]` or `[pbs]` is left unspecified it will default to `ghcr.io/commit-boost/commit-boost:latest`. Make sure to specify both if you intend to use versions other than the latest release. +If either the `docker_image` under the `[signer]` or `[pbs]` is left unspecified, it defaults to `ghcr.io/commit-boost/commit-boost:latest`. Make sure to specify both if you intend to use versions other than the latest release. ### Commit-Boost Init Output -Run `commit-boost init --config cb-config.toml` with the above configuration, the program will produce two files: +Run `commit-boost init --config cb-config.toml` with the above configuration. The program will produce two files: - `cb.docker-compose.yml` - `.cb.env` @@ -273,12 +271,14 @@ This will create three Docker containers when executed: Finally, the `.cb.env` file produced will look like this: ``` -CB_JWT_DA_COMMIT=hJ0bV40pTMShsRb9QS7fVinAsL9Roxkc -CB_JWTS=DA_COMMIT=hJ0bV40pTMShsRb9QS7fVinAsL9Roxkc -CB_SIGNER_ADMIN_JWT=WbdxlH32hNOMkfc6BfBHaV1WZj3vgODA +CB_JWT_DA_COMMIT=hJ0bV40p... +CB_JWTS=DA_COMMIT=hJ0bV40p... +CB_SIGNER_ADMIN_JWT=WbdxlH32... ``` -The Signer service needs JWT authentication from each of its modules. The program creates these and embeds them into the containers via environment variables automatically for convenience. This is demonstrated for the Signer Service within the `environment` compose block: the `CB_JWTS: ${CB_JWTS}` forwards the `CB_JWTS` environment variable that's present when running Docker compose. The program requests that you do so via the command `docker compose --env-file "./.cb.env" -f "./cb.docker-compose.yml" up -d`; the `--env-file "./.cb.env"` handles loading the program's JWT output into this environment variable. +The values shown are truncated examples; `init` generates fresh random secrets on every run. The file contains live secrets: restrict it with `chmod 600 .cb.env` and keep it out of backups and version control. + +The Signer service needs JWT authentication from each of its modules. The program creates these and embeds them into the containers via environment variables automatically for convenience. This is demonstrated for the Signer service within the `environment` compose block: the `CB_JWTS: ${CB_JWTS}` forwards the `CB_JWTS` environment variable that's present when running Docker compose. The program requests that you do so via the command `docker compose --env-file "./.cb.env" -f "./cb.docker-compose.yml" up -d`; the `--env-file "./.cb.env"` handles loading the program's JWT output into this environment variable. Similarly, for the `cb_da_commit` module, the `CB_SIGNER_JWT: ${CB_JWT_DA_COMMIT}` line within its `environment` block will set the JWT that it should use to authenticate with the Signer service. @@ -290,31 +290,6 @@ As with the PBS-only example, the configuration file is placed into a read-only ### Networking -The program will force both the PBS and Signer API endpoints to bind to `0.0.0.0` within Docker's internal network so other Docker containers can access them, but it will only expose the API port (default `18550` for PBS and `20000` for the Signer) to `127.0.0.1` on your host machine. That way any processes running on the same machine can access them on their respective ports. If you want to open the ports for access across your entire network, not just your local machine, you can add the line: - -``` -host = "0.0.0.0" -``` - -to both the `[pbs]` and `[signer]` sections in the configuration. This will cause the resulting `ports` entries in the Docker compose file to become: - -``` -cb_pbs: - ... - ports: - - 0.0.0.0:18550:18550 - - -cb_signer: - ... - ports: - - 0.0.0.0:20000:20000 -``` +The same networking rules as the [PBS-only example](#networking) apply to both services; the Signer's API port (default `20000`) is exposed to `127.0.0.1` alongside PBS's `18550`. To open them to your network, add `host = "0.0.0.0"` to both the `[pbs]` and `[signer]` sections, or remove the `ports` entries from the compose file to keep them Docker-internal. -though you will need to add entries to your local machine's firewall software (if applicable) for other machines to access them. - -Currently, the program will always export the PBS and Signer services' API ports in one of these two ways. If you don't want to expose them at all, so they can only be accessed by other Docker containers running within Docker's internal network, you will need to manually remove the `ports` entries from the Docker compose files after they've been created: - -``` -ports: [] -``` +Unlike the [metrics ports](../configuration.md#metrics), both of these ports carry authenticated APIs, and the signer fronts your validator keys over plain HTTP by default, so open them beyond localhost only behind a firewall and, for the signer, with [TLS enabled](../configuration.md#tls). From 2a68a510dabf563942bb9fbd23be85636f8ca2cf Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 11:40:20 -0700 Subject: [PATCH 14/21] styling, clarifications --- .../get_started/running/metrics-catalog.md | 13 ++++---- docs/docs/get_started/running/metrics.md | 31 ++++++++++++++----- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/docs/docs/get_started/running/metrics-catalog.md b/docs/docs/get_started/running/metrics-catalog.md index 88525ed19..957f6f307 100644 --- a/docs/docs/get_started/running/metrics-catalog.md +++ b/docs/docs/get_started/running/metrics-catalog.md @@ -4,7 +4,7 @@ sidebar_label: "Metrics catalog" # Metrics catalog -This page lists every metric emitted by the Commit-Boost PBS and Signer services together with the runtime-registered build-info metric from the shared telemetry crate. Use this as a reference when building dashboards or writing alerting rules. +Every metric emitted by the Commit-Boost PBS and Signer services, together with the runtime-registered build-info metric from the shared telemetry crate. Useful when building dashboards or writing alerting rules. For scrape and port setup, see [Metrics](./metrics.md). --- @@ -14,11 +14,12 @@ PBS metrics use a custom Prometheus registry with namespace prefix `cb_pbs_`. Th | Metric name (wire) | Type | Labels | Description | |---|---|---|---| -| `cb_pbs_relay_status_code_total` | Counter | `http_status_code`, `endpoint`, `relay_id` | HTTP status code received by relay. Incremented after each relay HTTP response; `http_status_code` may be `"555"` (the value of `TIMEOUT_ERROR_CODE_STR`) for timeouts. Endpoint values: `get_header`, `register_validator`, `submit_blinded_block`, `status`. | +| `cb_pbs_relay_status_code_total` | Counter | `http_status_code`, `endpoint`, `relay_id` | HTTP status code received by relay. Incremented after each relay HTTP response; `http_status_code` may be `"555"` (the value of `TIMEOUT_ERROR_CODE_STR`) for timeouts, or `"556"` (the value of `TRANSPORT_ERROR_CODE`) for WebSocket transport failures on the `get_header` bids stream (unreleased, from v0.11). Endpoint values: `get_header`, `register_validator`, `submit_blinded_block`, `status`. | | `cb_pbs_relay_latency` | Histogram | `endpoint`, `relay_id` | HTTP latency (duration in seconds) by relay. Records duration of relay HTTP requests. Endpoint values: `get_header`, `register_validator`, `submit_blinded_block`, `status`. | | `cb_pbs_relay_last_slot` | Gauge | `relay_id` | Latest slot for which a relay delivered a header. Only updated in the `get_header` handler. Set to the current slot on each successful header from that relay. | | `cb_pbs_relay_header_value` | Gauge | `relay_id` | Header value in gwei delivered by a relay. Converted from raw wei (÷ 1e9) in the `get_header` handler. | -| `cb_pbs_beacon_node_status_code_total` | Counter | `http_status_code`, `endpoint` | HTTP status code returned to the beacon node. Tracks what status codes the PBS returns for beacon node-facing requests. Endpoint values: `get_header`, `register_validator`, `submit_blinded_block`, `status`, `reload`. Error status codes (`502` for `NoResponse`/`NoPayload`, `500` for `Internal`) are set via `PbsClientError`. | +| `cb_pbs_beacon_node_status_code_total` | Counter | `http_status_code`, `endpoint` | HTTP status code returned to the beacon node. Tracks what status codes the PBS returns for beacon node-facing requests. Endpoint values: `get_header`, `register_validator`, `submit_blinded_block`, `status`, `reload`. Error status codes (`502` for `NoResponse`/`NoPayload`, `500` for `Internal`) are set via `PbsClientError`. The handlers also record `406` directly when the request's `Accept` header offers no supported encoding (unreleased, from v0.11 content negotiation), `204` when no bid is available on `get_header`, and `202` for accepted v2 `submit_blinded_block` requests. | +| `cb_pbs_pbs_submit_block_v2_unsupported_total` | Counter | `relay_id` | (unreleased, from v0.11) Count of v2 `submit_blinded_block` requests a relay could not serve because it returned 404 on the v2 endpoint. A non-zero value means the relay does not support `submitBlindedBlockV2` and those blocks were not submitted via that relay. The double `pbs` in the wire name comes from the registry prefix plus the metric name `pbs_submit_block_v2_unsupported_total`. | --- @@ -40,16 +41,16 @@ When each service starts its metrics HTTP server (via the `MetricsProvider` from |---|---|---|---| | `info` | Gauge | `version`, `commit`, `network` | Always `1`. Carries build metadata as Prometheus const labels. The `version` label is the crate version (`CARGO_PKG_VERSION`), `commit` is the Git hash at build time (`GIT_HASH`), and `network` is the chain name (e.g. `Mainnet`, `Holesky`, `Sepolia`, `Hoodi`, or `Custom` for custom chain specs). | -This metric appears under the service's own registry prefix — for example, the PBS instance exposes it as `cb_pbs_info{version="...",commit="...",network="..."}` and the Signer exposes it as `cb_signer_info{version="...",commit="...",network="..."}`. +This metric appears under the service's own registry prefix: the PBS instance exposes it as `cb_pbs_info{version="...",commit="...",network="..."}` and the Signer exposes it as `cb_signer_info{version="...",commit="...",network="..."}`. --- ## Custom module metrics -Commit-Modules can register their own metrics via the `prometheus` crate. Each module receives a `ModuleMetricsConfig` at init time which includes the `server_port` for its metrics HTTP server. To expose custom metrics: +Commit modules can register their own metrics via the `prometheus` crate. The module's metrics HTTP server port comes from `CB_METRICS_PORT` (see [Running > Binary](./binary.md#common)). To expose custom metrics: 1. Create a custom `Registry` (optionally with a namespace prefix). 2. Register your metrics on that registry. -3. Pass the registry to `MetricsProvider::new()` or `MetricsProvider::load_and_run()` to serve them on the module's `/metrics` endpoint. +3. Call `MetricsProvider::load_and_run(chain, registry)` to serve the registry on the module's `/metrics` endpoint. Alternatively, construct a `ModuleMetricsConfig` and pass it to `MetricsProvider::new()`, then spawn `provider.run()` yourself. All module metrics are served on a separate port and are **not** aggregated into the PBS or Signer registries. To collect them, add the module's metrics port as an additional scrape target in your Prometheus configuration. diff --git a/docs/docs/get_started/running/metrics.md b/docs/docs/get_started/running/metrics.md index 9a92d3c2a..5b8c96302 100644 --- a/docs/docs/get_started/running/metrics.md +++ b/docs/docs/get_started/running/metrics.md @@ -6,15 +6,15 @@ description: Setup metrics collection Commit-Boost can be configured to collect metrics from the different services and expose them to be scraped from Prometheus. -Make sure to add the `[metrics]` section to your config file: +For a full reference of every metric the PBS and Signer services expose, see the [Metrics catalog](./metrics-catalog.md). + +Make sure to add the `[metrics]` section to your config file (fields and the `start_port` port ladder: [Configuration > Metrics](../configuration.md#metrics)): ```toml [metrics] enabled = true -host = "127.0.0.1" # Host for metrics servers. Default: 127.0.0.1 -start_port = 10000 # Port the first service listens on for Prometheus scrapes; following services use port+1, port+2, etc. Default: 10000 ``` -If the section is missing, metrics collection will be disabled. If you generated the `docker-compose.yml` file with `commit-boost init`, metrics ports will be automatically configured. If you're running the binaries directly, you will need to set the correct environment variables, as described in the [previous section](./binary.md#common). +If you generated the `cb.docker-compose.yml` file with `commit-boost init`, metrics ports will be automatically configured. If you're running the binaries directly, you will need to set the correct environment variables, as described in the [previous section](./binary.md#common). ## Example setup @@ -40,7 +40,7 @@ cb_cadvisor: ### Prometheus -For more information on how to setup Prometheus, see the [Prometheus documentation](https://prometheus.io/docs/prometheus/latest/getting_started/). +For more information on how to set up Prometheus, see the [Prometheus documentation](https://prometheus.io/docs/prometheus/latest/getting_started/). ```yml title="cb.docker-compose.yml" cb_prometheus: @@ -51,8 +51,13 @@ cb_prometheus: volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus + networks: + - default + - signer_network ``` +The generated compose file attaches the signer and commit module containers only to `signer_network`, so Prometheus must join that network too or the `cb_signer` and `cb_da_commit` scrape targets below will be unreachable. + ```yml title="prometheus.yml" global: scrape_interval: 15s @@ -64,7 +69,7 @@ scrape_configs: ``` ### Grafana -For more information on how to setup Grafana, see the [Grafana documentation](https://grafana.com/docs/grafana/latest/getting-started/). +For more information on how to set up Grafana, see the [Grafana documentation](https://grafana.com/docs/grafana/latest/fundamentals/getting-started/). ```yml title="cb.docker-compose.yml" cb_grafana: @@ -77,6 +82,8 @@ cb_grafana: - grafana-data:/var/lib/grafana ``` +The mounted datasource provisioning file points Grafana at the Prometheus service: + ```yml title="datasources.yml" apiVersion: 1 @@ -91,4 +98,14 @@ datasources: editable: true ``` -Once Grafana is running, you can [import](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/import-dashboards/) the Commit-Boost dashboards from [here](https://github.com/Commit-Boost/commit-boost-client/tree/main/provisioning/grafana), making sure to select the correct `Prometheus` datasource. +### Named volumes + +The Prometheus and Grafana services above use the named volumes `prometheus-data` and `grafana-data`. The generated compose file has no top-level `volumes:` block, and Docker Compose refuses to start the whole project if a service references an undeclared volume, so declare both when merging the snippets: + +```yml title="cb.docker-compose.yml" +volumes: + prometheus-data: + grafana-data: +``` + +Once Grafana is running, you can [import](https://grafana.com/docs/grafana/latest/visualizations/dashboards/build-dashboards/import-dashboards/) the Commit-Boost dashboards from [here](https://github.com/Commit-Boost/commit-boost-client/tree/main/provisioning/grafana), making sure to select the correct `Prometheus` datasource. From 95e7b7559d3e32f94bf50eb1fcc00a0dd11095c0 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 11:43:20 -0700 Subject: [PATCH 15/21] clarify local build --- docs/docs/get_started/building.md | 109 ++++++++++++++---------------- 1 file changed, 52 insertions(+), 57 deletions(-) diff --git a/docs/docs/get_started/building.md b/docs/docs/get_started/building.md index 5054d7f62..8b84340d3 100644 --- a/docs/docs/get_started/building.md +++ b/docs/docs/get_started/building.md @@ -1,33 +1,55 @@ # Building Commit-Boost from Source -Commit-Boost's components are all written in [Rust](https://www.rust-lang.org/). This guide will walk you through the setup required to build them from source. It assumes you are on a Debian or Debian-based system (e.g., Ubuntu, Linux Mint, Pop OS). For other systems, please adapt the steps for your system's package manager accordingly. +Commit-Boost's components are all written in [Rust](https://www.rust-lang.org/). This guide walks through building them from source. + +## Getting the Source + +Pull the repository: + +```bash +git clone https://github.com/Commit-Boost/commit-boost-client +``` + +Check out the release you want to build. Each release is pinned in `.releases/` +(e.g. `.releases/v0.10.0.yml` names its `commit:`); check out that commit: + +```bash +cd commit-boost-client && git checkout +``` + +Finally, update the submodules: + +``` +git submodule update --init --recursive +``` ## Building via the Docker Builder -For convenience, Commit-Boost has Dockerized the build environment for Linux `x64` and `arm64` platforms. It utilizes Docker's powerful [buildx](https://docs.docker.com/reference/cli/docker/buildx/) system. All of the prerequisites, cross-compilation tooling, and configuration are handled by the builder image. If you would like to build the Commit-Boost binary and Docker image from source, you are welcome to use the Docker builder process. +The build environment is Dockerized for the Linux `x64` and `arm64` platforms, using Docker's [buildx](https://docs.docker.com/reference/cli/docker/buildx/) system. The builder image handles all of the prerequisites, cross-compilation tooling, and configuration, so this path does not need a local Rust toolchain. -To use the builder, you will need to have [Docker Engine](https://docs.docker.com/engine/install/) installed on your system. Please follow the instructions to install it first. +The builder requires [Docker Engine](https://docs.docker.com/engine/install/). :::note The build system assumes that you've added your user account to the `docker` group with the Linux [post-install steps](https://docs.docker.com/engine/install/linux-postinstall/). If you haven't, then you'll need to run the build script below as `root` or modify it so each call to `docker` within it is run as the root user (e.g., with `sudo`). ::: -The Docker builder is built into the project's `justfile` which is used to invoke many facets of Commit Boost development. To use it, you'll need to install [Just](https://github.com/casey/just) on your system. - -Use `just --list` to show all of the actions - there are many. The `justfile` provides actions, called "recipes", for building the unified `commit-boost` binary, as well as actions to build the unified Docker image that is used to run the PBS and Signer services and the CLI. +Builds run through the project's `justfile` (install [Just](https://github.com/casey/just); `just --list` shows all recipes). The relevant recipes: `build-bin ` builds the `commit-boost` binary; `build-all ` additionally builds the unified Docker image `commit-boost/commit-boost:` (bundling all subcommands) and loads it into your local registry. `` is the output directory name under `./build/` and the Docker tag, e.g. `$(git rev-parse --short HEAD)`. For Linux `amd64` + `arm64` use the `-multiarch` recipe variants; a multiarch image manifest needs a [custom Docker registry](https://www.digitalocean.com/community/tutorials/how-to-set-up-a-private-docker-registry-on-ubuntu-20-04), as Docker's built-in local registry does not support them. -Below is a brief summary of the relevant ones for building the Commit-Boost artifacts: +To build the binary, run: -- `build-all ` builds the `commit-boost` binary to `./build/` and creates a Docker image called `commit-boost/commit-boost:` (a unified image that bundles all subcommands), loading it into your local Docker registry. -- `build-bin ` can be used to create the `commit-boost` binary itself. +``` +just build-bin +``` -The `version` provided will be used to house the output binaries in `./build/`, and act as the version tag for the Docker images when they're added to your local system or uploaded to your local Docker repository. For example, using `$(git rev-parse --short HEAD)` will set the version to the current commit hash. +This will create a binary in `build//`, for example `build/206658b/linux_amd64/`. Confirm that it works: -If you're interested in building the binaries and/or Docker images for multiple architectures (currently Linux `amd64` and `arm64`), use the variants of those recipes that have the `-multiarch` suffix. Note that building a multiarch Docker image manifest will require the use of a [custom Docker registry](https://www.digitalocean.com/community/tutorials/how-to-set-up-a-private-docker-registry-on-ubuntu-20-04), as the local registry built into Docker does not have multiarch manifest support. +``` +./build///commit-boost --version +``` ## Building Manually -If you don't want to use the Docker builder, you can compile the Commit-Boost artifacts locally. The following instructions assume a Debian or Debian-based system (e.g., Ubuntu, Linux Mint, Pop OS) for simplicity. For other systems, please adapt any relevant instructions to your environment accordingly. +If you don't want to use the Docker builder, you can compile the Commit-Boost artifacts with a local Rust toolchain. The following instructions assume a Debian or Debian-based system (e.g., Ubuntu, Linux Mint, Pop OS) for simplicity. For other systems, please adapt any relevant instructions to your environment accordingly. ### Prerequisites @@ -38,7 +60,7 @@ Requirements: - OpenSSL development libraries - Protobuf Compiler (`protoc`) -Start by installing Rust if you don't already have it. Follow [the official directions](https://www.rust-lang.org/learn/get-started) to install it and bring it up to date. +Install Rust via [the official directions](https://www.rust-lang.org/learn/get-started) if you don't already have it. Install the dependencies: @@ -52,54 +74,37 @@ Install the Protobuf compiler: While many package repositories provide a `protobuf-compiler` package in lieu of manually installing protoc, we've found at the time of this writing that Debian-based ones use v3.21 which is quite out of date. We recommend getting the latest version manually. ::: -We provide a convenient recipe to install the latest version directly from the GitHub releases page: +The repository provides a recipe to install the latest version directly from the GitHub releases page. It requires [Just](https://github.com/casey/just) and works from anywhere inside the repository: ```bash just install-protoc ``` -This works on OSX and Linux systems, but you are welcome to download and install it manually as well. - -With the prerequisites set up, pull the repository: - -```bash -git clone https://github.com/Commit-Boost/commit-boost-client -``` - -Check out the release you want to build. Each release is pinned in `.releases/` -(e.g. `.releases/v0.10.0.yml` names its `commit:`); check out that commit: - -```bash -cd commit-boost-client && git checkout -``` - -Finally, update the submodules: - -``` -git submodule update --init --recursive -``` +This works on OSX and Linux systems. You can also run `provisioning/protoc.sh` directly, or download and install protoc manually. Your build environment should now be ready to use. ### Building the Binary -To build the binary, run: +From the repository root, build the unified `commit-boost` binary with Cargo: ``` -just build-bin +cargo build --release --bin commit-boost ``` -This will create a binary in `build//`, for example `build/206658b/linux_amd64/`. Confirm that it works: +This will create the binary at `target/release/commit-boost`. Confirm that it works: ``` -./build///commit-boost --version +./target/release/commit-boost --version ``` You can now use this to generate the Docker Compose file to drive the other modules if desired. See the [configuration](./configuration.md) guide for more information. ### Verifying the PBS Service -To verify the PBS service works, create [a TOML configuration](./configuration.md) for the PBS module (e.g., `cb-config.toml`). +The commands below use the manual build's output path (`./target/release/commit-boost`); if you used the Docker builder, substitute `./build///commit-boost`. + +To verify the PBS service works, create [a TOML configuration](./configuration.md) for the PBS service (e.g., `cb-config.toml`). As a quick example, we'll use this configuration that connects to the Flashbots relay on the Hoodi network: @@ -133,51 +138,41 @@ signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b Set the path to it in the `CB_CONFIG` environment variable and run the binary: ``` -CB_CONFIG=cb-config.toml ./build///commit-boost pbs +CB_CONFIG=cb-config.toml ./target/release/commit-boost pbs ``` If it works, you should see output like this: ``` -2025-11-04T14:22:03.118427Z WARN No metrics server configured -2025-11-04T14:22:03.118512Z INFO starting PBS service version="0.10.0" commit_hash="f05eefbf652ac5442088bd2b20390d29c23b1c5d" addr=127.0.0.1:18550 chain=Hoodi -2025-11-04T14:22:03.372184Z INFO : new request ua="" relay_check=true method=/eth/v1/builder/status req_id=5c405c33-0496-42ea-a35d-a7a01dbba356 -2025-11-04T14:22:03.521903Z INFO : relay check successful method=/eth/v1/builder/status req_id=5c405c33-0496-42ea-a35d-a7a01dbba356 -2025-11-04T14:22:03.522015Z INFO : Responded with 200 OK in 149 ms method=/eth/v1/builder/status req_id=5c405c33-0496-42ea-a35d-a7a01dbba356 +2025-11-04T14:22:03.118512Z INFO starting PBS service version="0.10.0-rc4" commit_hash="eeff25750c01f4adfc95fc08d69d541ace8e4087" addr=127.0.0.1:18550 chain=Hoodi ``` -The exact timestamps, request ids and commit hash will of course differ; what matters is the -`starting PBS service` line and the successful relay check that follows it. +The v0.10.0 release commit self-reports version `0.10.0-rc4`; timestamps will differ, and any other checkout prints its own commit hash and version. A successful relay check follows the `starting PBS service` line; the full annotated healthy-log reference is in [Expected healthy logs](./troubleshooting.md#expected-healthy-logs). If you see that, then the PBS service works. ### Verifying the Signer Service -To verify the Signer service works, create [a TOML configuration](./configuration.md) for the Signer module (e.g., `cb-config.toml`). We'll use the example in the PBS section above. - -The signer needs the following environment variables set: +To verify the Signer service works, create [a TOML configuration](./configuration.md) for the Signer service (e.g., `cb-config.toml`). We'll use the example in the PBS section above. -- `CB_CONFIG` = path of your config file. -- `CB_JWTS` = key-value pairs of [JWT](https://en.wikipedia.org/wiki/JSON_Web_Token) secrets for each module defined in the config file. The keys must match the module IDs, so for the `test` module above we can use something like `"test=dummy"`. -- `CB_SIGNER_ADMIN_JWT` = the JWT secret for the signer's admin endpoints. Since we don't need it for the sake of just testing the binary, we can use a dummy value. +The signer needs `CB_CONFIG`, `CB_JWTS`, and `CB_SIGNER_ADMIN_JWT` set (definitions: [Binary > Signer Service](./running/binary.md#signer-service)); for this smoke test use `test=dummy` and a dummy admin secret. Set these values, create the `keys` and `secrets` directories listed in the configuration file, and run the binary: ``` mkdir -p /tmp/keys && mkdir -p /tmp/secrets -CB_CONFIG=cb-config.toml CB_JWTS="test=dummy" CB_SIGNER_ADMIN_JWT="dummy_admin" ./build///commit-boost signer +CB_CONFIG=cb-config.toml CB_JWTS="test=dummy" CB_SIGNER_ADMIN_JWT="dummy_admin" ./target/release/commit-boost signer ``` You should see output like this: ``` 2025-11-04T14:31:44.815702Z WARN Proxy store not configured. Proxies keys and delegations will not be persisted -2025-11-04T14:31:44.818193Z INFO Starting signing service version="0.10.0" commit_hash="f05eefbf652ac5442088bd2b20390d29c23b1c5d" modules=["test"] endpoint=127.0.0.1:20000 loaded_consensus=0 loaded_proxies=0 jwt_auth_fail_limit=3 jwt_auth_fail_timeout=300s reverse_proxy=None +2025-11-04T14:31:44.818193Z INFO Starting signing service version="0.10.0-rc4" commit_hash="eeff25750c01f4adfc95fc08d69d541ace8e4087" modules=["test"] endpoint=127.0.0.1:20000 loaded_consensus=0 loaded_proxies=0 jwt_auth_fail_limit=3 jwt_auth_fail_timeout=300s reverse_proxy=None 2025-11-04T14:31:44.818229Z WARN No metrics server configured 2025-11-04T14:31:44.818305Z WARN Running in insecure HTTP mode, no TLS certificates provided ``` -The `insecure HTTP mode` warning is expected here: the default `tls_mode` is `insecure`, and this -config does not set one. See [TLS](./configuration.md#tls) for how to enable it. +The `insecure HTTP mode` warning is expected: this config does not set a `tls_mode`; see [TLS](./configuration.md#tls) to enable it. If you see that, then the binary works. From 695d2670de66e5e24944f3f8e0eb3bcc62b023f0 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 11:48:40 -0700 Subject: [PATCH 16/21] styling, clarifications, add stader docs --- docs/docs/get_started/mux-key-loaders.md | 89 +++++++++++------------- 1 file changed, 40 insertions(+), 49 deletions(-) diff --git a/docs/docs/get_started/mux-key-loaders.md b/docs/docs/get_started/mux-key-loaders.md index cd77f4300..6372c4cbd 100644 --- a/docs/docs/get_started/mux-key-loaders.md +++ b/docs/docs/get_started/mux-key-loaders.md @@ -4,33 +4,33 @@ description: Mux (multiplexer) configuration and key loader types # Mux key loaders -The PBS multiplexer (AKA *mux*) lets you route different validators to different relay sets or timing game configurations. Instead of a single `[[relays]]` list for all your validators, you declare one or more `[[mux]]` entries that match specific validator pubkeys to custom relay and timing settings. +The PBS multiplexer (or *mux*) lets you route different validators to different relay sets or timing game configurations. Instead of a single `[[relays]]` list for all your validators, you declare one or more `[[mux]]` entries that match specific validator pubkeys to custom relay and timing settings. -Use a mux when you need: +A mux covers cases like a Lido or SSV node operator who sends some validators to an operator-specific relay while the rest use the global relay set, or per-group timing games: `timeout_get_header_ms` and `late_in_slot_time_ms` can be set per-mux, overriding the PBS defaults for those validators. The mux key loaders (File, URL, Registry) populate a mux's validator set from a file, an HTTP endpoint, or an on-chain registry, so you don't have to list hundreds or thousands of pubkeys by hand. -- **Different relay sets for different validators** — for example, Lido or SSV node operators who send some validators to an operator-specific relay while the rest use the global relay set. -- **Per-group timing game parameters** — `timeout_get_header_ms` and `late_in_slot_time_ms` can be set per-mux, overriding the PBS defaults for those validators. -- **Dynamic key loading from on-chain or external sources** — the mux key loaders (File, URL, Registry) populate the mux's validator set automatically, so you don't have to list hundreds or thousands of pubkeys by hand. - -Mux entries are an optional addition to the `[[relays]]` section. If you don't need per-validator routing, you can ignore this page entirely. +Mux entries are an optional addition to the `[[relays]]` section. A mux affects only `get_header` requests: validator registrations and `submit_blinded_block` always go to all configured relays, global and mux alike. --- ## Mux entry matching -Each `[[mux]]` entry declares a set of validator pubkeys. At startup the sidecar resolves every mux (running its loader, if any), then flattens all of them into a **single pubkey -> mux** lookup table. Matching is therefore a direct lookup on the validator pubkey, not an ordered scan: the order in which `[[mux]]` entries appear in the config file does not matter. - -Because there is only one entry per pubkey, the mux sets must be **disjoint**. This is enforced before the lookup table is built: if the same validator pubkey appears in two different mux entries — whether listed inline or pulled in by a loader — startup fails with `duplicate validator pubkey in muxes: 0x...`. There is no "first mux wins" fallback; you must fix the config. +At startup the sidecar resolves every mux (running its loader, if any) and builds one pubkey-to-mux lookup, so entry order in the config does not matter and the mux pubkey sets must be disjoint: -Validators that don't appear in any mux fall through to the global `[[relays]]` configuration. +| Condition | Behavior | +|---|---| +| Pubkey belongs to exactly one mux | That mux's relays and timing config are used for `get_header`; registrations and `submit_blinded_block` still go to all relays | +| Pubkey appears in more than one mux | Startup error: `duplicate validator pubkey in muxes` | +| Pubkey doesn't belong to any mux | Falls through to global `[[relays]]` | +| A mux has no pubkeys (empty set) | Startup error: each mux must have at least one pubkey | +| A mux has no relays | Startup error: each mux must have at least one relay | ```toml -# Global relays — used for validators not matching any mux +# Global relays, used for validators not matching any mux [[relays]] id = "global-relay" url = "..." -# A mux entry — its pubkeys must not appear in any other mux +# A mux entry; its pubkeys must not appear in any other mux [[mux]] id = "timing-sensitive" validator_pubkeys = [ @@ -51,19 +51,9 @@ url = "..." # Multiple muxes can be defined repeating this pattern ``` -### Matching rules summary - -| Condition | Behaviour | -|---|---| -| Pubkey belongs to exactly one mux | That mux's relays and timing config are used | -| Pubkey appears in more than one mux | Startup error: `duplicate validator pubkey in muxes` | -| Pubkey doesn't belong to any mux | Falls through to global `[[relays]]` | -| A mux has no pubkeys (empty set) | Startup error — each mux must have at least one pubkey | -| A mux has no relays | Startup error — each mux must have at least one relay | - The pubkey set for a mux can come from two sources combined: -1. **Inline `validator_pubkeys`** — a list of hex-prefixed BLS pubkeys in the config file itself. -2. **A loader plugin** — loads additional keys from a file, URL, or on-chain registry. Keys from the loader are merged into the mux's pubkey set *before* the disjointness check runs, so a key pulled in by a loader can collide with one you listed inline in another mux. +1. **Inline `validator_pubkeys`**: a list of hex-prefixed BLS pubkeys in the config file itself. +2. **A loader plugin**: loads additional keys from a file, URL, or on-chain registry. Keys from the loader are merged into the mux's pubkey set *before* the disjointness check runs, so a key pulled in by a loader can collide with one you listed inline in another mux. --- @@ -75,7 +65,7 @@ Key loaders are how you populate a mux with validator pubkeys without listing th Loads pubkeys from a flat JSON file on disk. -**Schema:** A JSON array of hex-prefixed BLS public key strings. +The file is a JSON array of hex-prefixed BLS public key strings. ```json [ @@ -85,7 +75,7 @@ Loads pubkeys from a flat JSON file on disk. ] ``` -**Config:** Specify the path relative to the process working directory, or as an absolute path. Note that relative paths are resolved against the directory the sidecar is started from, not the config file's location — absolute paths are recommended for binary deployments. +Relative paths resolve against the sidecar's working directory, not the config file's location; absolute paths are recommended for binary deployments. ```toml [[mux]] @@ -97,17 +87,19 @@ id = "my-relay" url = "..." ``` -**Environment variable override:** The path can be overridden at runtime via `CB_MUX_PATH_{id}` where `{id}` is the mux identifier. For a mux with `id = "lido-mux"`, the variable would be `CB_MUX_PATH_lido-mux`. This is useful when you want to keep the config file the same across deployments but point to different key files. +The path can be overridden at runtime via `CB_MUX_PATH_{id}` where `{id}` is the mux identifier, verbatim. For a mux with `id = "lido-mux"`, the variable is `CB_MUX_PATH_lido-mux`. ```bash -export CB_MUX_PATH_lido-mux="/path/to/override.json" +env CB_MUX_PATH_lido-mux="/path/to/override.json" commit-boost pbs ``` +Hyphenated mux ids cannot be set with bash `export` (a hyphen is not valid in a shell identifier); use `env` as above, a compose `environment:` entry, or an underscore-only mux id. + --- ### URL loader -Loads the same JSON schema from an HTTP(S) endpoint. The endpoint must return a JSON array of hex-prefixed BLS public keys (identical format to the File loader). +Loads pubkeys from an HTTP(S) endpoint returning the same JSON array format as the File loader. ```toml [[mux]] @@ -119,18 +111,15 @@ id = "my-relay" url = "..." ``` -**Security:** HTTPS is recommended. HTTP URLs work but trigger a warning at startup. +HTTPS is recommended; plain HTTP works but triggers a warning at startup. -**Request behaviour:** -- One-shot GET request — no retry logic. -- Timeout is controlled by `http_timeout_seconds` in the `[pbs]` section (default: 10s). -- The response body is read in full and parsed as JSON. +The loader makes a one-shot GET request with no retry logic. The timeout is controlled by `http_timeout_seconds` in the `[pbs]` section (default: 10s). The response body is read (up to a 10 MiB limit) and parsed as JSON; larger responses fail the load. --- ### Registry loader -Loads validator pubkeys from an on-chain or network registry. This resolves pubkeys automatically from a data source that stays in sync as validators are added or removed. +Loads validator pubkeys from an on-chain or network registry. Three registries are currently supported: @@ -140,11 +129,13 @@ Three registries are currently supported: | SSV | `"ssv"` | SSV node API or public API | SSV API URLs (from `[pbs]` config) | | Stader | `"stader"` | On-chain contract via RPC | RPC URL (from `[pbs]` config) | +Registry entries must be unique within their registry type (one Lido entry per node operator ID, one SSV entry per node operator ID, one Stader entry per pool and node operator ID); a Lido and an SSV entry may share a node operator ID, since the registries are independent. + #### Lido registry Reads validator pubkeys from Lido's on-chain `NodeOperatorsRegistry` or `CSModule registry`, depending on the module type. The sidecar connects to the configured RPC endpoint and calls the contract's `getSigningKeys` method with pagination. -**Requirements:** `rpc_url` must be set in the `[pbs]` configuration. +`rpc_url` must be set in the `[pbs]` configuration. ```toml [pbs] @@ -184,11 +175,11 @@ url = "..." | Hoodi | 2 | SimpleDVT | `NodeOperatorsRegistry` | | Hoodi | 3 | Sandbox | `NodeOperatorsRegistry` | | Hoodi | 4 | Community Staking (CSM) | `CSModule` | -| Sepolia | 1 | — | `NodeOperatorsRegistry` | +| Sepolia | 1 | | `NodeOperatorsRegistry` | -Module ids 1 and 2 use the `NodeOperatorsRegistry` contract. Module id 3 (Mainnet) and module id 4 (Holesky / Hoodi) use the `CSModule` (Community Staking Module) contract, which has a different ABI. The sidecar detects the module type automatically based on chain and module id. +The sidecar picks the right contract automatically based on chain and module id. -**Dynamic refreshing:** When `enable_refreshing = true`, the sidecar periodically re-fetches keys from the on-chain registry at runtime. New validators that register with the node operator are picked up automatically without a restart. This is useful for growing node operator deployments where you don't want to restart the sidecar every time a new validator is added. The refresh interval is controlled by `mux_registry_refresh_interval_seconds` in the `[pbs]` configuration (default: `384` seconds, i.e. one epoch; must be greater than 0), and applies to all registry muxes with refreshing enabled. +When `enable_refreshing = true`, the sidecar periodically re-fetches keys from the on-chain registry at runtime. New validators that register with the node operator are picked up automatically without a restart, and validators removed from the registry are dropped from the mux (falling back to the global relays) on the same refresh cycle. The refresh interval is controlled by `mux_registry_refresh_interval_seconds` in the `[pbs]` configuration (default: `384` seconds, i.e. one epoch; must be greater than 0), and applies to all registry muxes with refreshing enabled. --- @@ -196,7 +187,7 @@ Module ids 1 and 2 use the `NodeOperatorsRegistry` contract. Module id 3 (Mainne Loads validator pubkeys from the SSV network. The sidecar first tries to fetch keys from your local SSV node API. If that fails, it falls back to the public SSV API. -**Requirements:** None — `ssv_node_api_url` and `ssv_public_api_url` are optional in the `[pbs]` configuration and default to `http://localhost:16000/v1/` (node API) and `https://api.ssv.network/api/v4/` (public API). Set them only to point at a different node or API server. +No `[pbs]` settings are required: `ssv_node_api_url` and `ssv_public_api_url` are optional and default to `http://localhost:16000/v1/` (node API) and `https://api.ssv.network/api/v4/` (public API). Set them only to point at a different node or API server. ```toml [pbs] @@ -221,14 +212,14 @@ url = "..." | `node_operator_id` | integer | Yes | SSV node operator ID | | `enable_refreshing` | boolean | No (default: `false`) | Whether to periodically refresh keys at runtime | -**API sources (fallback chain):** +The loader tries two API sources in order: 1. **SSV node API** (preferred): `GET {ssv_node_api_url}validators` with a JSON body `{"operators": [node_operator_id]}`. Response contains a `data` array of validators with hex-encoded `public_key` fields. 2. **Public API** (fallback): `GET {ssv_public_api_url}{chain}/validators/in_operator/{node_operator_id}?perPage=100&page={page}` with pagination. Response contains a `validators` array and `pagination` object. If the node API call fails (timeout, connection error, etc.), the sidecar logs a warning and falls back to the public API. -**Chains supported:** Mainnet, Holesky, and Hoodi. +The public-API fallback supports Mainnet, Holesky, and Hoodi only; the node API path is chain-agnostic, so on other chains the loader works only while your local SSV node API is reachable. --- @@ -236,7 +227,7 @@ If the node API call fails (timeout, connection error, etc.), the sidecar logs a Reads validator pubkeys from Stader's on-chain node registry. The sidecar connects to the configured RPC endpoint and queries the registry contract for the pool you specify. -**Requirements:** `rpc_url` must be set in the `[pbs]` configuration, and `stader_pool` must be set in the mux config. +`rpc_url` must be set in the `[pbs]` configuration, and `stader_pool` must be set in the mux config. ```toml [pbs] @@ -258,16 +249,16 @@ url = "..." |---|---|---|---| | `registry` | string | Yes | Must be `"stader"` | | `node_operator_id` | integer | Yes | Stader node operator ID | -| `stader_pool` | string | Yes | Stader staking pool — `"permissioned"` or `"permissionless"` | +| `stader_pool` | string | Yes | Stader staking pool: `"permissioned"` or `"permissionless"` | | `enable_refreshing` | boolean | No (default: `false`) | Whether to periodically refresh keys at runtime | -**Chains supported:** Mainnet only. +Stader is supported on Mainnet only. --- ## Reference config -For a complete working example with multiple mux entries — File loader, Lido registry, SSV registry, and Stader registry — see: +For a complete working example with multiple mux entries (File loader, Lido registry, SSV registry, and Stader registry), see: - [`examples/configs/pbs_mux.toml`](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/configs/pbs_mux.toml) @@ -275,5 +266,5 @@ For a complete working example with multiple mux entries — File loader, Lido r ## See also -- [Configuration reference](./configuration.md) — full config field listing -- [Signer API](../developing/prop-commit-signing.md#api-quickstart) — signing API quickstart and authentication +- [Configuration reference](./configuration.md): full config field listing +- [Signer API](../developing/prop-commit-signing.md#api-quickstart): signing API quickstart and authentication From 85076105a63b186915621333ee71c9fc978fab0f Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 11:48:51 -0700 Subject: [PATCH 17/21] styling --- docs/docs/get_started/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/get_started/overview.md b/docs/docs/get_started/overview.md index ac096b629..b9fafd616 100644 --- a/docs/docs/get_started/overview.md +++ b/docs/docs/get_started/overview.md @@ -10,7 +10,7 @@ Each component roughly maps to a container: from a single `.toml` config file, t Commit-Boost ships with two core services: - A PBS service which implements the [BuilderAPI](https://ethereum.github.io/builder-specs/) for [MEV Boost](https://docs.flashbots.net/flashbots-mev-boost/architecture-overview/specifications). -- A Signer Service, which implements the [Signer API](/api) and provides the interface for modules to request proposer commitments. +- A Signer service, which implements the [Signer API](/api) and provides the interface for modules to request proposer commitments. ## Setup From 97669627725ff9bc70d077b59acdba716f8ede38 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 11:49:19 -0700 Subject: [PATCH 18/21] styling --- docs/docs/overview.md | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/docs/overview.md b/docs/docs/overview.md index 4c38943a1..0a54a0df5 100644 --- a/docs/docs/overview.md +++ b/docs/docs/overview.md @@ -5,24 +5,22 @@ sidebar_position: 2 # Why Commit-Boost ## Background -- Proposer commitments have been an important part of Ethereum’s history. Today, we already see the power of commitments where over 90% of validators give up their autonomy and make a wholesale commitment that outsources block building to a sophisticated actor called a block builder. -- However, most are starting to agree on a common denominator: in the future, beacon proposers will face a broader set of options of what they may “commit" to–be it inclusions lists or preconfs or other types of commitments such as long-dated blockspace futures–compared to just an external or local payload they see today. -- A recent post from Barnabe captures this well; during block construction, the validator “…creates the specs, or the template, by which the resulting block must be created, and the builders engaged by the proposer are tasked with delivering the block according to its specifications”. -- While this all seems great, the challenge is that many teams building commitments are creating new sidecars driving fragmentation and risks for Ethereum. -- For Ethereum, there are going to be significant challenges and increased risks during upgrades if there are a handful of sidecars validators are running. -- For validators, these risks potentially take us to a world where proposers will need to make decisions on which teams to “bet on” and which sidecars they will need to run to participate in what those teams are offering. -- For homestakers, this is difficult and they likely will be unable to participate in more than one of these commitments. -- For sophisticated actors, this increases the attack vector and operational complexity as more and more sidecars are required to be run. -- Another side effect of this is validators are somewhat locked into using a specific sidecar due to limited operational capacity and the switching costs of running a different sidecar (i.e., vendor lock-in). The higher the switching costs, the more embedded network effects could become if these sidecars only support certain downstream actors / proposer commitment protocols. -- This also could create a dynamic where core out-of-protocol infrastructure supporting Ethereum which should be a public good, starts being used for monetization, distribution, or other purposes. -- Commit-Boost aims to standardize how proposer commitment protocols communicate with the proposer, by providing a unified interface implemented in a single validator sidecar with the goal of reducing fragmentation. +Proposer commitments have been an important part of Ethereum’s history. Today, we already see the power of commitments where over 90% of validators give up their autonomy and make a wholesale commitment that outsources block building to a sophisticated actor called a block builder. + +Most are starting to agree on a common denominator: in the future, beacon proposers will face a broader set of options of what they may “commit” to (be it inclusion lists, preconfs, or other types of commitments such as long-dated blockspace futures) compared to just an external or local payload they see today. A recent post from Barnabe captures this well; during block construction, the validator “…creates the specs, or the template, by which the resulting block must be created, and the builders engaged by the proposer are tasked with delivering the block according to its specifications”. + +The challenge is that many teams building commitments are creating new sidecars, driving fragmentation and risks for Ethereum. For Ethereum, there are going to be significant challenges and increased risks during upgrades if there are a handful of sidecars validators are running. For validators, these risks potentially take us to a world where proposers will need to make decisions on which teams to “bet on” and which sidecars they will need to run to participate in what those teams are offering. For homestakers, this is difficult and they likely will be unable to participate in more than one of these commitments. For sophisticated actors, this increases the attack vector and operational complexity as more and more sidecars are required to be run. + +Another side effect is that validators are somewhat locked into using a specific sidecar due to limited operational capacity and the switching costs of running a different sidecar (i.e., vendor lock-in). The higher the switching costs, the more embedded network effects could become if these sidecars only support certain downstream actors / proposer commitment protocols. This also could create a dynamic where core out-of-protocol infrastructure supporting Ethereum, which should be a public good, starts being used for monetization, distribution, or other purposes. + +Commit-Boost's answer is a unified interface implemented in a single validator sidecar. ## Goals - Unify behind a software / standard to reduce fragmentation risks for Ethereum and its validators, while ensuring open innovation downstream from the proposer can flourish. - Create a neutral, open-source, public good for the safe development and distribution of proposer commitments protocols. - Provide a well-tested, reliable validator sidecar with support for advanced observability and telemetry. -## Why Commit-Boost? +## Who it serves ### For validators - Run a single sidecar with support for MEV-Boost and other proposer commitments protocols, such as preconfirmations and inclusion lists. From c3f7460c2c84ce443d7bfa8c8472c8ac777b77b4 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 11:53:22 -0700 Subject: [PATCH 19/21] styling, clarifications --- docs/docs/get_started/troubleshooting.md | 174 +++++++++-------------- 1 file changed, 64 insertions(+), 110 deletions(-) diff --git a/docs/docs/get_started/troubleshooting.md b/docs/docs/get_started/troubleshooting.md index 125c824e5..3620d4ae1 100644 --- a/docs/docs/get_started/troubleshooting.md +++ b/docs/docs/get_started/troubleshooting.md @@ -4,24 +4,22 @@ description: Common issues # Troubleshooting -If you find any or have any question, please reach out on [X (Twitter)](https://x.com/Commit_Boost). If there are any security related items, please see [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/SECURITY.md). +If you run into an issue or have a question, please reach out on [X (Twitter)](https://x.com/Commit_Boost). For security related items, please see [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/SECURITY.md). --- -## Symptom → service decision matrix - -Real failures often cascade across service boundaries. Before diving into a specific section, use this table to identify the most likely culprit from the observable symptom. +## Where to start | If you see… | Likely culprit | Start in section | |---|---|---| | Container won't start / exits immediately | Docker (volume, port, image) or missing env vars | [Docker / networking](#docker--networking) | -| HTTP 401 from `POST /signer/*` | JWT auth failure — shared secret mismatch | [Signer Service > JWT auth failures](#jwt-auth-failures) | -| Signer logs a key loading error at startup | Key loading path, format mismatch, or permission error | [Signer Service > Key loading](#key-loading) | +| HTTP 401 from `POST /signer/*` | JWT auth failure (shared secret mismatch) | [Signer service > JWT auth failures](#jwt-auth-failures) | +| Signer logs a key loading error at startup | Key loading path, format mismatch, or permission error | [Signer service > Key loading](#key-loading) | | Module log says `connection refused` reaching signer | Docker networking: wrong URL, port, or network | [Docker / networking > container-to-container connectivity](#container-to-container-connectivity) | -| `POST /reload` returns HTTP 500 or 400 | Reload failure — invalid config (500) or bad body override (400) | [Hot reload > Reload failures](#reload-failures) | +| `POST /reload` returns HTTP 500 or 400 | Reload failure: invalid config (500) or bad body override (400) | [Hot reload > Reload failures](#reload-failures) | | `POST /reload` reverts a previous `POST /revoke_jwt` | Body override not persisted | [Hot reload > Body overrides and footguns](#body-overrides-and-footguns) | -| Module starts but fails all signature requests | Shared secret mismatch, module missing from the signer's config, or clock skew beyond the 10s leeway | [Signer Service > JWT auth failures](#jwt-auth-failures) | -| Module container runs but PBS returns no headers | Relays unreachable or timing game expiring too early | [PBS](#pbs) | +| Module starts but fails all signature requests | Shared secret mismatch, module missing from the signer's config, or clock skew beyond ~5 minutes | [Signer service > JWT auth failures](#jwt-auth-failures) | +| Module container runs but PBS returns no headers | Relays unreachable or timing game expiring too early | [Cascading diagnostics > Scenario 3](#scenario-3-relay-timeout-causes-no-payload-cascade) | | `docker compose` exits with `no such file` | Missing or misnamed config file or env file | [Docker / networking > Init failures](#init-failures) | --- @@ -30,11 +28,11 @@ Real failures often cascade across service boundaries. Before diving into a spec ### Init failures -`commit-boost init --config cb-config.toml` produces `cb.docker-compose.yml`, and `.cb.env`. If you see `no such file` when running Docker Compose: +`commit-boost init --config cb-config.toml` produces `cb.docker-compose.yml` and, when the Signer service is enabled, `.cb.env`. If you see `no such file` when running Docker Compose: -1. **Missing config file** — verify `cb-config.toml` exists in the working directory and is TOML-valid. -2. **Missing env file** — if the Signer Service is enabled, `.cb.env` is created alongside the compose file. Pass it with `--env-file ./.cb.env`. -3. **Wrong path** — the volume bindings in the compose file are relative to the working directory. If you moved the config file after `init`, update the `volumes` entry. +1. Missing config file: verify `cb-config.toml` exists in the working directory and is TOML-valid. +2. Missing env file: if the Signer service is enabled, `.cb.env` is created alongside the compose file. Pass it with `--env-file ./.cb.env`. +3. Wrong path: the volume bindings in the compose file are relative to the working directory. If you moved the config file after `init`, update the `volumes` entry. See the [configuration reference](./configuration.md) for a full field listing and [Docker setup](./running/docker.md#init) for init details. @@ -42,24 +40,24 @@ See the [configuration reference](./configuration.md) for a full field listing a If a container exits immediately after `docker compose up`: -1. **Port conflict** — try a different `[pbs] port` or `[signer] port` in the config, or stop whatever is already using the port. The `docker compose logs` output will show a `bind: address already in use` error. -2. **Missing image** — the config's `docker_image` field must point to a valid image. For local development images (e.g. `test_da_commit`), build them first with `just docker-build-test-modules`. -3. **Volume mount failure** — the config file, keys, and secrets paths must be accessible at runtime. If a path is wrong, the container will exit. Check logs for `file not found`. -4. **Missing environment variables** — services that need `CB_CONFIG`, `CB_SIGNER_JWT`, or `CB_MODULE_ID` will fail to start if these aren't set. Docker containers get these from `.cb.env` (via `--env-file`); native binaries set them on the command line. +1. Port conflict: try a different `[pbs] port` or `[signer] port` in the config, or stop whatever is already using the port. A conflict on a published port shows up in the `docker compose up` output as a `port is already allocated` error from the Docker daemon. +2. Missing image: the config's `docker_image` field must point to a valid image. For local development images (e.g. `test_da_commit`), build them first with `just docker-build-test-modules`. +3. Volume mount failure: the config file, keys, and secrets paths must be accessible at runtime. If a path is wrong, the container will exit. A bad config mount logs `Unable to find config file`. +4. Missing environment variables: services that need `CB_CONFIG`, `CB_SIGNER_JWT`, or `CB_MODULE_ID` will fail to start if these aren't set. In the generated compose file, `CB_CONFIG` and `CB_MODULE_ID` are written inline into each service and only the JWT secret comes from `.cb.env` (via `--env-file`); native binaries set all of them on the command line. Check `docker compose logs` for the specific error message. ### Container-to-container connectivity -Modules connect to the Signer Service over an internal Docker bridge network. If a module logs `connection refused`: +Modules connect to the Signer service over an internal Docker bridge network. If a module logs `connection refused`: -1. **Wrong URL** — modules receive `CB_SIGNER_URL` as an env var. The default is `http://cb_signer:20000`. If you override this, verify the hostname matches the Signer container name (`cb_signer` by default) and the port matches `[signer] port`. -2. **Network isolation** — verify the module's compose service is on the `signer_network` (or whatever network the signer is on). The `init` command sets this up automatically; manual compose edits can break it. -3. **Signer not healthy** — the compose file sets `depends_on: cb_signer: condition: service_healthy`. If the signer fails its health check (e.g., because it can't load keys), dependent modules will never start. Check `docker compose logs cb_signer` first. +1. Wrong URL: modules receive `CB_SIGNER_URL` as an env var. The default is `http://cb_signer:20000`. If you override this, verify the hostname matches the Signer container name (`cb_signer` by default) and the port matches `[signer] port`. +2. Network isolation: verify the module's compose service is on the `signer_network` (or whatever network the signer is on). The `init` command sets this up automatically; manual compose edits can break it. +3. Signer not healthy: the compose file sets `depends_on: cb_signer: condition: service_healthy`. If the signer fails its health check (e.g., because it can't load keys), dependent modules will never start. Check `docker compose logs cb_signer` first. --- -## Signer Service +## Signer service If the signer logs an error at startup or signature requests fail at runtime, the likely causes fall into three categories. @@ -67,22 +65,22 @@ If the signer logs an error at startup or signature requests fail at runtime, th A `401` response from any `POST /signer/*` endpoint means the request's JWT was rejected. -1. **Shared secret mismatch (most common)** — each module authenticates with a JWT derived from a shared secret. The signer's `CB_JWTS` env var and the module's `CB_SIGNER_JWT` env var must carry the **same secret for the same module ID**. Common pitfalls: +1. Shared secret mismatch (most common): each module authenticates with a JWT derived from a shared secret. The signer's `CB_JWTS` env var and the module's `CB_SIGNER_JWT` env var must carry the **same secret for the same module ID**. Common pitfalls: - Typo in the module ID or secret string. - The `.cb.env` file was regenerated (e.g., by re-running `init`) but the running containers still use the old env file. - - A manual override was applied via [`POST /reload` body overrides](#body-overrides-and-footguns) but the environment variable was not updated — after a restart the override is lost. -2. **Clock skew** — the only time-based claim JWT validation checks is `exp` (expiration), with a 10-second leeway. If the signer's system clock differs from the module's clock by more than the leeway, the JWT may appear invalid. -3. **Admin endpoint auth failure** — `POST /reload` and `POST /revoke_jwt` require the admin JWT secret (`CB_SIGNER_ADMIN_JWT` environment variable or `admin_secret` body override). If you get a 401 on these endpoints, check that the admin secret matches. + - A manual override was applied via [`POST /reload` body overrides](#body-overrides-and-footguns) but the environment variable was not updated; after a restart the override is lost. +2. Clock skew: the only time-based claim JWT validation checks is `exp` (expiration), with a 10-second leeway. Tokens are minted with a 5-minute expiration, so a token is rejected only when the signer's clock is ahead of the module's by more than the token lifetime plus the leeway, about 5 minutes 10 seconds. A signer clock running behind the module's never invalidates a token. +3. Admin endpoint auth failure: `POST /reload` and `POST /revoke_jwt` require the admin JWT secret (`CB_SIGNER_ADMIN_JWT` environment variable or `admin_secret` body override). If you get a 401 on these endpoints, check that the admin secret matches. ### Key loading If the signer fails to start with errors about keys: -- **Wrong format** — the `[signer.local.loader] format` must match the actual keystore layout. See the [Signer configuration](./configuration.md#local-signer) for supported formats and their expected file structures. -- **Wrong path** — `keys_path` and `secrets_path` are relative to the container's filesystem, not the host. In Docker, these are volume-mounted from the host; verify the mount paths match what the loader expects. -- **Permission denied** — the signer process runs as a non-root user inside the container. Ensure the mounted keys and secrets are readable by the container user. -- **Proxy store path missing** — if `[signer.local.store]` is configured, the proxy directory must exist and be writable. The signer will fail to start if it cannot create proxy key files. -- **Remote signer unavailable** — for Dirk, the remote signer must be reachable at startup. A timeout or connection error during the initial handshake will cause the signer to exit. +- The `[signer.local.loader] format` must match the actual keystore layout. See the [Signer configuration](./configuration.md#local-signer) for supported formats and their expected file structures. +- `keys_path` and `secrets_path` are relative to the container's filesystem, not the host. In Docker, these are volume-mounted from the host; verify the mount paths match what the loader expects. +- The signer process runs as a non-root user inside the container, so the mounted keys and secrets must be readable by the container user. +- If `[signer.local.store]` is configured, the proxy directory must exist and be writable. The signer will fail to start if it cannot create proxy key files. +- For Dirk, the remote signer must be reachable at startup. A timeout or connection error during the initial handshake will cause the signer to exit. See the [Signer configuration](./configuration.md#signer-service) for a full reference and [Docker setup](./running/docker.md#example-with-pbs-signer-and-a-commit-module) for a working example. @@ -90,9 +88,10 @@ See the [Signer configuration](./configuration.md#signer-service) for a full ref If you enable TLS and the signer fails to start: -1. **Missing certificate files** — the directory set by `path` in `[signer.tls_mode]` (required when `type = "certificate"`; mounted at `/certs` inside the Docker container) must contain `cert.pem` and `key.pem`. They are not generated automatically. See the [TLS section](./configuration.md#tls) for details. -2. **Self-signed certificate** — recommended for testing only. Production setups should use a well-known CA. -3. **Certificate permissions** — the key file must be readable by the signer process (non-root user inside the container). +- The directory set by `path` in `[signer.tls_mode]` (mounted at `/certs` inside the Docker container) is missing `cert.pem` or `key.pem`. +- The key file is not readable by the signer process (a non-root user inside the container). + +See [Configuration > TLS](./configuration.md#tls) for the full certificate contract. --- @@ -102,17 +101,17 @@ If you enable TLS and the signer fails to start: If a commit module logs errors when calling the signer: -1. **Wrong JWT** — the module's `CB_SIGNER_JWT` must match the signer's entry for that module ID. See [JWT auth failures](#jwt-auth-failures) above. -2. **Wrong signer URL** — verify `CB_SIGNER_URL` points to the correct host and port. In Docker, the host is the signer container name (`cb_signer` by default); with native binaries, it is the signer's host IP. -3. **Signer not started** — modules depend on the signer via Docker Compose `depends_on`. If the signer fails to start (e.g., key loading error), dependent modules will never leave the `created` state. -4. **Proxy key generation fails** — if using proxy keys, the signer must have the proxy store configured and writable. Check the signer logs for proxy store errors (e.g. `failed reading proxy dir: ...`). +1. Wrong JWT: the module's `CB_SIGNER_JWT` must match the signer's entry for that module ID. See [JWT auth failures](#jwt-auth-failures) above. +2. Wrong signer URL: verify `CB_SIGNER_URL` points to the correct host and port. In Docker, the host is the signer container name (`cb_signer` by default); with native binaries, it is the signer's host IP. +3. Signer not started: modules depend on the signer via Docker Compose `depends_on`. If the signer fails to start (e.g., key loading error), dependent modules will never leave the `created` state. +4. Proxy key errors: generating proxy keys does not require a proxy store; without one the signer only warns that proxies will not be persisted, and they are lost on restart. If `[signer.local.store]` is configured, the directory must be readable and writable or the signer fails at startup (e.g. `failed reading proxy dir: ...`). ### Module ID mismatch If the signer responds with `401` `unauthorized` to a module's requests (or `404` `module id not found` on `POST /revoke_jwt`): - The `[[modules]]` entry in `cb-config.toml` uses a different `id` than what the module was started with (`CB_MODULE_ID` env var). These must match exactly. -- After adding a new module to the config, you must send [`POST /reload`](#hot-reload) to the signer before starting the module container. Until then, the signer has no record of the new module and will reject its requests. +- After adding a new module to the config, send [`POST /reload`](#hot-reload) to the signer before starting the module container. Until then, the signer has no record of the new module and will reject its requests. `/reload` returns `500` with `JWT secret for module X is missing` if the secret was not in `CB_JWTS` at signer startup; update `.cb.env` and restart. Full mechanism: [Hot reload > Common patterns](./configuration.md#common-patterns). --- @@ -122,84 +121,37 @@ Commit-Boost supports hot-reloading the configuration without restarting contain ### Reload failures -If `POST /reload` returns `500`: - -1. **Invalid TOML** — the config file changed on disk since the service started. If the new content has syntax errors, the reload is rejected and the previous configuration is kept. Check `docker compose logs` for the parse error. -2. **Permission denied** — the service may not be able to re-read the config file if its permissions changed after startup (e.g., file was moved or ownership changed). - -If `POST /reload` returns `400` ("bad request"): - -- **Body override references a non-existent module** — the body fields `jwt_secrets` and `admin_secret` (the "body overrides") accept optional overrides applied on top of the config. If the body references a module ID that does not exist in the config file, the entire reload is rejected. +`500` means the reload was rejected and the previous configuration kept: usually invalid TOML in the changed config file (check `docker compose logs` for the parse error), sometimes a permission error re-reading it. `400` means a body override references a module ID that is not in the config file. Full rules: [Hot reload](./configuration.md#hot-reload). ### Body overrides and footguns -The request body for `POST /reload` accepts two optional fields — collectively called **body overrides** — that are applied on top of the config at runtime but **never persisted to disk**: - -- `jwt_secrets`: a comma-separated list of `=` pairs to override specific module secrets. -- `admin_secret`: a string to override the admin JWT secret. - -Because these are in-memory only, they are lost on container restart. If you rotate a JWT secret via a body override, the environment variable (`CB_JWTS` or the module's `CB_SIGNER_JWT`) still holds the old value. After any restart the signer will fall back to the old secret and authentication will fail until you update the environment variable to match. - -Similarly, if you revoke a module with `POST /revoke_jwt` but leave it in the config, the next `POST /reload` (without a body override) re-adds the module from the config. Always remove revoked modules from `[[modules]]` in the config to make the revocation permanent. - -See the [Hot Reload section in the configuration page](./configuration.md#footguns) for the full list of footguns. +The `POST /reload` body overrides (`jwt_secrets`, `admin_secret`) live only in memory and are lost on restart, and a `/reload` re-adds any module revoked with `POST /revoke_jwt` that is still in the config. Full rules: [Signer service reload](./configuration.md#signer-service-reload). ### Hot reload and custom PBS -Custom PBS services may override the default reload behaviour to parse extra configuration fields. If a custom PBS returns `500` on reload, check the module's documentation for custom reload handling. See the [custom module examples](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/status_api/src/main.rs) for details. +Custom PBS services may override the default reload behavior to parse extra configuration fields. If a custom PBS returns `500` on reload, check the module's documentation for custom reload handling. See the [custom module examples](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/status_api/src/main.rs) for details. --- ## Cascading diagnostics -Failures in one service often propagate to others. When debugging, always check the **upstream dependency first**: +Failures in one service often propagate to others. When debugging, check the upstream dependency first. -### Scenario 1: Signer fails to load keys → all modules fail +### Scenario 1: Signer fails to load keys, all modules fail -``` -Signer can't read keystore - ↓ -Signer health check fails - ↓ -Docker Compose never marks cb_signer as healthy - ↓ -Modules (depends_on: condition: service_healthy) never start - ↓ -Modules that need proposer commitments (proxy key generation, signature requests) get connection refused -``` +A signer that cannot read its keystore fails its health check, so Docker Compose never marks `cb_signer` as healthy and modules guarded by `depends_on: condition: service_healthy` never start. Anything that needs proposer commitments (proxy key generation, signature requests) then gets `connection refused`. -**Diagnosis:** Start with the signer log. A key loading error at the top (e.g. `failed reading proxy dir: ...` or a keystore parse failure) means all downstream failures are consequences. Fix the key loading, then restart. +Start with the signer log. A key loading error at the top (e.g. `failed reading proxy dir: ...` or a keystore parse failure) means all downstream failures are consequences. Fix the key loading, then restart. ### Scenario 2: Config file becomes stale after a restart -``` -Admin rotates JWT secrets via POST /reload body overrides - (overrides are in-memory only) - ↓ -Container crashes or is restarted - ↓ -Signer starts with the old secrets from .cb.env / config file - ↓ -Modules still hold the rotated JWT → 401 on every request -``` - -**Diagnosis:** Look for a pattern where everything worked before a restart, then all modules fail with 401. The fix is to update the environment variable (`.cb.env` or the shell env) to match the rotated secret, then restart cleanly. +Telltale pattern: everything worked until a restart, then all modules fail with 401. Cause: a body-override secret rotation was never persisted (see [Body overrides and footguns](#body-overrides-and-footguns)). Update `.cb.env` to the rotated secret and restart. ### Scenario 3: Relay timeout causes no-payload cascade -``` -One relay becomes slow or unresponsive - ↓ -PBS times out waiting for that relay's header - ↓ -No relay returns a valid bid → PBS returns 204 (no content) to the CL - ↓ -CL falls back to local execution payload → no MEV reward -``` - -If the request itself fails (rather than simply yielding no bids), PBS instead returns `502` (`no payload from relays`). +When a relay becomes slow or unresponsive, PBS times out waiting for that relay's header. If no relay returns a valid bid, PBS returns `204` (no content) to the CL, which falls back to the local execution payload, so there is no MEV reward. If the request itself fails (rather than simply yielding no bids), PBS instead returns `502` (`no payload from relays`). -**Diagnosis:** Check the PBS logs for relay timeout errors (status code `555` or `TIMEOUT_ERROR_CODE_STR`) on a specific relay. Remove or replace that relay in the `[[relays]]` config, then `POST /reload` the PBS. +Check the PBS logs for a specific relay repeatedly timing out: a `get_header` timeout logs `err="Timed Out"` with the relay id, and increments `cb_pbs_relay_status_code_total` with `http_status_code="555"` (the synthetic timeout code; see the [Metrics catalog](./running/metrics-catalog.md)). Remove or replace that relay in the `[[relays]]` config, then `POST /reload` the PBS. --- @@ -210,11 +162,13 @@ If you started the modules correctly you should see the following logs. ### PBS After the service started correctly you should see: -```bash -2025-11-04T14:22:03.118512Z INFO starting PBS service version="0.10.0" commit_hash="f05eefbf652ac5442088bd2b20390d29c23b1c5d" addr=0.0.0.0:18550 chain=Hoodi +```text +2025-11-04T14:22:03.118512Z INFO starting PBS service version="0.10.0-rc4" commit_hash="eeff25750c01f4adfc95fc08d69d541ace8e4087" addr=0.0.0.0:18550 chain=Hoodi ``` -To check that the setup is correct and you are connected to relays, you can trigger manually the `/status` endpoint, by running: +The v0.10.0 release commit self-reports version `0.10.0-rc4`; any other checkout prints its own commit hash and version. + +To check that the setup is correct and you are connected to relays, you can manually trigger the `/status` endpoint, by running: ```bash curl http://0.0.0.0:18550/eth/v1/builder/status -vvv @@ -234,9 +188,9 @@ curl http://0.0.0.0:18550/eth/v1/builder/status -vvv * Connection #0 to host 0.0.0.0 left intact ``` -if now you check the logs, you should see: +If you now check the logs, you should see: -```bash +```text 2025-11-04T14:32:07.634966Z INFO : new request ua="curl/7.81.0" relay_check=true method=/eth/v1/builder/status req_id=62f1c0db-f277-49fa-91e7-a9a1c2b2a6d3 2025-11-04T14:32:07.642992Z INFO : relay check successful method=/eth/v1/builder/status req_id=62f1c0db-f277-49fa-91e7-a9a1c2b2a6d3 2025-11-04T14:32:07.643104Z INFO : Responded with 200 OK in 8 ms method=/eth/v1/builder/status req_id=62f1c0db-f277-49fa-91e7-a9a1c2b2a6d3 @@ -245,12 +199,12 @@ if now you check the logs, you should see: The leading `:` is the (deliberately unnamed) request span; the fields after the message are that span's fields, so every line belonging to one request carries the same `req_id`. -If the sidecar is setup correctly, it will receive and process calls from the CL: +If the sidecar is set up correctly, it will receive and process calls from the CL: #### Register validator This should happen periodically, depending on your validator setup. -```bash +```text 2025-11-04T14:28:37.976534Z INFO : new request ua="Lighthouse/v5.2.1-9e12c21" num_registrations=500 method=/eth/v1/builder/validators req_id=296f662f-0e7a-4f15-be75-55b8ca19ffc0 2025-11-04T14:28:38.819591Z INFO : register validator successful method=/eth/v1/builder/validators req_id=296f662f-0e7a-4f15-be75-55b8ca19ffc0 ``` @@ -258,9 +212,9 @@ This should happen periodically, depending on your validator setup. #### Get header This will only happen if some of your validators have a proposal slot coming up. -```bash -2025-11-04T14:30:24.135376Z INFO : new request ua="Lighthouse/v5.2.1-9e12c21" ms_into_slot=135 method=/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey} req_id=74126c5f-69e6-4961-86a6-6c2597bf15f5 slot=2551052 parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea validator=0x84fc20b09496341f24abfcb6f407e916ecc317497c5b1bba4970e50e96cf5e731b88e51753064c30cb221453bd71aebf -2025-11-04T14:30:25.089477Z INFO : received header value_eth="0.001399518501462470" block_hash=0x0139686e8d251f010153875270256fce6f298d7b3f3f9129179fb86297dffad3 method=/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey} req_id=74126c5f-69e6-4961-86a6-6c2597bf15f5 slot=2551052 parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea validator=0x84fc20b09496341f24abfcb6f407e916ecc317497c5b1bba4970e50e96cf5e731b88e51753064c30cb221453bd71aebf +```text +2025-11-04T14:30:24.135376Z INFO : new request ua="Lighthouse/v5.2.1-9e12c21" ms_into_slot=135 method=/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey} req_id=74126c5f-69e6-4961-86a6-6c2597bf15f5 slot=1671102 parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea validator=0x84fc20b09496341f24abfcb6f407e916ecc317497c5b1bba4970e50e96cf5e731b88e51753064c30cb221453bd71aebf +2025-11-04T14:30:25.089477Z INFO : received header value_eth="0.001399518501462470" block_hash=0x0139686e8d251f010153875270256fce6f298d7b3f3f9129179fb86297dffad3 method=/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey} req_id=74126c5f-69e6-4961-86a6-6c2597bf15f5 slot=1671102 parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea validator=0x84fc20b09496341f24abfcb6f407e916ecc317497c5b1bba4970e50e96cf5e731b88e51753064c30cb221453bd71aebf ``` If no relay returns a usable bid you will see `no header available for slot` instead, and PBS answers the beacon node with a `204`. @@ -268,9 +222,9 @@ If no relay returns a usable bid you will see `no header available for slot` ins #### Submit block This will only happen if you received a header in the previous call, and if the header is higher than the locally built block. -```bash -2025-11-04T14:38:01.409075Z INFO : new request ua="Lighthouse/v5.2.1-9e12c21" ms_into_slot=1409 method=/eth/v1/builder/blinded_blocks req_id=6eb9a04d-6f79-4295-823f-c054582b3599 slot=2549590 block_hash=0xfa135ae6f2bfb32b0a47368f93d69e0a2b3f8b855d917ec61d78e78779edaae6 block_number=2549123 parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea -2025-11-04T14:38:02.910974Z INFO : received unblinded block (v1) method=/eth/v1/builder/blinded_blocks req_id=6eb9a04d-6f79-4295-823f-c054582b3599 slot=2549590 block_hash=0xfa135ae6f2bfb32b0a47368f93d69e0a2b3f8b855d917ec61d78e78779edaae6 block_number=2549123 parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea +```text +2025-11-04T14:30:26.409075Z INFO : new request ua="Lighthouse/v5.2.1-9e12c21" ms_into_slot=2409 method=/eth/v1/builder/blinded_blocks req_id=6eb9a04d-6f79-4295-823f-c054582b3599 slot=1671102 block_hash=0x0139686e8d251f010153875270256fce6f298d7b3f3f9129179fb86297dffad3 block_number=1640231 parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea +2025-11-04T14:30:27.910974Z INFO : received unblinded block (v1) method=/eth/v1/builder/blinded_blocks req_id=6eb9a04d-6f79-4295-823f-c054582b3599 slot=1671102 block_hash=0x0139686e8d251f010153875270256fce6f298d7b3f3f9129179fb86297dffad3 block_number=1640231 parent_hash=0x641c99d6e4f14bf6d268eb2a8c0dc51c7030ab24e384c0e679f2a6b438d298ea ``` -A beacon node calling the v2 route (`POST /eth/v2/builder/blinded_blocks`) logs `received unblinded block (v2)` instead, and PBS answers with a `202` and an empty body. +A beacon node calling the v2 route (`POST /eth/v2/builder/blinded_blocks`) logs `received unblinded block (v2)` instead. From 974dd2ed5bbfe9def3bfd0a44999f17896ffcb79 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 12:02:38 -0700 Subject: [PATCH 20/21] styling, clarifications, v0.11 placeholder, typos --- docs/docs/get_started/configuration.md | 212 ++++++++++++++----------- 1 file changed, 116 insertions(+), 96 deletions(-) diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index 668c45efe..929e9f54f 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -6,8 +6,8 @@ description: Configure Commit-Boost Commit-Boost needs a configuration file detailing all the services that you want to run. Create a `cb-config.toml` and modify it depending on which modules you plan to run. -- For a full explanation of all the fields, check out [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml). -- For some additional examples on config presets, check out [here](https://github.com/Commit-Boost/commit-boost-client/tree/main/examples/configs). +- For a full explanation of all the fields, see the [annotated config example](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml). +- For some additional examples, see the [example config presets](https://github.com/Commit-Boost/commit-boost-client/tree/main/examples/configs). ## Minimal PBS setup on Hoodi @@ -26,16 +26,14 @@ enabled = true ``` :::warning -The relay `url` is not a placeholder you can leave blank. It must be a full absolute URL whose -userinfo part is the relay's BLS public key: an empty string fails to parse, and a URL without a -pubkey is rejected with `invalid BLS pubkey`. Either way the sidecar refuses to start. +`url` must be a full URL containing the relay's BLS pubkey; an empty URL or one without a pubkey (`invalid BLS pubkey`) prevents the sidecar from starting. ::: You can find a list of MEV-Boost Hoodi relays [here](https://github.com/ethstaker/ethstaker-guides/blob/main/MEV-relay-list.md#mev-relay-list-for-hoodi-testnet). -After the sidecar is started, it will expose a port (`18550` in this example), that you need to point your CL to. This may be different depending on which CL you're running, check out [here](https://docs.flashbots.net/flashbots-mev-boost/getting-started/system-requirements#consensus-client-configuration-guides) for a list of configuration guides. +After the sidecar is started, it will expose a port (`18550` in this example), that you need to point your CL to. This may be different depending on which CL you're running; see the [consensus client configuration guides](https://docs.flashbots.net/flashbots-mev-boost/getting-started/system-requirements#consensus-client-configuration-guides). :::note -In this setup, the Signer Service will not be started. +In this setup, the Signer service will not be started. ::: ## Custom chains @@ -54,30 +52,21 @@ chain = { genesis_time_secs = 1695902400, path = "/path/to/spec.json" } chain = { genesis_time_secs = 1695902400, slot_time_secs = 12, genesis_fork_version = "0x01017000", fulu_fork_slot = 5283840, chain_id = 17000 } ``` -All fields of the inline form are required; there are no defaults, and omitting one makes the whole `chain` value fail to parse. +All inline fields are required; omitting one makes the `chain` value fail to parse. -When using the spec-file form, the `CB_CHAIN_SPEC` environment variable can be set to override the spec file path at runtime (see [Binary](./running/binary.md#common)). It has no effect on the other two forms. +With the spec-file form, the `CB_CHAIN_SPEC` environment variable can override the spec file path at runtime (see [Binary](./running/binary.md#common)). ## PBS safety and tuning options -Beyond the basics shown above, the `[pbs]` section supports some additional knobs (see the [annotated config example](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml) for the full list): - -- `relay_check`: whether to forward `get_status` calls to the relays on startup, or skip the check and return `200` immediately. Default: `true`. -- `wait_all_registrations`: whether to wait for all relays to respond to a validator registration before returning, or return after the first successful registration. Default: `true`. -- `timeout_get_header_ms`: timeout, in milliseconds, for the `get_header` call to relays. Must be greater than 0, and must be less than `late_in_slot_time_ms`. Note that the CL also has a timeout (e.g. 1 second), so this should be lower than that to leave some margin for overhead. Default: `950`. -- `timeout_get_payload_ms`: timeout, in milliseconds, for the `submit_blinded_block` (get payload) call to relays. Must be greater than 0. Default: `4000`. -- `timeout_register_validator_ms`: timeout, in milliseconds, for the `register_validator` call to relays. Must be greater than 0. Default: `3000`. -- `skip_sigverify`: whether to skip verification of the relay signature and pubkey in `get_header` responses. Default: `false`. -- `min_bid_eth`: minimum bid in ETH that will be accepted from `get_header`, can be specified as a float or a string for extra precision (e.g. `"0.01"`). Default: `0.0`. -- `late_in_slot_time_ms`: how late, in milliseconds into the slot, is considered "late". This shortens `get_header` timeouts to make sure a header is returned within this deadline; if the CL request arrives later in the slot, fetching headers is skipped to force local building and minimize the risk of a missed slot (see also the timing games notes). Must be greater than 0. Default: `2000`. -- `http_timeout_seconds`: timeout, in seconds, for any HTTP request sent from the PBS module to other services. Default: `10`. +Beyond the basics shown above, the `[pbs]` section supports additional knobs. The [annotated config example](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml) is the full field reference with defaults; the options below carry extra operational context: + +- `timeout_get_header_ms`: timeout, in milliseconds, for the `get_header` call to relays. Must be greater than 0, and must be less than `late_in_slot_time_ms`. The CL also has a timeout (e.g. 1 second), so this should be lower than that to leave some margin for overhead. Default: `950`. +- `late_in_slot_time_ms`: how late, in milliseconds into the slot, is considered "late". This shortens `get_header` timeouts to make sure a header is returned within this deadline; if the CL request arrives later in the slot, fetching headers is skipped to force local building and minimize the risk of a missed slot. Must be greater than 0. Default: `2000`. - `extra_validation_enabled`: whether to enable extra validation of `get_header` responses. If enabled, `rpc_url` must also be set to an Execution Layer RPC on the same chain as the sidecar (this is checked at startup). Default: `false`. -- `register_validator_retry_limit`: maximum number of retries for validator registration requests per relay, must be greater than 0. Default: `3`. -- `validator_registration_batch_size`: maximum number of validators to send to relays in a single registration request. Default: unlimited. -- `mux_registry_refresh_interval_seconds`: for registry-based muxes with [dynamic refreshing](./mux-key-loaders.md#lido-registry) enabled, how often to refresh the list of pubkeys from the registry, in seconds. Must be greater than 0. Default: `384` (one epoch). +- `mux_registry_refresh_interval_seconds`: refresh interval, in seconds, for registry-based muxes with [dynamic refreshing](./mux-key-loaders.md#lido-registry) enabled. Default: `384`. :::warning -`validator_registration_batch_size` used to be a per-relay option. It is now obsolete on a per-relay basis and setting it inside a `[[relays]]` entry makes the sidecar fail at startup: move it to the `[pbs]` section instead. +`validator_registration_batch_size` used to be a per-relay option. It is no longer accepted per relay: setting it inside a `[[relays]]` entry makes the sidecar fail at startup; set it in the `[pbs]` section instead. ::: ### Per-relay options @@ -86,12 +75,27 @@ Each `[[relays]]` entry supports, besides `id` and `url`: - `headers`: optional headers to send with each request to this relay. - `get_params`: optional GET parameters to add to each request URL for this relay. -- `enable_timing_games`: whether to enable timing games for this relay, as tuned by `target_first_request_ms` and `frequency_get_header_ms`. If neither of those is set, this flag has no effect. Timing games should only be used by advanced users: each relay has different latency and timing games setups, and misconfiguration can result in e.g. fetching a lower header value or missing a slot. Default: `false`. +- `get_header` (unreleased, from v0.11): how headers are fetched from this relay, either `"http"` (one request per `get_header`) or `"stream"` (a websocket stream of bid updates, only for relays that support it; see the annotated config example for the stream endpoint and header handshake). Default: `"http"`. Released v0.10.0 does not recognize this option: setting it in a `[[relays]]` entry fails config parsing at startup. +- `enable_timing_games`: whether to enable timing games for this relay, as tuned by `target_first_request_ms` and `frequency_get_header_ms`. If neither of those is set, this flag has no effect. Advanced users only: misconfiguration can result in e.g. fetching a lower header value or missing a slot (caveats and worked examples in the annotated config example). Default: `false`. - `target_first_request_ms`: target time in the slot, in milliseconds, at which to send the first `get_header` request. - `frequency_get_header_ms`: frequency, in milliseconds, at which to send `get_header` requests. The same fields are available on `[[mux.relays]]` entries (see [Mux key loaders](./mux-key-loaders.md)). +#### Header streaming + +:::info Unreleased +This describes behavior on main, unreleased, targeted for v0.11. With `get_header = "stream"`, PBS opens one websocket connection per `get_header` call, keeps the latest bid received until the deadline, then validates and returns it; the timing-game options do not apply while streaming. Any configured `headers` (e.g. an API key) are sent on the websocket handshake. If the connection cannot be established, PBS falls back to a plain HTTP `get_header` with the remaining timeout; a handshake timeout instead surfaces as status `555` in `cb_pbs_relay_status_code_total`, and a relay that rejects the handshake with an HTTP response records that response's own status code. A stream error before any bid arrives yields no header from that relay for the slot, surfaced as `556`; if a bid already arrived, that bid is still returned. +::: + +### SSZ support + +All Builder API requests and responses currently use JSON. + +:::info Unreleased +This describes behavior on main, unreleased, targeted for v0.11: on `get_header` and v1 `submit_blinded_block` requests, PBS negotiates the response encoding with the beacon node through the `Accept` header. Both SSZ and JSON are supported, the response follows the client's `Accept` preference (q-values, then listing order), defaulting to JSON when no preference is expressed, and a request that accepts neither is rejected with `406`. v2 `submit_blinded_block` responses are empty `202`s, so there is nothing to negotiate. Towards relays, PBS always requests SSZ first and falls back to JSON for relays that do not support it. +::: + ## Logs Logging is configured via the optional `[logs.stdout]` and `[logs.file]` sections: @@ -115,32 +119,28 @@ The `CB_LOGS_DIR` environment variable overrides `dir_path` (see [Binary](./runn ## Metrics -Prometheus metrics are configured via the optional `[metrics]` section: +Prometheus metrics are configured via the optional `[metrics]` section; if the section is missing, metrics collection is disabled: ```toml [metrics] enabled = true # Whether to collect metrics. Default: true host = "127.0.0.1" # Host to expose the metrics servers on. Default: 127.0.0.1 -start_port = 10000 # First port to expose metrics on. Default: 10000 +start_port = 10000 # First Prometheus scrape port; each service uses start_port, start_port + 1, ... Default: 10000 ``` -- `enabled`: whether to collect metrics. Default: `true`. -- `host`: host to expose the metrics servers on. Default: `127.0.0.1`. -- `start_port`: the first port that services listen on for Prometheus scrapes. Each service uses this port, then `start_port + 1`, `start_port + 2`, and so on. Default: `10000`. - -The `CB_METRICS_PORT` environment variable overrides the port used by a module at runtime. +The `CB_METRICS_PORT` environment variable overrides the port used by a module at runtime (see [Binary](./running/binary.md#common)). -## Signer Service +## Signer service -Commit-Boost supports both local and remote signers. The Signer Service is responsible for signing the transactions that commit modules generate (***it is not used by the PBS Service***). Please note that only one signer at a time is allowed. +Commit-Boost supports both local and remote signers. The Signer service is responsible for signing the transactions that commit modules generate. It is not used by the default PBS image; custom PBS builds can opt in via `pbs.with_signer`. Only one signer at a time is allowed. The config file must still contain at least one `[[relays]]` entry even on a host that runs only the signer; the shared config schema requires it. ### Local signer -To start a local Signer Service, you need to include its parameters in the config file +To start a local Signer service, you need to include its parameters in the config file: ```toml [pbs] -... +# ... with_signer = true [signer] @@ -149,10 +149,10 @@ port = 20000 [signer.local.loader] format = "lighthouse" keys_path = "/path/to/keys" -secrets_path = "/path/to.secrets" +secrets_path = "/path/to/secrets" ``` -We currently support Lighthouse, Prysm, Teku, Lodestar, and Nimbus's keystores so it's easier to load the keys. We're working on adding support for additional keystores. These are the expected file structures for each format: +Supported keystore formats: Lighthouse, Prysm, Teku, Lodestar, and Nimbus. Expected file structures for each format:

Lighthouse @@ -174,7 +174,7 @@ We currently support Lighthouse, Prysm, Teku, Lodestar, and Nimbus's keystores s ```toml [pbs] -... +# ... with_signer = true [signer] @@ -206,7 +206,7 @@ secrets_path = "secrets" ```toml [pbs] -... +# ... with_signer = true [signer] @@ -238,7 +238,7 @@ secrets_path = "secrets/password.txt" ```toml [pbs] -... +# ... with_signer = true [signer] @@ -269,7 +269,7 @@ secrets_path = "secrets" ```toml [pbs] -... +# ... with_signer = true [signer] @@ -305,7 +305,7 @@ All keys have the same password stored in `secrets/password.txt` #### Config: ```toml [pbs] - ... + # ... with_signer = true [signer] @@ -320,7 +320,7 @@ All keys have the same password stored in `secrets/password.txt` ### Proxy keys store -Proxy keys can be used to sign transactions with a different key than the one used to sign the block. Proxy keys are generated by the Signer Service and authorized by the validator key. Each module can have their own proxy keys, that can be BLS or ECDSA. +Proxy keys can be used to sign transactions with a different key than the one used to sign the block. Proxy keys are generated by the Signer service and authorized by the validator key. Each module can have its own proxy keys, which can be BLS or ECDSA. To persist proxy keys across restarts, you must enable the proxy store in the config file. There are 2 options for this: @@ -366,7 +366,7 @@ Where each `` file contains the following:
ERC2335 -The keys are stored in a ERC-2335 style keystore, along with a password. This way, you can safely share the keys directory as without the password they are useless. +The keys are stored in an ERC-2335-style keystore, along with a password. This way, you can safely share the keys directory: without the password they are useless. #### File structure @@ -410,7 +410,7 @@ You might choose to use an external service to sign the transactions. For now, o #### Dirk -Dirk is a distributed key management system that can be used to sign transactions. In this case the Signer Service is needed as an intermediary between the modules and Dirk. The following parameters are needed: +Dirk is a distributed key management system that can be used to sign transactions. In this case the Signer service is needed as an intermediary between the modules and Dirk. The following parameters are needed: ```toml [signer.dirk] @@ -434,7 +434,7 @@ wallets = ["AnotherWallet", "DistributedWallet"] ``` - `cert_path` and `key_path` are the paths to the client certificate and key used to authenticate with Dirk. -`wallets` is a list of wallets from which the Signer Service will load all accounts as consensus keys. Generated proxy keys will have format `///`, so accounts found with that pattern will be ignored. +- `wallets` is a list of wallets from which the Signer service will load all accounts as consensus keys. Generated proxy keys will have format `///`, so accounts found with that pattern will be ignored. - `secrets_path` is the path to the folder containing the passwords of the generated proxy accounts, which will be stored in `////.pass`. Additionally, you can set a proxy store so that the delegation signatures for generated proxy keys are stored locally. As these signatures are not sensitive, the only supported store type is `File`: @@ -451,11 +451,11 @@ A full example of a config file with Dirk can be found [here](https://github.com ### TLS -By default, the Signer Service runs in **insecure** mode, so its API service uses HTTP without any TLS encryption. This is sufficient for testing or if you're running locally within your machine's isolated Docker network and only intend to access it within the confines of your machine. However, for larger production setups, it's recommended to enable TLS - especially for traffic that spans across multiple machines. +By default the Signer API uses plain HTTP with no TLS. That is fine for testing or a single-machine Docker network; enable TLS for any traffic that crosses machines. To enable TLS, you must first create a **certificate / key pair**. We **strongly advise** using a well-known Certificate Authority to create and sign the certificate and do not recommend using a self-signed certificate / key pair for production environments. -When configuring TLS support, the Signer Service expects a single folder containing: +When configuring TLS support, the Signer service expects a single folder containing: - `cert.pem`: The SSL certificate file signed by a certificate authority, in PEM format - `key.pem`: The private key corresponding to `cert.pem` that will be used for signing TLS traffic, in PEM format @@ -463,19 +463,19 @@ Specifying it is done within Commit-Boost's configuration file using the `[signe ```toml [pbs] -... +# ... with_signer = true [signer] port = 20000 -... +# ... [signer.tls_mode] type = "certificate" path = "path/to/your/cert/folder" ``` -Where `path` is the aforementioned folder. If `[signer.tls_mode]` is omitted, the Signer Service runs in insecure HTTP mode. With `type = "certificate"`, an existing `cert.pem` and `key.pem` must be present at `path` - there is no default path and the files are not auto-generated. +With `type = "certificate"`, an existing `cert.pem` and `key.pem` must be present at `path`; there is no default path and the files are not auto-generated. ### Rate limit @@ -483,15 +483,15 @@ The Signer service implements a rate limit system of 3 failed authentications ev ```toml [signer] -... +# ... jwt_auth_fail_limit = 3 # The amount of failed requests allowed jwt_auth_fail_timeout_seconds = 300 # The time window in seconds ``` -The rate limit is applied to the IP address of the client making the request. By default, the IP is extracted directly from the TCP connection. If you're running the Signer service behind a reverse proxy (e.g. Nginx), you can configure it to extract the IP from a custom HTTP header instead. There're two options: +The rate limit is applied to the IP address of the client making the request. By default, the IP is extracted directly from the TCP connection. If you're running the Signer service behind a reverse proxy (e.g. Nginx), you can configure it to extract the IP from a custom HTTP header instead. There are two options: - `unique`: Provides an HTTP header that contains the IP. This header is expected to appear only once in the request. This is common when using `X-Real-IP`, `True-Client-IP`, etc. If a request has multiple values for this header, it will be considered invalid and rejected. -- `rightmost`: Provides an HTTP header that contains a comma-separated list of IPs. The nth rightmost IP in the list is used. If the header appears multiple times, the last occurrence is used. This is common when using `X-Forwarded-For`. +- `rightmost`: Provides an HTTP header that contains a comma-separated list of IPs. The nth rightmost IP in the list is used. If the header appears multiple times, all occurrences are concatenated in order and the rightmost counting spans the combined list. This is common when using `X-Forwarded-For`. Examples: @@ -508,7 +508,13 @@ header = "X-Forwarded-For" trusted_count = 1 ``` -Note: `trusted_count` is the number of trusted proxies in front of the Signer service, but the last proxy won't add its address, so the number of skipped IPs is `trusted_count - 1`. See [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For#trusted_proxy_count) for more info. +:::note +`trusted_count` is the number of trusted proxies in front of the Signer service, but the last proxy won't add its address, so the number of skipped IPs is `trusted_count - 1`. See [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For#trusted_proxy_count) for more info. +::: + +:::warning +Only enable `[signer.reverse_proxy]` when the signer is reachable exclusively through the trusted proxy: this configuration decides whose IP gets rate-limited, so a directly reachable signer or a wrong `trusted_count` lets a client spoof the header to bypass the limit or lock other clients out. +::: ## Custom module @@ -540,7 +546,7 @@ port = 20000 [signer.local.loader] format = "lighthouse" keys_path = "/path/to/keys" -secrets_path = "/path/to.secrets" +secrets_path = "/path/to/secrets" [metrics] enabled = true @@ -553,40 +559,39 @@ signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b sleep_secs = 5 ``` -A few things to note: - -- We now added a `signer` section which will be used to create the Signer Service. -- There is now a `[[modules]]` section which at a minimum needs to specify the module `id`, `type` and `docker_image`. For modules with type `commit`, which will be used to access the Signer Service and request signatures for preconfs, you will also need to specify the module's unique `signing_id` (see [the proposer commitment documentation](../developing/prop-commit-signing.md)). You can also pass environment variables to the module with `env` (a map of variable name to value) and `env_file` (path to an environment file for the module). Additional parameters needed for the business logic of the module will also be here. +The `[[modules]]` section at a minimum needs to specify the module `id`, `type` and `docker_image`. For modules with type `commit`, which will be used to access the Signer service and request signatures for preconfs, you will also need to specify the module's unique `signing_id` (see [the proposer commitment documentation](../developing/prop-commit-signing.md)). You can also pass environment variables to the module with `env` (a map of variable name to value) and `env_file` (path to an environment file for the module). Additional parameters needed for the business logic of the module will also be here. -To learn more about developing modules, check out [here](../developing/commit-modules.md). +To learn more about developing modules, see [Commit modules](../developing/commit-modules.md). ## Vouch -[Vouch](https://github.com/attestantio/vouch) is a multi-node validator client built by [Attestant](https://www.attestant.io/). Vouch is particular in that it also integrates an MEV-Boost client to interact with relays. The Commit-Boost PBS Service is compatible with the Vouch `blockrelay` since it implements the same Builder-API as relays. For example, depending on your setup and preference, you may want to fetch headers from a given relay using Commit-Boost vs using the built-in Vouch `blockrelay`. +[Vouch](https://github.com/attestantio/vouch) is a multi-node validator client built by [Attestant](https://www.attestant.io/). Vouch is particular in that it also integrates an MEV-Boost client to interact with relays. The Commit-Boost PBS service is compatible with the Vouch `blockrelay` since it implements the same Builder-API as relays. For example, depending on your setup and preference, you may want to fetch headers from a given relay using Commit-Boost vs using the built-in Vouch `blockrelay`. ### Configuration Get familiar on how to set up Vouch [here](https://github.com/attestantio/vouch/blob/master/docs/getting_started.md). -You can setup Commit-Boost with Vouch in two ways. -For simplicity, assume that in Vouch `blockrelay.listen-address: 127.0.0.0:19550` and in Commit-Boost `pbs.port = 18550`. +You can set up Commit-Boost with Vouch in two ways. +For simplicity, assume that in Vouch `blockrelay.listen-address: 127.0.0.1:19550` and in Commit-Boost `pbs.port = 18550`. #### Beacon Node to Vouch -In this setup, the BN Builder-API endpoint will be pointing to the Vouch `blockrelay` (e.g. for Lighthouse you will need the flag `--builder=http://127.0.0.0:19550`). +In this setup, the BN Builder-API endpoint will be pointing to the Vouch `blockrelay` (e.g. for Lighthouse you will need the flag `--builder=http://127.0.0.1:19550`). -Modify the `blockrelay.config` file to add Commit-Boost: +Modify the `blockrelay.config` file to add Commit-Boost to its `relays`: ```json -"relays": { - "http://127.0.0.0:18550": {} +{ + "relays": { + "http://127.0.0.1:18550": {} + } } ``` #### Beacon Node to Commit-Boost -In this setup, the BN Builder-API endpoint will be pointing to the PBS module (e.g. for Lighthouse you will need the flag `--builder=http://127.0.0.0:18550`). +In this setup, the BN Builder-API endpoint will be pointing to the PBS service (e.g. for Lighthouse you will need the flag `--builder=http://127.0.0.1:18550`). This will bypass the `blockrelay` entirely so make sure all relays are properly configured in the `[[relays]]` section. @@ -599,34 +604,51 @@ This approach could also work if you have a multi-beacon-node setup, where some - It's up to you to decide which relays will be connected via Commit-Boost (`[[relays]]` section in the `toml` config) and which via Vouch (additional entries in the `relays` field). Remember that any rate-limit will be shared across the two sidecars, if running on the same machine. - You may occasionally see a `timeout` error during registrations, especially if you're running a large number of validators in the same instance. This can resolve itself as registrations will be cleared later in the epoch when relays are less busy processing other registrations. Alternatively you can also adjust the `builderclient.timeout` option in `.vouch.yml`. -## Hot Reload +## Hot reload + +Commit-Boost can hot-reload `cb-config.toml` without restarting modules: send `POST /reload` to each module you want to reload. + +On the signer, the `/reload` and `/revoke_jwt` endpoints require admin authentication. `CB_SIGNER_ADMIN_JWT` holds the admin *secret* (an HMAC key), not a ready-to-use token: mint a short-lived HS256 admin JWT from it (claims spec: [Signer API > Admin token](../developing/prop-commit-signing.md#admin-token)). Sending the raw secret as the Bearer token fails with `401`. Commit-Boost ships no CLI helper for minting this token today; a few lines of Python do it (deps: `pip install pyjwt "eth-hash[pycryptodome]"`): -Commit-Boost supports hot-reloading the configuration file. This means that you can modify the `cb-config.toml` file and apply the changes without needing to restart the modules. To do this, you need to send a `POST` request to the `/reload` endpoint on each module you want to reload the configuration. On the signer, the `/reload` and `/revoke_jwt` endpoints require the admin JWT (`CB_SIGNER_ADMIN_JWT`) as a Bearer token. In the case the module is running in a Docker container without the port exposed (like the signer), you can use the following command: +```python +import os, time, jwt +from eth_hash.auto import keccak + +secret = os.environ["CB_SIGNER_ADMIN_JWT"] +route = "/reload" +body = b"{}" # the request body you will send + +claims = {"admin": True, "route": route, "exp": int(time.time()) + 30} +if body: + claims["payload_hash"] = "0x" + keccak(body).hex() +print(jwt.encode(claims, secret, algorithm="HS256")) +``` + +Then send the request with the minted token. The signer's `/reload` endpoint requires a JSON body with `Content-Type: application/json` even when no overrides are sent; send an empty JSON object (`{}`) at minimum, and mint the token over the exact body you send. In the case the module is running in a Docker container without the port exposed (like the signer), you can run the request inside the container (assuming the snippet above is saved as `mint_admin_jwt.py`). In the Docker setup the admin secret lives in `.cb.env`, so export it into your shell first: ```bash -docker compose -f cb.docker-compose.yml exec cb_signer curl -X POST -H "Authorization: Bearer $CB_SIGNER_ADMIN_JWT" http://localhost:20000/reload +export $(grep CB_SIGNER_ADMIN_JWT .cb.env) +TOKEN=$(python3 mint_admin_jwt.py) +docker compose -f cb.docker-compose.yml exec cb_signer curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' http://localhost:20000/reload ``` +Passing the minted token on the command line is acceptable only because it expires in 30 seconds and is bound to the route and body; the admin secret itself must never appear on a command line. + ### Automatic reload (PBS only) -In addition to the manual `/reload` endpoint, the PBS Service watches the config file for changes and automatically reloads the configuration whenever the file is modified — no restart or API call needed. If the reload fails (e.g. because of a misconfigured option), it logs a warning and keeps the previous configuration. +In addition to the manual `/reload` endpoint, the PBS service watches the config file for changes and automatically reloads the configuration whenever the file is modified, with no restart or API call needed. If a reload fails (e.g. because of a misconfigured option), the previous configuration is kept: the watcher logs a warning and the `/reload` endpoint returns a 500 error. :::caution Custom PBS binaries - -The file watcher is only started when the PBS service is given a non-empty config path. The stock -PBS binary always passes one, so automatic reload works out of the box. A **custom PBS binary** only -gets it if it passes the real config path to `PbsState::new` — `examples/status_api` passes an empty -`PathBuf`, which disables the watcher. See [Extending PBS](../developing/extending-pbs.md#entry-point). -Note that the manual `POST /reload` endpoint works either way. +Custom PBS binaries only get the file watcher if they pass the real config path to `PbsState::new`; see [Extending PBS](../developing/extending-pbs.md#entry-point). `POST /reload` works either way. ::: -### Signer Service reload +### Signer service reload When the signer receives a reload request it re-reads the configuration file and environment variables, rebuilding its internal state to match: -- **New modules** added to the config are registered with the signer. -- **Removed modules** are dropped from the signer's access list. -- **JWT secrets and admin secret** are reset to their current values from the environment variables. +- New modules added to the config are registered with the signer. +- Removed modules are dropped from the signer's access list. +- JWT secrets and the admin secret are reset to their current values from the environment variables. - Any runtime changes from previous `/revoke_jwt` or `/reload` calls are reverted. The request body accepts 2 optional override parameters, applied on top of the config: @@ -634,32 +656,30 @@ The request body accepts 2 optional override parameters, applied on top of the c - `jwt_secrets`: a comma-separated list of `=` pairs to override specific module secrets. Only modules present in the config can be overridden. - `admin_secret`: a string to override the admin JWT secret. -If the body is empty, the signer state is simply synced to match the config. +With an empty JSON object body (`{}`), the signer state is simply synced to match the config. #### Common patterns **Add a new module without restarting:** +This works only if the module's JWT secret was already included in `CB_JWTS` (a comma-separated list of `=` pairs) when the signer started: the reload re-reads `CB_JWTS` from the signer's own process environment, which is fixed at startup, and fails with a 500 (`internal error`; the signer log shows `JWT secret for module X is missing`) otherwise. For a module whose secret is not yet in `CB_JWTS`, there are two Docker paths: re-run `commit-boost init` and `docker compose up -d` with the new env file so every container is recreated with consistent fresh secrets, or hand-add the module's service to the compose file and append its secret to `CB_JWTS` in `.cb.env` before restarting the signer. Re-running `init` rotates every JWT secret, including the admin secret. 1. Add the `[[modules]]` entry to `cb-config.toml`. -2. Set the new module's JWT secret in the signer's environment (`CB_JWTS`, a comma-separated list of `=` pairs). -3. Send `POST /reload` with an empty body. The signer picks up the new module from config. -4. Start the new module container with the matching JWT secret. +2. Send `POST /reload` with an empty JSON object body (`{}`). The signer picks up the new module from config. +3. Start the new module container with the matching JWT secret. **Rotate a JWT secret remotely:** -Send `POST /reload` with the new secret in the body. The module must already exist in the config. This is useful for scripted rotation without SSH access to edit config files. +Send `POST /reload` with the new secret in the body. The module must already exist in the config. Scripted rotation works without SSH access to edit config files. Unless TLS is enabled (see [TLS](#tls)), the request sends the new secret in cleartext over the network. A secret passed on a curl command line also lands in shell history and the process list; read it from a file or stdin instead. **Revoke a compromised module immediately:** Send `POST /revoke_jwt` with the module ID. This removes the module from the signer's access list without touching the config. The next `/reload` will restore the module if it is still in the config, so remove it from config as well if the revocation should be permanent. #### Footguns -- **Body overrides are not persisted.** If the signer crashes or restarts after a body-based secret rotation, it falls back to the config/environment values. The module container will still have the rotated secret and authentication will fail. To avoid this, update the environment variable to match after rotating via the body. -- **Reload reverts revocations.** If you revoke a module with `/revoke_jwt` but leave it in the config, the next `/reload` without a body override will re-add it from the config baseline. Always remove revoked modules from the config to make the revocation permanent. -- **Override validation is strict.** If the body references a module ID that does not exist in the config, the entire reload request is rejected and no changes are applied. This prevents typos from silently failing. +- Body overrides are not persisted: if the signer crashes or restarts after a body-based secret rotation, it falls back to the config/environment values. The module container will still have the rotated secret and authentication will fail. To avoid this, update the environment variable to match after rotating via the body. +- Override validation is strict: if the body references a module ID that does not exist in the config, the entire reload request is rejected and no changes are applied. This prevents typos from silently failing. ### Notes -- The hot reload feature is available for PBS Service (both default and custom) and Signer Service. Note that the *automatic* file-watching reload additionally requires a non-empty config path (see the caution above); the `/reload` endpoint itself is always available. -- Changes related to listening hosts and ports will not been applied, as it requires the server to be restarted. +- The hot reload feature is available for the PBS service (both default and custom) and the Signer service. +- Changes to listening hosts and ports are not applied, since they require a server restart. - If running in Docker containers, changes in `volumes` will not be applied, as it requires the container to be recreated. Be careful if changing a path to a local file as it may not be accessible from the container. -- Custom PBS Service may override the default behaviour of the hot reload feature to parse extra configuration fields. Check the [examples](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/status_api/src/main.rs) for more details. -- In case the reload fails (most likely because of some misconfigured option), the server will return a 500 error and the previous configuration will be kept. +- Custom PBS services may override the default behavior of the hot reload feature to parse extra configuration fields. Check the [examples](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/status_api/src/main.rs) for more details. From 0c7fc179731c9cd8515c0f6f368cc7dff03e06c6 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 14 Aug 2026 12:08:17 -0700 Subject: [PATCH 21/21] styling, cut down on prose, clarifications --- api/signer-api.yml | 308 +++++++++++++++++++++------------------------ 1 file changed, 146 insertions(+), 162 deletions(-) diff --git a/api/signer-api.yml b/api/signer-api.yml index 931e88699..30b1792a0 100644 --- a/api/signer-api.yml +++ b/api/signer-api.yml @@ -8,21 +8,27 @@ info: ## Authentication All endpoints (except `/status`) require a Bearer JWT in the `Authorization` header. + A missing or malformed `Authorization` header returns `400`, not `401`. ### Module JWT claims HS256-signed token with the module's pre-shared secret (`CB_SIGNER_JWT` env var): - - **module** (string, required) — Module `id` from `[[modules]]` in `cb-config.toml`. - - **route** (string, required) — Exact request path, e.g. `/signer/v1/get_pubkeys`. - - **exp** (integer, required) — UNIX expiry timestamp. Expires after 5 minutes. - - **payload_hash** (string, POST only) — Keccak-256 hash of the JSON request body, `0x`-prefixed. Prevents JWT replay attacks. + - **module** (string, required): Module `id` from `[[modules]]` in `cb-config.toml`. + - **route** (string, required): Exact request path, e.g. `/signer/v1/get_pubkeys`. + - **exp** (integer, required): UNIX expiry timestamp. The SDK issues tokens with a 5-minute expiry; the server only validates that the token is unexpired. + - **payload_hash** (string, POST only): Keccak-256 hash of the JSON request body, `0x`-prefixed. Prevents JWT replay attacks. - Refresh is client-side — the module generates a new JWT locally. No refresh endpoint. + Refresh is client-side: the module generates a new JWT locally. No refresh endpoint. ### Admin JWT - Admin endpoints use a separate secret (`CB_SIGNER_ADMIN_JWT` env var) and include `admin: true` in claims. + Admin endpoints (`/reload`, `/revoke_jwt`) use a separate HS256 secret (`CB_SIGNER_ADMIN_JWT` env var) with the following claims: + + - **admin** (boolean, required): Must be `true`. + - **route** (string, required): Exact request path, `/reload` or `/revoke_jwt`. + - **exp** (integer, required): UNIX expiry timestamp. + - **payload_hash** (string, required): Keccak-256 hash of the JSON request body, `0x`-prefixed. Both admin endpoints require a JSON body, so this claim is required in practice. ### Rate limiting @@ -35,12 +41,7 @@ paths: get: summary: Get a list of public keys for which signatures may be requested description: | - This endpoint requires a valid JWT Bearer token. - - The token **must include** the following claims: - - `exp` (integer): Expiration timestamp - - `route` (string): The route being requested (must be `/signer/v1/get_pubkeys` for this endpoint). - - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. + Requires a module JWT (see Authentication above); the `route` claim must be `/signer/v1/get_pubkeys`. tags: - Signer security: @@ -72,25 +73,25 @@ paths: type: array items: $ref: "#/components/schemas/EcdsaAddress" - "500": - description: Internal error + "400": + description: The request did not include a valid `Authorization` header with a Bearer token, or the client IP could not be determined. content: text/plain: schema: type: string - example: "internal error" + example: "Header of type `authorization` was missing" + "401": + $ref: "#/components/responses/UnauthorizedJwt" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/InternalError" /signer/v1/request_signature/bls: post: summary: Request a signature for a 32-byte blob of data (typically a hash), signed by the BLS private key for the requested public key. description: | - This endpoint requires a valid JWT Bearer token. - - The token **must include** the following claims: - - `exp` (integer): Expiration timestamp - - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. - - `route` (string): The route being requested (must be `/signer/v1/request_signature/bls` for this endpoint). - - `payload_hash` (string): The Keccak-256 hash of the JSON-encoded request body, with optional `0x` prefix. This is required to prevent JWT replay attacks. + Requires a module JWT (see Authentication above); the `route` claim must be `/signer/v1/request_signature/bls`. tags: - Signer security: @@ -104,16 +105,17 @@ paths: required: [pubkey, object_root, nonce] properties: pubkey: - description: The 48-byte BLS public key, with optional `0x` prefix, of the proposer key that you want to request a signature from. + description: The consensus (proposer) pubkey to sign with. $ref: "#/components/schemas/BlsPubkey" object_root: - description: The 32-byte data you want to sign, with optional `0x` prefix. + description: The 32-byte digest to sign. $ref: "#/components/schemas/B256" nonce: $ref: "#/components/schemas/Nonce" example: pubkey: "0xa3ffa9241f78279f1af04644cb8c79c2d8f02bcf0e28e2f186f6dcccac0a869c2be441fda50f0dea895cfce2e53f0989" object_root: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" + nonce: 1 responses: "200": description: A successful signature response. @@ -125,26 +127,22 @@ paths: pubkey: "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" object_root: "0x0123456789012345678901234567890123456789012345678901234567890123" module_signing_id: "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" + nonce: 1 + chain_id: "0x1" signature: "0xa43e623f009e615faa3987368f64d6286a4103de70e9a81d82562c50c91eae2d5d6fb9db9fe943aa8ee42fd92d8210c1149f25ed6aa72a557d74a0ed5646fdd0e8255ec58e3e2931695fe913863ba0cdf90d29f651bce0a34169a6f6ce5b3115" "400": description: | This can occur in several scenarios: - - The request did not include a valid `Authorization` header with a Bearer token (a missing or malformed header is rejected with `400`, not `401`). + - The request did not include a valid `Authorization` header with a Bearer token. - The Commit-Boost configuration file does not contain a signing ID for the module that made the request. - - You requested an operation while using the Dirk signer mode instead of locally-managed signer mode, but Dirk doesn't support that operation. - Something went wrong while preparing your request; the error text will provide more information. content: text/plain: schema: type: string - example: "Dirk signer does not support this operation" + example: "bad request: Module signing ID not found" "401": - description: The JWT provided in the request's authorization header was invalid. For example, it was expired, signed with the wrong secret, or its claims did not match the request. Note that a request with a missing or malformed `Authorization` header is rejected with `400` instead. - content: - text/plain: - schema: - type: string - example: "unauthorized" + $ref: "#/components/responses/UnauthorizedJwt" "404": description: You either requested a route that doesn't exist, or you requested a signature from a key that does not exist. @@ -154,45 +152,19 @@ paths: type: string example: "unknown consensus signer: 0xa9e9cff900de07e295a044789fd4bdb6785eb0651ad282f9e76d12afd87e75180bdd64caf2e315b815d7322bd31ab48a" "422": - description: The request body could not be deserialized. For example, the pubkey was not valid hex of the expected length. - content: - text/plain: - schema: - type: string - example: "Failed to deserialize the JSON body into the target type" + $ref: "#/components/responses/DeserializationFailed" "429": - description: Your module attempted and failed JWT authentication too many times recently, and is currently timed out. It cannot make any more requests until the timeout ends. - content: - text/plain: - schema: - type: string - example: "rate limited for 12.3s" + $ref: "#/components/responses/RateLimited" "500": - description: Your request was valid, but something went wrong internally that prevented it from being fulfilled. - content: - text/plain: - schema: - type: string - example: "internal error" + $ref: "#/components/responses/InternalError" "502": - description: The signer service is running in Dirk signer mode, but Dirk could not be reached. - content: - text/plain: - schema: - type: string - example: "Dirk communication error" + $ref: "#/components/responses/DirkUnreachable" /signer/v1/request_signature/proxy-bls: post: summary: Request a signature for a 32-byte blob of data (typically a hash), signed by the BLS private key for the requested proxy public key. description: | - This endpoint requires a valid JWT Bearer token. - - The token **must include** the following claims: - - `exp` (integer): Expiration timestamp - - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. - - `route` (string): The route being requested (must be `/signer/v1/request_signature/proxy-bls` for this endpoint). - - `payload_hash` (string): The Keccak-256 hash of the JSON-encoded request body, with optional `0x` prefix. This is required to prevent JWT replay attacks. + Requires a module JWT (see Authentication above); the `route` claim must be `/signer/v1/request_signature/proxy-bls`. tags: - Signer security: @@ -206,16 +178,17 @@ paths: required: [proxy, object_root, nonce] properties: proxy: - description: The 48-byte BLS public key, with optional `0x` prefix, of the proxy key that you want to request a signature from. + description: The proxy pubkey to sign with. $ref: "#/components/schemas/BlsPubkey" object_root: - description: The 32-byte data you want to sign, with optional `0x` prefix. + description: The 32-byte digest to sign. $ref: "#/components/schemas/B256" nonce: $ref: "#/components/schemas/Nonce" example: proxy: "0xa3ffa9241f78279f1af04644cb8c79c2d8f02bcf0e28e2f186f6dcccac0a869c2be441fda50f0dea895cfce2e53f0989" object_root: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" + nonce: 1 responses: "200": description: A successful signature response. @@ -227,26 +200,22 @@ paths: pubkey: "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" object_root: "0x0123456789012345678901234567890123456789012345678901234567890123" module_signing_id: "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" + nonce: 1 + chain_id: "0x1" signature: "0xa43e623f009e615faa3987368f64d6286a4103de70e9a81d82562c50c91eae2d5d6fb9db9fe943aa8ee42fd92d8210c1149f25ed6aa72a557d74a0ed5646fdd0e8255ec58e3e2931695fe913863ba0cdf90d29f651bce0a34169a6f6ce5b3115" "400": description: | This can occur in several scenarios: - - The request did not include a valid `Authorization` header with a Bearer token (a missing or malformed header is rejected with `400`, not `401`). + - The request did not include a valid `Authorization` header with a Bearer token. - The Commit-Boost configuration file does not contain a signing ID for the module that made the request. - - You requested an operation while using the Dirk signer mode instead of locally-managed signer mode, but Dirk doesn't support that operation. - Something went wrong while preparing your request; the error text will provide more information. content: text/plain: schema: type: string - example: "Dirk signer does not support this operation" + example: "bad request: Module signing ID not found" "401": - description: The JWT provided in the request's authorization header was invalid. For example, it was expired, signed with the wrong secret, or its claims did not match the request. Note that a request with a missing or malformed `Authorization` header is rejected with `400` instead. - content: - text/plain: - schema: - type: string - example: "unauthorized" + $ref: "#/components/responses/UnauthorizedJwt" "404": description: You either requested a route that doesn't exist, or you requested a signature from a key that does not exist. @@ -254,47 +223,21 @@ paths: text/plain: schema: type: string - example: "unknown consensus signer: 0xa9e9cff900de07e295a044789fd4bdb6785eb0651ad282f9e76d12afd87e75180bdd64caf2e315b815d7322bd31ab48a" + example: "unknown proxy signer: 0xa9e9cff900de07e295a044789fd4bdb6785eb0651ad282f9e76d12afd87e75180bdd64caf2e315b815d7322bd31ab48a" "422": - description: The request body could not be deserialized. For example, the pubkey was not valid hex of the expected length. - content: - text/plain: - schema: - type: string - example: "Failed to deserialize the JSON body into the target type" + $ref: "#/components/responses/DeserializationFailed" "429": - description: Your module attempted and failed JWT authentication too many times recently, and is currently timed out. It cannot make any more requests until the timeout ends. - content: - text/plain: - schema: - type: string - example: "rate limited for 12.3s" + $ref: "#/components/responses/RateLimited" "500": - description: Your request was valid, but something went wrong internally that prevented it from being fulfilled. - content: - text/plain: - schema: - type: string - example: "internal error" + $ref: "#/components/responses/InternalError" "502": - description: The signer service is running in Dirk signer mode, but Dirk could not be reached. - content: - text/plain: - schema: - type: string - example: "Dirk communication error" + $ref: "#/components/responses/DirkUnreachable" /signer/v1/request_signature/proxy-ecdsa: post: summary: Request a signature for a 32-byte blob of data (typically a hash), signed by the ECDSA private key for the requested proxy Ethereum address. description: | - This endpoint requires a valid JWT Bearer token. - - The token **must include** the following claims: - - `exp` (integer): Expiration timestamp - - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. - - `route` (string): The route being requested (must be `/signer/v1/request_signature/proxy-ecdsa` for this endpoint). - - `payload_hash` (string): The Keccak-256 hash of the JSON-encoded request body, with optional `0x` prefix. This is required to prevent JWT replay attacks. + Requires a module JWT (see Authentication above); the `route` claim must be `/signer/v1/request_signature/proxy-ecdsa`. tags: - Signer security: @@ -308,16 +251,17 @@ paths: required: [proxy, object_root, nonce] properties: proxy: - description: The 20-byte Ethereum address, with optional `0x` prefix, of the proxy key that you want to request a signature from. + description: The proxy address to sign with. $ref: "#/components/schemas/EcdsaAddress" object_root: - description: The 32-byte data you want to sign, with optional `0x` prefix. + description: The 32-byte digest to sign. $ref: "#/components/schemas/B256" nonce: $ref: "#/components/schemas/Nonce" example: proxy: "0x71f65e9f6336770e22d148bd5e89b391a1c3b0bb" object_root: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" + nonce: 1 responses: "200": description: A successful signature response. @@ -329,11 +273,13 @@ paths: address: "0x71f65e9f6336770e22d148bd5e89b391a1c3b0bb" object_root: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" module_signing_id: "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" + nonce: 1 + chain_id: "0x1" signature: "0x985b495f49d1b96db3bba3f6c5dd1810950317c10d4c2042bd316f338cdbe74359072e209b85e56ac492092d7860063dd096ca31b4e164ef27e3f8d508e656801c" "400": description: | This can occur in several scenarios: - - The request did not include a valid `Authorization` header with a Bearer token (a missing or malformed header is rejected with `400`, not `401`). + - The request did not include a valid `Authorization` header with a Bearer token. - The Commit-Boost configuration file does not contain a signing ID for the module that made the request. - You requested an operation while using the Dirk signer mode instead of locally-managed signer mode, but Dirk doesn't support that operation. - Something went wrong while preparing your request; the error text will provide more information. @@ -343,12 +289,7 @@ paths: type: string example: "Dirk signer does not support this operation" "401": - description: The JWT provided in the request's authorization header was invalid. For example, it was expired, signed with the wrong secret, or its claims did not match the request. Note that a request with a missing or malformed `Authorization` header is rejected with `400` instead. - content: - text/plain: - schema: - type: string - example: "unauthorized" + $ref: "#/components/responses/UnauthorizedJwt" "404": description: You either requested a route that doesn't exist, or you requested a signature from a key that does not exist. @@ -356,47 +297,19 @@ paths: text/plain: schema: type: string - example: "unknown consensus signer: 0xa9e9cff900de07e295a044789fd4bdb6785eb0651ad282f9e76d12afd87e75180bdd64caf2e315b815d7322bd31ab48a" + example: "unknown proxy signer: 0x71f65e9f6336770e22d148bd5e89b391a1c3b0bb" "422": - description: The request body could not be deserialized — for example, the pubkey was not valid hex of the expected length. - content: - text/plain: - schema: - type: string - example: "Failed to deserialize the JSON body into the target type" + $ref: "#/components/responses/DeserializationFailed" "429": - description: Your module attempted and failed JWT authentication too many times recently, and is currently timed out. It cannot make any more requests until the timeout ends. - content: - text/plain: - schema: - type: string - example: "rate limited for 12.3s" + $ref: "#/components/responses/RateLimited" "500": - description: Your request was valid, but something went wrong internally that prevented it from being fulfilled. - content: - text/plain: - schema: - type: string - example: "internal error" - "502": - description: The signer service is running in Dirk signer mode, but Dirk could not be reached. - content: - text/plain: - schema: - type: string - example: "Dirk communication error" + $ref: "#/components/responses/InternalError" /signer/v1/generate_proxy_key: post: summary: Request a proxy key be generated for a specific consensus pubkey description: | - This endpoint requires a valid JWT Bearer token. - - The token **must include** the following claims: - - `exp` (integer): Expiration timestamp - - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. - - `route` (string): The route being requested (must be `/signer/v1/generate_proxy_key` for this endpoint). - - `payload_hash` (string): The Keccak-256 hash of the JSON-encoded request body, with optional `0x` prefix. This is required to prevent JWT replay attacks. + Requires a module JWT (see Authentication above); the `route` claim must be `/signer/v1/generate_proxy_key`. tags: - Signer security: @@ -463,20 +376,34 @@ paths: delegator: "0xa9e9cff900de07e295a044789fd4bdb6785eb0651ad282f9e76d12afd87e75180bdd64caf2e315b815d7322bd31ab48a" proxy: "0x71f65e9f6336770e22d148bd5e89b391a1c3b0bb" signature: "0xb5b5b71d1701cc45086af3d3d86bf9d3c509442835e5b9f7734923edc9a6c538e743d70613cdef90b7e5b171fbbe6a29075b3f155e4bd66d81ff9dbc3b6d7fa677d169b2ceab727ffa079a31fe1fc0e478752e9da9566a9408e4db24ac6104db" - "404": - description: Unknown value (pubkey, etc.) + "400": + description: | + This can occur in several scenarios: + - The request did not include a valid `Authorization` header with a Bearer token. + - You requested an ECDSA proxy key while using the Dirk signer mode, but Dirk only supports BLS operations. + - Something went wrong while preparing your request; the error text will provide more information. content: text/plain: schema: type: string - example: "unknown consensus signer: 0xa9e9cff900de07e295a044789fd4bdb6785eb0651ad282f9e76d12afd87e75180bdd64caf2e315b815d7322bd31ab48a" - "500": - description: Internal error + example: "Dirk signer does not support this operation" + "401": + $ref: "#/components/responses/UnauthorizedJwt" + "404": + description: Unknown value (pubkey, etc.) content: text/plain: schema: type: string - example: "internal error" + example: "unknown consensus signer: 0xa9e9cff900de07e295a044789fd4bdb6785eb0651ad282f9e76d12afd87e75180bdd64caf2e315b815d7322bd31ab48a" + "422": + $ref: "#/components/responses/DeserializationFailed" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/InternalError" + "502": + $ref: "#/components/responses/DirkUnreachable" /reload: post: @@ -486,7 +413,7 @@ paths: internal state. Accepts optional body overrides for JWT secrets and the admin secret. - **Behaviour:** + **Behavior:** - New modules in config are registered. - Removed modules are dropped from the access list. - JWT secrets and admin secret are reset to env var values. @@ -499,7 +426,7 @@ paths: Body overrides are **not persisted** across restarts. A JSON body with `Content-Type: application/json` is required even when no - overrides are sent — send an empty object `{}`. + overrides are sent; send an empty object `{}`. tags: - Management security: @@ -523,12 +450,18 @@ paths: "200": description: Configuration reloaded successfully "400": - description: Body references a module ID not present in the config + description: The body references a module ID not present in the config, or the request did not include a valid `Authorization` header with a Bearer token. content: text/plain: schema: type: string example: "bad request: Module unknown-module not found in config, cannot override JWT secret" + "401": + $ref: "#/components/responses/UnauthorizedJwt" + "422": + $ref: "#/components/responses/DeserializationFailed" + "429": + $ref: "#/components/responses/RateLimited" "500": description: Failed to reload config (previous state preserved) content: @@ -566,6 +499,15 @@ paths: responses: "200": description: Module access revoked successfully + "400": + description: The request did not include a valid `Authorization` header with a Bearer token. + content: + text/plain: + schema: + type: string + example: "Header of type `authorization` was missing" + "401": + $ref: "#/components/responses/UnauthorizedJwt" "404": description: Module ID not found content: @@ -573,6 +515,10 @@ paths: schema: type: string example: "module id not found" + "422": + $ref: "#/components/responses/DeserializationFailed" + "429": + $ref: "#/components/responses/RateLimited" /status: get: @@ -599,6 +545,42 @@ components: type: http scheme: bearer bearerFormat: JWT + responses: + UnauthorizedJwt: + description: The JWT provided in the request's authorization header was invalid. For example, it was expired, signed with the wrong secret, or its claims did not match the request. + content: + text/plain: + schema: + type: string + example: "unauthorized" + RateLimited: + description: Your client IP failed JWT authentication too many times recently, and is currently timed out. It cannot make any more requests until the timeout ends. + content: + text/plain: + schema: + type: string + example: "rate limited for 12.3" + DeserializationFailed: + description: The request body could not be deserialized. For example, a field was not valid hex of the expected length. + content: + text/plain: + schema: + type: string + example: "Failed to deserialize the JSON body into the target type" + InternalError: + description: Internal server error. The request was valid but could not be fulfilled. + content: + text/plain: + schema: + type: string + example: "internal error" + DirkUnreachable: + description: The signer service is running in Dirk signer mode, but Dirk could not be reached. + content: + text/plain: + schema: + type: string + example: "Dirk communication error" schemas: B256: type: string @@ -645,7 +627,7 @@ components: format: hex example: "0x1" signature: - description: The BLS signature of the Merkle root hash of the provided `object_root` field and the requesting module's Signing ID. For details on this signature, see the [signature structure documentation](https://commit-boost.github.io/commit-boost-client/developing/prop-commit-signing.md#structure-of-a-signature). + description: The BLS signature of the SSZ root over the provided `object_root`, the module's signing ID, the nonce, and the chain ID (`PropCommitSigningInfo`), domain-separated per the [signature structure documentation](https://commit-boost.github.io/commit-boost-client/developing/prop-commit-signing#structure-of-a-signature). $ref: "#/components/schemas/BlsSignature" EcdsaSignatureResponse: type: object @@ -667,16 +649,18 @@ components: format: hex example: "0x1" signature: - description: The ECDSA signature (in Ethereum RSV format) of the Merkle root hash of the provided `object_root` field and the requesting module's Signing ID. For details on this signature, see the [signature structure documentation](https://commit-boost.github.io/commit-boost-client/developing/prop-commit-signing.md#structure-of-a-signature). + description: The ECDSA signature (in Ethereum RSV format) of the SSZ root over the provided `object_root`, the module's signing ID, the nonce, and the chain ID (`PropCommitSigningInfo`), domain-separated per the [signature structure documentation](https://commit-boost.github.io/commit-boost-client/developing/prop-commit-signing#structure-of-a-signature). $ref: "#/components/schemas/EcdsaSignature" Nonce: type: integer description: | Replay-protection nonce, always mixed into the signing root via `PropCommitSigningInfo`. It - must be an unsigned 64-bit integer between 0 and 2^64-2 (18446744073709551614), inclusive. + is an unsigned 64-bit integer. Per the [EIP-2681](https://eips.ethereum.org/EIPS/eip-2681) + convention the maximum is 2^64-2 (18446744073709551614), but the service does not enforce + this cap. Modules that track nonces for replay protection should use a monotonically increasing value per key. Modules that do not use replay protection should always send `0`. minimum: 0 - maximum: 18446744073709551614 + maximum: 18446744073709551615 example: 1