diff --git a/Cargo.lock b/Cargo.lock index 3f49e0be..89bab5f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9756,6 +9756,7 @@ version = "0.4.0" dependencies = [ "alloy-primitives", "anyhow", + "async-trait", "axum 0.8.8", "bitcoin", "bitcoin-light-client-circuit", diff --git a/circuits/proof-builder/src/lib.rs b/circuits/proof-builder/src/lib.rs index 85c3a7c7..5d76ffea 100644 --- a/circuits/proof-builder/src/lib.rs +++ b/circuits/proof-builder/src/lib.rs @@ -200,6 +200,8 @@ pub struct ProofDescResponse { pub struct OperatorProofRequest { pub instance_id: String, pub graph_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gateway_address: Option, pub operator_committed_blockhash: String, pub execution_layer_block_number: i64, pub watchtower_challenge_txids: Vec>, @@ -251,6 +253,8 @@ pub struct OperatorProofResponse { pub struct WatchtowerProofRequest { pub instance_id: String, pub graph_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gateway_address: Option, pub public_key: String, pub challenge_init_txid: String, pub execution_layer_block_number: i64, @@ -266,6 +270,8 @@ pub struct WatchtowerProofResponse { pub struct OperatorProofTimeoutUpdateRequest { pub instance_id: String, pub graph_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gateway_address: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct OperatorProofTimeoutUpdateResponse { @@ -279,6 +285,8 @@ pub struct OperatorProofTimeoutUpdateResponse { pub struct WatchtowerProofTimeoutUpdateRequest { pub instance_id: String, pub graph_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gateway_address: Option, pub public_key: String, } diff --git a/deployment/regtest/proof-builder-rpc/.env.proof-builder-rpc b/deployment/regtest/proof-builder-rpc/.env.proof-builder-rpc index f8f00cd9..5b96d9ab 100644 --- a/deployment/regtest/proof-builder-rpc/.env.proof-builder-rpc +++ b/deployment/regtest/proof-builder-rpc/.env.proof-builder-rpc @@ -21,4 +21,10 @@ ZKM_PROOF_POLL_INTERVAL=2000 # The log directory. ESPLORA_URL=http://localhost:13002 BITCOIN_NETWORK=regtest -DATABASE_URL=sqlite:/tmp/.bitvm-node-sd.sqlite \ No newline at end of file +DATABASE_URL=sqlite:/tmp/.bitvm-node-sd.sqlite + +# GOAT contracts used for live Operator/Watchtower authorization. +GOAT_NETWORK=test +GOAT_CHAIN_URL=https://rpc.testnet3.goat.network +# Proof Builder accepts a comma-separated list; each Node still configures one Gateway address. +GOAT_GATEWAY_CONTRACT_ADDRESS=0x26Aa99d72f5f85D60A1B44773B3897793981B7F7 diff --git a/deployment/regtest/proof-builder-rpc/proof-builder.toml b/deployment/regtest/proof-builder-rpc/proof-builder.toml index 4bd2386b..114bacce 100644 --- a/deployment/regtest/proof-builder-rpc/proof-builder.toml +++ b/deployment/regtest/proof-builder-rpc/proof-builder.toml @@ -1,8 +1,3 @@ -[api_auth] -# Fill these lists with the public keys derived from the deployed nodes' BITVM_SECRET values. -trusted_operator_public_keys = [] -trusted_watchtower_public_keys = [] - [header_chain] enable = true init_input = true diff --git a/node/src/utils.rs b/node/src/utils.rs index 8800c4e0..e6b637b7 100644 --- a/node/src/utils.rs +++ b/node/src/utils.rs @@ -2435,6 +2435,7 @@ pub async fn get_watchtower_commitment( let payload = WatchtowerProofRequest { instance_id: instance_id.to_string(), graph_id: graph_id.to_string(), + gateway_address: Some(env::get_goat_gateway_contract_from_env().to_string()), public_key: env::get_node_pubkey()?.to_string(), challenge_init_txid: challenge_init_txid.0.to_string(), execution_layer_block_number: graph.proceed_withdraw_height, // NOTE: this number may be zero @@ -2743,6 +2744,7 @@ pub async fn get_operator_proof( let payload = OperatorProofRequest { instance_id: instance_id.to_string(), graph_id: graph_id.to_string(), + gateway_address: Some(env::get_goat_gateway_contract_from_env().to_string()), operator_committed_blockhash, execution_layer_block_number: graph.proceed_withdraw_height, watchtower_challenge_txids, @@ -3890,6 +3892,7 @@ pub async fn notify_to_cancel_proof_task( let payload = WatchtowerProofTimeoutUpdateRequest { instance_id: graph.instance_id.to_string(), graph_id: graph.graph_id.to_string(), + gateway_address: Some(env::get_goat_gateway_contract_from_env().to_string()), public_key: get_node_pubkey()?.to_string(), }; let auth_keypair = get_bitvm_key()?; diff --git a/proof-builder-rpc/Cargo.toml b/proof-builder-rpc/Cargo.toml index a98bf087..dfb2d558 100644 --- a/proof-builder-rpc/Cargo.toml +++ b/proof-builder-rpc/Cargo.toml @@ -13,6 +13,7 @@ clap = { workspace = true } futures = { workspace = true } dotenv = { workspace = true } anyhow = { workspace = true } +async-trait = { workspace = true } store = { workspace = true } tokio = { workspace = true, features = ["full"] } tokio-util.workspace = true diff --git a/proof-builder-rpc/README.md b/proof-builder-rpc/README.md index f606ccf4..b2c1eb0f 100644 --- a/proof-builder-rpc/README.md +++ b/proof-builder-rpc/README.md @@ -10,28 +10,48 @@ BITCOIN_NETWORK=testnet4 cargo build -r ## Deployment -Initial parameters are read from `proof-builder.toml` (see [proof-builder.toml](./proof-builder.toml)). After that, they will be loaded from the database. +Initial parameters are read from `proof-builder.toml` (see +[proof-builder.toml.example](./proof-builder.toml.example)). After that, they will be loaded from +the database. Field descriptions for the configuration are available in the circuits documentation: [circuits README](../circuits/README.md). -The four Operator/Watchtower task endpoints require signed requests. Configure the trusted node -public keys before startup; keys may be compressed or x-only secp256k1 public keys: +The four Operator/Watchtower task endpoints require signed requests and live GOAT contract +authorization. Configure the read-only GOAT connection before startup: -```toml -[api_auth] -trusted_operator_public_keys = [""] -trusted_watchtower_public_keys = [""] +```dotenv +GOAT_NETWORK=test +GOAT_CHAIN_URL=https://rpc.testnet3.goat.network +GOAT_GATEWAY_CONTRACT_ADDRESS=, ``` -Both lists are required and must be non-empty. Nodes sign requests with their existing -`BITVM_SECRET`; no private key is configured on the Proof Builder. Deploy signing-capable nodes -before enabling the authenticated Proof Builder so that in-flight proof polling is not rejected. +Proof Builder accepts one or more comma-separated Gateway addresses. All configured Gateways use +the same GOAT network and RPC. It discovers the CommitteeManagement contract through each Gateway +and exits if any configured contract set cannot be initialized. Operator requests require graph +ownership and a matching instance/graph relation. Operator registration and stake are enforced +when graphs are admitted and are not rechecked for existing proof tasks. +Watchtower requests require membership in the current global Watchtower registry and the request +public key must match the authenticated signer. Contract authorization is queried for every +request; failures return HTTP 503 rather than falling back to stale authorization data. + +Each authenticated request selects a configured deployment with the signed `gateway_address` +field. For staged upgrades, the field may be omitted while Proof Builder has exactly one Gateway; +it is required when multiple Gateways are configured. Nodes continue to use one +`GOAT_GATEWAY_CONTRACT_ADDRESS` value and add it to every Operator/Watchtower proof request. + +Nodes sign requests with their existing `BITVM_SECRET`; no GOAT or Bitcoin private key is +configured on the Proof Builder. Deploy signing-capable nodes before enabling the authenticated +Proof Builder so that in-flight proof polling is not rejected. Authenticated requests carry `x-proof-auth-timestamp`, `x-proof-auth-nonce`, `x-proof-auth-public-key`, and `x-proof-auth-signature`. The signature binds the caller role, HTTP method, route, timestamp, nonce, and canonical JSON body. Rust callers should use `proof_builder::api_auth::sign_proof_builder_request`; each retry must generate a new nonce. +To enable multiple Gateways without interrupting proof polling, first upgrade Proof Builder with a +single Gateway, then upgrade all Nodes to send `gateway_address`, and finally configure the +comma-separated Gateway list. + ## Failure recovery Long-running proof tasks (stored in the `long_running_task_proof` table) — such as header-chain, commit-chain, and state-chain proofs — can be recovered from the database. Recovery notes: diff --git a/proof-builder-rpc/proof-builder.toml.example b/proof-builder-rpc/proof-builder.toml.example index c51d34a0..96dce504 100644 --- a/proof-builder-rpc/proof-builder.toml.example +++ b/proof-builder-rpc/proof-builder.toml.example @@ -1,9 +1,3 @@ -[api_auth] -# Required. Accepts compressed (66 hex chars) or x-only (64 hex chars) secp256k1 public keys. -# Populate both lists before starting proof-builder-rpc; empty lists fail closed. -trusted_operator_public_keys = [] -trusted_watchtower_public_keys = [] - [header_chain] enable = false init_input = false diff --git a/proof-builder-rpc/src/api/auth.rs b/proof-builder-rpc/src/api/auth.rs index cd5ea75a..e4cbc8bf 100644 --- a/proof-builder-rpc/src/api/auth.rs +++ b/proof-builder-rpc/src/api/auth.rs @@ -1,36 +1,61 @@ use crate::api::response::ErrorResponse; -use crate::config::TrustedApiKeys; +use alloy_primitives::Address; +use async_trait::async_trait; use axum::Json; use axum::http::{HeaderMap, StatusCode}; +use client::goat_chain::{GOATClient, GraphData}; use proof_builder::api_auth::{ AUTH_NONCE_HEADER, AUTH_PUBLIC_KEY_HEADER, AUTH_SIGNATURE_HEADER, AUTH_TIMESTAMP_HEADER, AUTH_WINDOW_SECS, ProofBuilderAuthRole, normalize_public_key, verify_proof_builder_request_signature, }; +use secp256k1::XOnlyPublicKey; use serde::Serialize; -use std::collections::{HashMap, HashSet}; -use std::sync::Mutex; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use uuid::Uuid; -type AuthResult = Result)>; +pub(crate) type AuthResult = Result)>; +pub(crate) type AuthorizationChains = HashMap>; + +#[async_trait] +pub(crate) trait AuthorizationChain: Send + Sync { + /// Returns the Gateway data registered for one graph. + async fn graph_data(&self, graph_id: &Uuid) -> anyhow::Result; + /// Returns all graph IDs registered under one instance. + async fn graph_ids(&self, instance_id: &Uuid) -> anyhow::Result>; + /// Returns the current global Watchtower registry. + async fn watchtowers(&self) -> anyhow::Result>; +} + +#[async_trait] +impl AuthorizationChain for GOATClient { + async fn graph_data(&self, graph_id: &Uuid) -> anyhow::Result { + self.gateway_get_graph_data(graph_id).await + } + + async fn graph_ids(&self, instance_id: &Uuid) -> anyhow::Result> { + self.gateway_get_graph_ids_by_instance_id(instance_id).await + } + + async fn watchtowers(&self) -> anyhow::Result> { + self.committee_mana_get_watchtowers().await + } +} pub(crate) struct RequestAuthorizer { - trusted_operator_keys: HashSet, - trusted_watchtower_keys: HashSet, + chains: AuthorizationChains, accepted_nonces: Mutex>, } impl RequestAuthorizer { - /// Creates a request authorizer from validated role-specific public keys. - pub(crate) fn new(keys: TrustedApiKeys) -> Self { - Self { - trusted_operator_keys: keys.operator, - trusted_watchtower_keys: keys.watchtower, - accepted_nonces: Mutex::new(HashMap::new()), - } + /// Creates an authorizer backed by live GOAT contract queries. + pub(crate) fn new(chains: AuthorizationChains) -> Self { + Self { chains, accepted_nonces: Mutex::new(HashMap::new()) } } - /// Authenticates and authorizes a request for one Proof Builder role. - pub(crate) fn authorize( + /// Verifies request credentials locally and returns the authenticated signer. + pub(crate) fn authenticate( &self, headers: &HeaderMap, role: ProofBuilderAuthRole, @@ -38,7 +63,7 @@ impl RequestAuthorizer { path: &str, body: &B, claimed_watchtower_public_key: Option<&str>, - ) -> AuthResult<()> { + ) -> AuthResult { let timestamp = required_header(headers, AUTH_TIMESTAMP_HEADER)?; let nonce = required_header(headers, AUTH_NONCE_HEADER)?; let signer_value = required_header(headers, AUTH_PUBLIC_KEY_HEADER)?; @@ -46,14 +71,6 @@ impl RequestAuthorizer { let signer = normalize_public_key(signer_value) .map_err(|_| unauthorized("invalid signer public key"))?; - let trusted = match role { - ProofBuilderAuthRole::Operator => &self.trusted_operator_keys, - ProofBuilderAuthRole::Watchtower => &self.trusted_watchtower_keys, - }; - if !trusted.contains(&signer) { - return Err(forbidden("signer is not trusted for this role")); - } - if let Some(claimed_public_key) = claimed_watchtower_public_key { let claimed = normalize_public_key(claimed_public_key) .map_err(|_| forbidden("invalid watchtower public key"))?; @@ -67,9 +84,79 @@ impl RequestAuthorizer { ) .map_err(|_| unauthorized("invalid request signature"))?; self.record_nonce(&signer.to_string(), nonce)?; + Ok(signer) + } + + /// Checks current graph ownership and instance membership for an Operator. + pub(crate) async fn authorize_operator( + &self, + signer: &XOnlyPublicKey, + gateway_address: Option<&str>, + instance_id: &Uuid, + graph_id: &Uuid, + ) -> AuthResult<()> { + let chain = self.chain_for_gateway(gateway_address)?; + let signer_bytes = signer.serialize(); + let graph_data = chain.graph_data(graph_id).await.map_err(contract_unavailable)?; + if graph_data.operator_pubkey == [0; 32] { + return Err(forbidden("graph is not registered")); + } + if graph_data.operator_pubkey != signer_bytes { + return Err(forbidden("operator signer does not own graph")); + } + + let graph_ids = chain.graph_ids(instance_id).await.map_err(contract_unavailable)?; + if !graph_ids.contains(graph_id) { + return Err(forbidden("graph does not belong to instance")); + } Ok(()) } + /// Checks that the signer is in the current global Watchtower registry. + pub(crate) async fn authorize_watchtower( + &self, + signer: &XOnlyPublicKey, + gateway_address: Option<&str>, + ) -> AuthResult<()> { + let chain = self.chain_for_gateway(gateway_address)?; + let watchtowers = chain.watchtowers().await.map_err(contract_unavailable)?; + if !watchtowers.contains(signer) { + return Err(forbidden("watchtower signer is not registered")); + } + Ok(()) + } + + /// Selects the configured contract set identified by the signed request body. + fn chain_for_gateway( + &self, + gateway_address: Option<&str>, + ) -> AuthResult> { + let gateway_address = match gateway_address { + Some(value) => { + let address = value + .parse::
() + .map_err(|_| bad_request("gateway_address is not a valid EVM address"))?; + if address == Address::ZERO { + return Err(bad_request("gateway_address must not be the zero address")); + } + address + } + None if self.chains.len() == 1 => { + return Ok(self.chains.values().next().expect("one chain is configured").clone()); + } + None => { + return Err(bad_request( + "gateway_address is required when multiple Gateways are configured", + )); + } + }; + + self.chains + .get(&gateway_address) + .cloned() + .ok_or_else(|| forbidden("gateway_address is not configured")) + } + /// Atomically rejects a nonce already accepted from the same signer. fn record_nonce(&self, signer: &str, nonce: &str) -> AuthResult<()> { let now = current_time_secs(); @@ -85,6 +172,11 @@ impl RequestAuthorizer { } } +/// Builds a 400 response for an invalid or ambiguous Gateway selection. +fn bad_request(message: &str) -> (StatusCode, Json) { + auth_error(StatusCode::BAD_REQUEST, message) +} + /// Reads one required UTF-8 authentication header. fn required_header<'a>(headers: &'a HeaderMap, name: &str) -> AuthResult<&'a str> { headers @@ -103,6 +195,12 @@ fn forbidden(message: &str) -> (StatusCode, Json) { auth_error(StatusCode::FORBIDDEN, message) } +/// Converts a failed authorization contract query into a fail-closed response. +fn contract_unavailable(error: anyhow::Error) -> (StatusCode, Json) { + tracing::warn!(error = %error, "GOAT authorization query failed"); + auth_error(StatusCode::SERVICE_UNAVAILABLE, "authorization contract is unavailable") +} + /// Builds a 500 response when the local authentication state cannot be used safely. fn internal_error(message: &str) -> (StatusCode, Json) { auth_error(StatusCode::INTERNAL_SERVER_ERROR, message) @@ -127,12 +225,102 @@ fn current_time_secs() -> i64 { .as_secs() as i64 } +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + use std::collections::{HashMap, HashSet}; + + #[derive(Default)] + struct TestAuthorizationState { + graphs: HashMap, + instance_graphs: HashMap>, + watchtowers: HashSet, + fail_queries: bool, + } + + #[derive(Default)] + pub(crate) struct TestAuthorizationChain { + state: Mutex, + } + + impl TestAuthorizationChain { + pub(crate) fn set_graph(&self, instance_id: Uuid, graph_id: Uuid, owner: XOnlyPublicKey) { + let mut state = self.state.lock().unwrap(); + state.graphs.insert(graph_id, graph_data(owner)); + state.instance_graphs.entry(instance_id).or_default().push(graph_id); + } + + pub(crate) fn set_graph_owner(&self, graph_id: Uuid, owner: XOnlyPublicKey) { + self.state.lock().unwrap().graphs.insert(graph_id, graph_data(owner)); + } + + pub(crate) fn add_watchtower(&self, public_key: XOnlyPublicKey) { + self.state.lock().unwrap().watchtowers.insert(public_key); + } + + pub(crate) fn remove_watchtower(&self, public_key: &XOnlyPublicKey) { + self.state.lock().unwrap().watchtowers.remove(public_key); + } + + pub(crate) fn set_fail_queries(&self, fail_queries: bool) { + self.state.lock().unwrap().fail_queries = fail_queries; + } + } + + #[async_trait] + impl AuthorizationChain for TestAuthorizationChain { + async fn graph_data(&self, graph_id: &Uuid) -> anyhow::Result { + let state = self.state.lock().unwrap(); + ensure_available(&state)?; + Ok(state.graphs.get(graph_id).cloned().unwrap_or_else(empty_graph_data)) + } + + async fn graph_ids(&self, instance_id: &Uuid) -> anyhow::Result> { + let state = self.state.lock().unwrap(); + ensure_available(&state)?; + Ok(state.instance_graphs.get(instance_id).cloned().unwrap_or_default()) + } + + async fn watchtowers(&self) -> anyhow::Result> { + let state = self.state.lock().unwrap(); + ensure_available(&state)?; + Ok(state.watchtowers.iter().copied().collect()) + } + } + + fn ensure_available(state: &TestAuthorizationState) -> anyhow::Result<()> { + anyhow::ensure!(!state.fail_queries, "authorization query failed"); + Ok(()) + } + + fn graph_data(owner: XOnlyPublicKey) -> GraphData { + GraphData { operator_pubkey: owner.serialize(), ..empty_graph_data() } + } + + fn empty_graph_data() -> GraphData { + GraphData { + operator_pubkey_prefix: 0, + operator_pubkey: [0; 32], + pegin_txid: [0; 32], + kickoff_txid: [0; 32], + take1_txid: [0; 32], + take2_txid: [0; 32], + watchtower_challenge_init_txid: [0; 32], + prover_assert_txid: [0; 32], + disprove_txids: vec![], + watchtower_challenge_timeout_txids: vec![], + operator_challenge_nack_txids: vec![], + operator_commit_timeout_txid: [0; 32], + } + } +} + #[cfg(test)] mod tests { + use super::test_support::TestAuthorizationChain; use super::*; use proof_builder::api_auth::{ProofBuilderAuthHeaders, sign_proof_builder_request}; use secp256k1::{Keypair, SECP256K1}; - use serde::Serialize; #[derive(Serialize)] struct TestBody { @@ -144,6 +332,15 @@ mod tests { Keypair::from_seckey_slice(SECP256K1, &[seed; 32]).unwrap() } + fn gateway(seed: u8) -> Address { + Address::from_slice(&[seed; 20]) + } + + fn chains(gateway: Address, chain: Arc) -> AuthorizationChains { + let chain: Arc = chain; + HashMap::from([(gateway, chain)]) + } + fn headers(values: &ProofBuilderAuthHeaders) -> HeaderMap { let mut headers = HeaderMap::new(); for (name, value) in values.to_header_pairs() { @@ -152,18 +349,11 @@ mod tests { headers } - fn authorizer(operator: &Keypair, watchtower: &Keypair) -> RequestAuthorizer { - RequestAuthorizer::new(TrustedApiKeys { - operator: HashSet::from([operator.x_only_public_key().0]), - watchtower: HashSet::from([watchtower.x_only_public_key().0]), - }) - } - #[test] - fn accepts_trusted_signer_once_and_rejects_replay() { + fn authenticates_valid_signer_once_and_rejects_replay() { let operator = keypair(7); - let watchtower = keypair(9); - let authorizer = authorizer(&operator, &watchtower); + let authorizer = + RequestAuthorizer::new(chains(gateway(1), Arc::new(TestAuthorizationChain::default()))); let body = TestBody { public_key: operator.public_key().to_string(), value: 1 }; let signed = sign_proof_builder_request( &operator, @@ -177,7 +367,7 @@ mod tests { assert!( authorizer - .authorize( + .authenticate( &headers, ProofBuilderAuthRole::Operator, "POST", @@ -189,7 +379,7 @@ mod tests { ); assert_eq!( authorizer - .authorize( + .authenticate( &headers, ProofBuilderAuthRole::Operator, "POST", @@ -203,55 +393,94 @@ mod tests { ); } - #[test] - fn rejects_cross_role_and_mismatched_watchtower_identity() { - let operator = keypair(7); - let watchtower = keypair(9); - let other_watchtower = keypair(11); - let authorizer = authorizer(&operator, &watchtower); - let operator_body = TestBody { public_key: watchtower.public_key().to_string(), value: 1 }; - let cross_role = sign_proof_builder_request( - &watchtower, - ProofBuilderAuthRole::Operator, - "POST", - "/v1/proofs/operator_proofs", - &operator_body, - ) - .unwrap(); + #[tokio::test] + async fn operator_authorization_checks_graph_owner() { + let operator = keypair(7).x_only_public_key().0; + let other_operator = keypair(8).x_only_public_key().0; + let instance_id = Uuid::new_v4(); + let graph_id = Uuid::new_v4(); + let chain = Arc::new(TestAuthorizationChain::default()); + let authorizer = RequestAuthorizer::new(chains(gateway(1), chain.clone())); + assert_eq!( authorizer - .authorize( - &headers(&cross_role), - ProofBuilderAuthRole::Operator, - "POST", - "/v1/proofs/operator_proofs", - &operator_body, - None, - ) + .authorize_operator(&operator, None, &instance_id, &graph_id) + .await + .unwrap_err() + .0, + StatusCode::FORBIDDEN + ); + + chain.set_graph_owner(graph_id, other_operator); + assert_eq!( + authorizer + .authorize_operator(&operator, None, &instance_id, &graph_id) + .await + .unwrap_err() + .0, + StatusCode::FORBIDDEN + ); + + chain.set_graph(instance_id, graph_id, operator); + assert!( + authorizer.authorize_operator(&operator, None, &instance_id, &graph_id).await.is_ok() + ); + } + + #[tokio::test] + async fn operator_authorization_rejects_instance_mismatch() { + let operator = keypair(7).x_only_public_key().0; + let instance_id = Uuid::new_v4(); + let other_instance_id = Uuid::new_v4(); + let graph_id = Uuid::new_v4(); + let chain = Arc::new(TestAuthorizationChain::default()); + chain.set_graph(other_instance_id, graph_id, operator); + let authorizer = RequestAuthorizer::new(chains(gateway(1), chain.clone())); + + assert_eq!( + authorizer + .authorize_operator(&operator, None, &instance_id, &graph_id) + .await .unwrap_err() .0, StatusCode::FORBIDDEN ); + } - let watchtower_body = - TestBody { public_key: other_watchtower.public_key().to_string(), value: 1 }; + #[tokio::test] + async fn watchtower_authorization_tracks_registry_and_binds_request_identity() { + let watchtower_keypair = keypair(9); + let watchtower = watchtower_keypair.x_only_public_key().0; + let other_watchtower = keypair(11); + let chain = Arc::new(TestAuthorizationChain::default()); + chain.add_watchtower(watchtower); + let authorizer = RequestAuthorizer::new(chains(gateway(1), chain.clone())); + + assert!(authorizer.authorize_watchtower(&watchtower, None).await.is_ok()); + chain.remove_watchtower(&watchtower); + assert_eq!( + authorizer.authorize_watchtower(&watchtower, None).await.unwrap_err().0, + StatusCode::FORBIDDEN + ); + + let body = TestBody { public_key: other_watchtower.public_key().to_string(), value: 1 }; let signed = sign_proof_builder_request( - &watchtower, + &watchtower_keypair, ProofBuilderAuthRole::Watchtower, "POST", "/v1/proofs/watchtower_proofs", - &watchtower_body, + &body, ) .unwrap(); assert_eq!( authorizer - .authorize( + .authenticate( &headers(&signed), ProofBuilderAuthRole::Watchtower, "POST", "/v1/proofs/watchtower_proofs", - &watchtower_body, - Some(&watchtower_body.public_key), + &body, + Some(&body.public_key), ) .unwrap_err() .0, diff --git a/proof-builder-rpc/src/api/mod.rs b/proof-builder-rpc/src/api/mod.rs index 1a4463cd..1758e4d2 100644 --- a/proof-builder-rpc/src/api/mod.rs +++ b/proof-builder-rpc/src/api/mod.rs @@ -12,7 +12,6 @@ use crate::api::proof_handler::{ post_watchtower_proof_task, update_operator_proof_task_timeout, update_watchtower_proof_task_timeout, }; -use crate::config::TrustedApiKeys; use axum::http::{Method, StatusCode}; use axum::routing::{get, post}; use axum::{Router, middleware}; @@ -22,6 +21,8 @@ use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; use tower_http::cors::{Any, CorsLayer}; +pub(crate) use auth::{AuthorizationChain, AuthorizationChains}; + struct ApiState { pub local_db: LocalDB, pub metrics_state: ApiMetricsState, @@ -29,16 +30,16 @@ struct ApiState { } impl ApiState { - /// Creates shared API state from the database, metrics, and trusted caller keys. + /// Creates shared API state from the database, metrics, and live authorization chain. fn new( local_db: LocalDB, metrics_state: ApiMetricsState, - trusted_api_keys: TrustedApiKeys, + authorization_chains: AuthorizationChains, ) -> Arc { Arc::new(ApiState { local_db, metrics_state, - auth: RequestAuthorizer::new(trusted_api_keys), + auth: RequestAuthorizer::new(authorization_chains), }) } } @@ -46,10 +47,10 @@ pub(crate) async fn serve( addr: String, local_db: LocalDB, metrics_state: ApiMetricsState, - trusted_api_keys: TrustedApiKeys, + authorization_chains: AuthorizationChains, cancellation_token: CancellationToken, ) -> anyhow::Result { - let api_state = ApiState::new(local_db, metrics_state, trusted_api_keys); + let api_state = ApiState::new(local_db, metrics_state, authorization_chains); let instrumented_routes = Router::new() .route(routes::ROOT, get(root)) .route(routes::v1::PROOFS_CHAIN_PROOFS_DESC, get(get_chain_proof_task_desc)) @@ -99,6 +100,10 @@ async fn root() -> &'static str { #[cfg(test)] mod tests { use super::*; + use crate::api::auth::{ + AuthorizationChain, AuthorizationChains, test_support::TestAuthorizationChain, + }; + use alloy_primitives::Address; use proof_builder::api_auth::{ ProofBuilderAuthHeaders, ProofBuilderAuthRole, sign_proof_builder_request, }; @@ -107,8 +112,8 @@ mod tests { WatchtowerProofTimeoutUpdateRequest, }; use secp256k1::{Keypair, SECP256K1}; - use std::collections::HashSet; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use uuid::Uuid; fn available_addr() -> String { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); @@ -161,11 +166,16 @@ mod tests { Keypair::from_seckey_slice(SECP256K1, &[seed; 32]).unwrap() } - fn trusted_api_keys(operator: &Keypair, watchtower: &Keypair) -> TrustedApiKeys { - TrustedApiKeys { - operator: HashSet::from([operator.x_only_public_key().0]), - watchtower: HashSet::from([watchtower.x_only_public_key().0]), - } + fn gateway(seed: u8) -> Address { + Address::from_slice(&[seed; 20]) + } + + fn authorization_chains( + gateway: Address, + chain: Arc, + ) -> AuthorizationChains { + let chain: Arc = chain; + std::collections::HashMap::from([(gateway, chain)]) } #[tokio::test] @@ -177,7 +187,7 @@ mod tests { addr.clone(), store::create_local_db("sqlite::memory:").await, ApiMetricsState::new(), - trusted_api_keys(&keypair(7), &keypair(9)), + authorization_chains(gateway(1), Arc::new(TestAuthorizationChain::default())), server_token, )); tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -211,6 +221,16 @@ mod tests { let operator = keypair(7); let watchtower = keypair(9); let other_watchtower = keypair(11); + let instance_id = "00112233-4455-6677-8899-aabbccddeeff".to_string(); + let graph_id = "11112233-4455-6677-8899-aabbccddeeff".to_string(); + let authorization_chain = Arc::new(TestAuthorizationChain::default()); + authorization_chain.set_graph( + Uuid::parse_str(&instance_id)?, + Uuid::parse_str(&graph_id)?, + operator.x_only_public_key().0, + ); + authorization_chain.add_watchtower(watchtower.x_only_public_key().0); + let gateway_address = gateway(1); let addr = available_addr(); let cancellation_token = CancellationToken::new(); let server_token = cancellation_token.clone(); @@ -218,16 +238,15 @@ mod tests { addr.clone(), store::create_local_db("sqlite::memory:").await, ApiMetricsState::new(), - trusted_api_keys(&operator, &watchtower), + authorization_chains(gateway_address, authorization_chain), server_token, )); tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let instance_id = "00112233-4455-6677-8899-aabbccddeeff".to_string(); - let graph_id = "11112233-4455-6677-8899-aabbccddeeff".to_string(); let operator_submit = OperatorProofRequest { instance_id: instance_id.clone(), graph_id: graph_id.clone(), + gateway_address: Some(gateway_address.to_string()), operator_committed_blockhash: "11".repeat(32), execution_layer_block_number: 1, watchtower_challenge_txids: vec![], @@ -262,6 +281,7 @@ mod tests { let operator_timeout = OperatorProofTimeoutUpdateRequest { instance_id: instance_id.clone(), graph_id: graph_id.clone(), + gateway_address: None, }; let operator_timeout_body = serde_json::to_string(&operator_timeout)?; let wrong_role_auth = sign_proof_builder_request( @@ -301,6 +321,7 @@ mod tests { let watchtower_submit = WatchtowerProofRequest { instance_id: instance_id.clone(), graph_id: graph_id.clone(), + gateway_address: Some(gateway_address.to_string()), public_key: watchtower.public_key().to_string(), challenge_init_txid: "33".repeat(32), execution_layer_block_number: 1, @@ -327,6 +348,7 @@ mod tests { let watchtower_timeout = WatchtowerProofTimeoutUpdateRequest { instance_id, graph_id, + gateway_address: Some(gateway_address.to_string()), public_key: watchtower.public_key().to_string(), }; let watchtower_timeout_body = serde_json::to_string(&watchtower_timeout)?; @@ -374,4 +396,113 @@ mod tests { server.await??; Ok(()) } + + #[tokio::test] + async fn multi_gateway_routes_reject_missing_invalid_unknown_and_wrong_deployment() + -> anyhow::Result<()> { + let operator = keypair(7); + let instance_id = "00112233-4455-6677-8899-aabbccddeeff".to_string(); + let graph_id = "11112233-4455-6677-8899-aabbccddeeff".to_string(); + let gateway_a = gateway(1); + let gateway_b = gateway(2); + let gateway_unknown = gateway(3); + let chain_a = Arc::new(TestAuthorizationChain::default()); + chain_a.set_graph( + Uuid::parse_str(&instance_id)?, + Uuid::parse_str(&graph_id)?, + operator.x_only_public_key().0, + ); + let chain_a: Arc = chain_a; + let chain_b: Arc = Arc::new(TestAuthorizationChain::default()); + let authorization_chains = + std::collections::HashMap::from([(gateway_a, chain_a), (gateway_b, chain_b)]); + let addr = available_addr(); + let cancellation_token = CancellationToken::new(); + let server_token = cancellation_token.clone(); + let server = tokio::spawn(serve( + addr.clone(), + store::create_local_db("sqlite::memory:").await, + ApiMetricsState::new(), + authorization_chains, + server_token, + )); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + for (gateway_address, expected_status) in [ + (None, "400"), + (Some("invalid".to_string()), "400"), + (Some(gateway_unknown.to_string()), "403"), + (Some(gateway_b.to_string()), "403"), + (Some(gateway_a.to_string()), "200"), + ] { + let request = OperatorProofTimeoutUpdateRequest { + instance_id: instance_id.clone(), + graph_id: graph_id.clone(), + gateway_address, + }; + let body = serde_json::to_string(&request)?; + let auth = sign_proof_builder_request( + &operator, + ProofBuilderAuthRole::Operator, + "POST", + routes::v1::PROOFS_OPERATOR_PROOF_TIMEOUT, + &request, + )?; + let response = + post(&addr, routes::v1::PROOFS_OPERATOR_PROOF_TIMEOUT, &body, Some(&auth)).await?; + assert!(response.starts_with(&format!("HTTP/1.1 {expected_status}"))); + } + + let metrics = get(&addr, "/metrics").await?; + assert!(metrics.contains("operation=\"operator_timeout\",result=\"invalid\"")); + assert!(metrics.contains("operation=\"operator_timeout\",result=\"unauthorized\"")); + + cancellation_token.cancel(); + server.await??; + Ok(()) + } + + #[tokio::test] + async fn authorization_query_failure_returns_503_and_records_unavailable() -> anyhow::Result<()> + { + let operator = keypair(7); + let authorization_chain = Arc::new(TestAuthorizationChain::default()); + authorization_chain.set_fail_queries(true); + let gateway_address = gateway(1); + let addr = available_addr(); + let cancellation_token = CancellationToken::new(); + let server_token = cancellation_token.clone(); + let server = tokio::spawn(serve( + addr.clone(), + store::create_local_db("sqlite::memory:").await, + ApiMetricsState::new(), + authorization_chains(gateway_address, authorization_chain), + server_token, + )); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let request = OperatorProofTimeoutUpdateRequest { + instance_id: "00112233-4455-6677-8899-aabbccddeeff".to_string(), + graph_id: "11112233-4455-6677-8899-aabbccddeeff".to_string(), + gateway_address: Some(gateway_address.to_string()), + }; + let body = serde_json::to_string(&request)?; + let auth = sign_proof_builder_request( + &operator, + ProofBuilderAuthRole::Operator, + "POST", + routes::v1::PROOFS_OPERATOR_PROOF_TIMEOUT, + &request, + )?; + let response = + post(&addr, routes::v1::PROOFS_OPERATOR_PROOF_TIMEOUT, &body, Some(&auth)).await?; + assert!(response.starts_with("HTTP/1.1 503")); + + let metrics = get(&addr, "/metrics").await?; + assert!(metrics.contains("operation=\"operator_timeout\",result=\"unavailable\"")); + + cancellation_token.cancel(); + server.await??; + Ok(()) + } } diff --git a/proof-builder-rpc/src/api/proof_handler.rs b/proof-builder-rpc/src/api/proof_handler.rs index 41745247..50e750c1 100644 --- a/proof-builder-rpc/src/api/proof_handler.rs +++ b/proof-builder-rpc/src/api/proof_handler.rs @@ -1,4 +1,5 @@ use crate::api::ApiState; +use crate::api::auth::AuthResult; use crate::api::metrics_service::ApiResultGuard; use crate::api::response::{ApiErrorExt, ApiResult, ok_response}; use crate::api::routes; @@ -9,7 +10,7 @@ use crate::task::{ }; use axum::Json; use axum::extract::{Query, State}; -use axum::http::HeaderMap; +use axum::http::{HeaderMap, StatusCode}; use proof_builder::api_auth::ProofBuilderAuthRole; use proof_builder::{ ChainProofDescRequest, OperatorProofDescRequest, OperatorProofRequest, OperatorProofResponse, @@ -26,12 +27,15 @@ fn record_invalid(api_result: &mut ApiResultGuard, result: Result) - result.inspect_err(|_| api_result.set("invalid")) } -/// Marks authentication failures as unauthorized API results while preserving the response. -fn record_unauthorized( - api_result: &mut ApiResultGuard, - result: Result, -) -> Result { - result.inspect_err(|_| api_result.set("unauthorized")) +/// Records local authorization failures separately from unavailable chain queries. +fn record_auth_result(api_result: &mut ApiResultGuard, result: AuthResult) -> AuthResult { + result.inspect_err(|(status, _)| { + api_result.set(match *status { + StatusCode::BAD_REQUEST => "invalid", + StatusCode::SERVICE_UNAVAILABLE => "unavailable", + _ => "unauthorized", + }); + }) } #[axum::debug_handler] @@ -185,9 +189,9 @@ pub(super) async fn post_operator_proof_task( Json(payload): Json, ) -> ApiResult { let mut api_result = api_state.metrics_state.api_result("operator_submit"); - record_unauthorized( + let signer = record_auth_result( &mut api_result, - api_state.auth.authorize( + api_state.auth.authenticate( &headers, ProofBuilderAuthRole::Operator, "POST", @@ -204,6 +208,18 @@ pub(super) async fn post_operator_proof_task( &mut api_result, InputValidator::validate_uuid(&payload.graph_id, "graph_id"), )?; + record_auth_result( + &mut api_result, + api_state + .auth + .authorize_operator( + &signer, + payload.gateway_address.as_deref(), + &instance_id, + &graph_id, + ) + .await, + )?; let operator_proof = find_operator_task(&api_state.local_db, instance_id, graph_id) .await .api_error("POST_OPERATOR_PROOF_TASK_ERROR")?; @@ -266,9 +282,9 @@ pub(super) async fn update_operator_proof_task_timeout( Json(payload): Json, ) -> ApiResult { let mut api_result = api_state.metrics_state.api_result("operator_timeout"); - record_unauthorized( + let signer = record_auth_result( &mut api_result, - api_state.auth.authorize( + api_state.auth.authenticate( &headers, ProofBuilderAuthRole::Operator, "POST", @@ -285,6 +301,18 @@ pub(super) async fn update_operator_proof_task_timeout( &mut api_result, InputValidator::validate_uuid(&payload.graph_id, "graph_id"), )?; + record_auth_result( + &mut api_result, + api_state + .auth + .authorize_operator( + &signer, + payload.gateway_address.as_deref(), + &instance_id, + &graph_id, + ) + .await, + )?; match update_operator_task_state( &api_state.local_db, instance_id, @@ -322,9 +350,9 @@ pub(super) async fn post_watchtower_proof_task( Json(payload): Json, ) -> ApiResult { let mut api_result = api_state.metrics_state.api_result("watchtower_submit"); - record_unauthorized( + let signer = record_auth_result( &mut api_result, - api_state.auth.authorize( + api_state.auth.authenticate( &headers, ProofBuilderAuthRole::Watchtower, "POST", @@ -341,6 +369,10 @@ pub(super) async fn post_watchtower_proof_task( &mut api_result, InputValidator::validate_uuid(&payload.graph_id, "graph_id"), )?; + record_auth_result( + &mut api_result, + api_state.auth.authorize_watchtower(&signer, payload.gateway_address.as_deref()).await, + )?; let challenge_init_txid = record_invalid( &mut api_result, InputValidator::validate_btc_txid(&payload.challenge_init_txid, "challenge_init_txid"), @@ -408,9 +440,9 @@ pub(super) async fn update_watchtower_proof_task_timeout( Json(payload): Json, ) -> ApiResult { let mut api_result = api_state.metrics_state.api_result("watchtower_timeout"); - record_unauthorized( + let signer = record_auth_result( &mut api_result, - api_state.auth.authorize( + api_state.auth.authenticate( &headers, ProofBuilderAuthRole::Watchtower, "POST", @@ -427,6 +459,10 @@ pub(super) async fn update_watchtower_proof_task_timeout( &mut api_result, InputValidator::validate_uuid(&payload.graph_id, "graph_id"), )?; + record_auth_result( + &mut api_result, + api_state.auth.authorize_watchtower(&signer, payload.gateway_address.as_deref()).await, + )?; match update_watchtower_task_state( &api_state.local_db, instance_id, diff --git a/proof-builder-rpc/src/config.rs b/proof-builder-rpc/src/config.rs index 82221973..95eb7386 100644 --- a/proof-builder-rpc/src/config.rs +++ b/proof-builder-rpc/src/config.rs @@ -1,13 +1,9 @@ use anyhow::Context; use proof_builder::LongRunning; -use proof_builder::api_auth::normalize_public_key; -use secp256k1::XOnlyPublicKey; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; #[derive(Debug, Deserialize, Serialize)] pub(crate) struct ProofBuilderConfig { - pub api_auth: ApiAuthConfig, pub header_chain: header_chain_proof::Args, pub commit_chain: commit_chain_proof::Args, pub state_chain: state_chain_proof::Args, @@ -15,29 +11,6 @@ pub(crate) struct ProofBuilderConfig { pub operator: operator_proof::Args, } -#[derive(Clone, Debug, Deserialize, Serialize)] -pub(crate) struct ApiAuthConfig { - pub trusted_operator_public_keys: Vec, - pub trusted_watchtower_public_keys: Vec, -} - -#[derive(Clone, Debug)] -pub(crate) struct TrustedApiKeys { - pub operator: HashSet, - pub watchtower: HashSet, -} - -impl ApiAuthConfig { - /// Parses and validates the fail-closed role-specific API allowlists. - pub(crate) fn trusted_keys(&self) -> anyhow::Result { - let operator = - parse_keys("trusted_operator_public_keys", &self.trusted_operator_public_keys)?; - let watchtower = - parse_keys("trusted_watchtower_public_keys", &self.trusted_watchtower_public_keys)?; - Ok(TrustedApiKeys { operator, watchtower }) - } -} - impl ProofBuilderConfig { pub(crate) fn new(url: &str) -> anyhow::Result { Self::load(url) @@ -59,61 +32,3 @@ impl ProofBuilderConfig { Ok(new_args) } } - -/// Normalizes one configured allowlist and rejects empty or invalid lists. -fn parse_keys(name: &str, values: &[String]) -> anyhow::Result> { - anyhow::ensure!(!values.is_empty(), "api_auth.{name} must not be empty"); - values - .iter() - .map(|value| { - normalize_public_key(value) - .with_context(|| format!("invalid public key in api_auth.{name}")) - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use secp256k1::{Keypair, SECP256K1}; - - fn public_keys() -> (String, String) { - let operator = Keypair::from_seckey_slice(SECP256K1, &[7; 32]).unwrap(); - let watchtower = Keypair::from_seckey_slice(SECP256K1, &[9; 32]).unwrap(); - (operator.public_key().to_string(), watchtower.x_only_public_key().0.to_string()) - } - - #[test] - fn api_auth_accepts_compressed_and_x_only_keys_and_deduplicates() { - let (operator, watchtower) = public_keys(); - let config = ApiAuthConfig { - trusted_operator_public_keys: vec![operator.clone(), operator], - trusted_watchtower_public_keys: vec![watchtower], - }; - let keys = config.trusted_keys().unwrap(); - assert_eq!(keys.operator.len(), 1); - assert_eq!(keys.watchtower.len(), 1); - } - - #[test] - fn api_auth_rejects_empty_or_invalid_lists() { - let (operator, watchtower) = public_keys(); - assert!( - ApiAuthConfig { - trusted_operator_public_keys: vec![], - trusted_watchtower_public_keys: vec![watchtower.clone()], - } - .trusted_keys() - .is_err() - ); - assert!( - ApiAuthConfig { - trusted_operator_public_keys: vec![operator], - trusted_watchtower_public_keys: vec!["not-a-public-key".to_string()], - } - .trusted_keys() - .is_err() - ); - assert!(toml::from_str::("").is_err()); - } -} diff --git a/proof-builder-rpc/src/main.rs b/proof-builder-rpc/src/main.rs index 6cfe0410..e76972d7 100644 --- a/proof-builder-rpc/src/main.rs +++ b/proof-builder-rpc/src/main.rs @@ -3,9 +3,15 @@ mod config; mod task; use crate::task::{is_start_generate_proof_tasks, run_generate_proof_tasks}; +use alloy_primitives::Address; +use anyhow::Context; use api::metrics_service::ApiMetricsState; +use api::{AuthorizationChain, AuthorizationChains}; use clap::Parser; +use client::goat_chain::{GOATClient, GoatInitConfig, GoatNetwork}; use futures::future; +use std::collections::HashSet; +use std::sync::Arc; use tokio::signal; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -35,10 +41,10 @@ async fn main() -> anyhow::Result<()> { let opt = Opts::parse(); let cfg = ProofBuilderConfig::new(&opt.config)?; - let trusted_api_keys = cfg.api_auth.trusted_keys()?; println!("proof builder config: {:?}", cfg); let _ = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).try_init(); + let authorization_chains = goat_clients_from_env().await?; // Create cancellation token for graceful shutdown let cancellation_token = CancellationToken::new(); info!("load db {}", opt.database_url); @@ -55,7 +61,7 @@ async fn main() -> anyhow::Result<()> { opt_rpc_addr, local_db_clone1, api_metrics_state, - trusted_api_keys, + authorization_chains, cancel_token_clone, ) .await @@ -134,6 +140,75 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// Builds one read-only GOAT client per configured Gateway for live authorization. +async fn goat_clients_from_env() -> anyhow::Result { + let network = match std::env::var("GOAT_NETWORK") + .context("GOAT_NETWORK is required for Proof Builder authorization")? + .as_str() + { + "main" => GoatNetwork::Main, + "test" => GoatNetwork::Test, + value => anyhow::bail!("invalid GOAT_NETWORK {value:?}; expected main or test"), + }; + let rpc_url = std::env::var("GOAT_CHAIN_URL") + .context("GOAT_CHAIN_URL is required for Proof Builder authorization")? + .parse() + .context("invalid GOAT_CHAIN_URL")?; + let gateway_addresses = parse_gateway_addresses( + &std::env::var("GOAT_GATEWAY_CONTRACT_ADDRESS") + .context("GOAT_GATEWAY_CONTRACT_ADDRESS is required for Proof Builder authorization")?, + )?; + + let base_config = + GoatInitConfig::new(rpc_url).await.context("failed to query GOAT chain id")?; + let mut chains = AuthorizationChains::with_capacity(gateway_addresses.len()); + for gateway_address in gateway_addresses { + let gateway_config = base_config.clone().with_gateway_address(Some(gateway_address)); + let discovery_client = GOATClient::new(gateway_config.clone(), network); + let committee_management = + discovery_client.gateway_get_committee_management().await.with_context(|| { + format!("failed to discover CommitteeManagement from Gateway {gateway_address}") + })?; + anyhow::ensure!( + committee_management != [0; 20], + "Gateway {gateway_address} returned a zero CommitteeManagement address" + ); + let config = gateway_config + .with_committee_management_address(Some(Address::from_slice(&committee_management))); + let chain: Arc = Arc::new(GOATClient::new(config, network)); + chains.insert(gateway_address, chain); + } + Ok(chains) +} + +/// Parses and validates the comma-separated Gateway deployment list. +fn parse_gateway_addresses(value: &str) -> anyhow::Result> { + let mut addresses = Vec::new(); + let mut seen = HashSet::new(); + for (index, raw_address) in value.split(',').enumerate() { + let raw_address = raw_address.trim(); + anyhow::ensure!( + !raw_address.is_empty(), + "GOAT_GATEWAY_CONTRACT_ADDRESS entry {} must not be empty", + index + 1 + ); + let address = raw_address.parse::
().with_context(|| { + format!("invalid GOAT_GATEWAY_CONTRACT_ADDRESS entry {raw_address:?}") + })?; + anyhow::ensure!( + address != Address::ZERO, + "GOAT_GATEWAY_CONTRACT_ADDRESS entry {} must not be the zero address", + index + 1 + ); + anyhow::ensure!( + seen.insert(address), + "duplicate GOAT_GATEWAY_CONTRACT_ADDRESS entry {raw_address:?}" + ); + addresses.push(address); + } + Ok(addresses) +} + async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c().await.expect("Failed to install Ctrl+C handler"); @@ -156,3 +231,31 @@ async fn shutdown_signal() { }, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_single_and_multiple_gateway_addresses() { + let first = Address::from_slice(&[1; 20]); + let second = Address::from_slice(&[2; 20]); + + assert_eq!(parse_gateway_addresses(&first.to_string()).unwrap(), vec![first]); + assert_eq!( + parse_gateway_addresses(&format!(" {first} , {second} ")).unwrap(), + vec![first, second] + ); + } + + #[test] + fn rejects_invalid_zero_empty_and_duplicate_gateway_addresses() { + let address = Address::from_slice(&[1; 20]); + + assert!(parse_gateway_addresses("").is_err()); + assert!(parse_gateway_addresses(&format!("{address},")).is_err()); + assert!(parse_gateway_addresses("invalid").is_err()); + assert!(parse_gateway_addresses(&Address::ZERO.to_string()).is_err()); + assert!(parse_gateway_addresses(&format!("{address},{address}")).is_err()); + } +}