From 49517d0d5a95246fab540067f57ea62dfae9ef39 Mon Sep 17 00:00:00 2001 From: Blake <0xblake.sg@gmail.com> Date: Fri, 14 Aug 2026 21:04:55 +0800 Subject: [PATCH 1/3] Switch to live GOAT contract-based authorization; replace `TrustedApiKeys` with `AuthorizationChains`. --- Cargo.lock | 1 + circuits/proof-builder/src/lib.rs | 8 + .../proof-builder-rpc/.env.proof-builder-rpc | 8 +- .../proof-builder-rpc/proof-builder.toml | 5 - node/src/utils.rs | 3 + proof-builder-rpc/Cargo.toml | 1 + proof-builder-rpc/README.md | 40 +- proof-builder-rpc/proof-builder.toml.example | 6 - proof-builder-rpc/src/api/auth.rs | 573 ++++++++++++++++-- proof-builder-rpc/src/api/mod.rs | 165 ++++- proof-builder-rpc/src/api/proof_handler.rs | 66 +- proof-builder-rpc/src/config.rs | 80 +-- proof-builder-rpc/src/main.rs | 116 +++- 13 files changed, 883 insertions(+), 189 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3f49e0be9..89bab5f7e 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 85c3a7c7c..5d76ffea5 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 f8f00cd9c..5b96d9ab7 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 4bd2386b2..114bacce7 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 8800c4e08..e6b637b79 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 a98bf0874..dfb2d5586 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 f606ccf4e..19ec8eab1 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 and StakeManagement contracts +through each Gateway and exits if any configured contract set cannot be initialized. Operator +requests require current registration, sufficient locked stake, graph ownership, and a matching +instance/graph relation. +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 c51d34a0a..96dce504f 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 cd5ea75a4..8fffacb88 100644 --- a/proof-builder-rpc/src/api/auth.rs +++ b/proof-builder-rpc/src/api/auth.rs @@ -1,36 +1,79 @@ 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 { + /// Resolves an Operator x-only public key to its registered stake address. + async fn operator_address(&self, public_key: &[u8; 32]) -> anyhow::Result<[u8; 20]>; + /// Returns the Gateway's current minimum Operator stake. + async fn minimum_operator_stake(&self) -> anyhow::Result; + /// Returns the stake currently locked by one registered Operator address. + async fn locked_operator_stake(&self, operator: &[u8; 20]) -> anyhow::Result; + /// 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 operator_address(&self, public_key: &[u8; 32]) -> anyhow::Result<[u8; 20]> { + self.stake_mana_pubkey_to_address(public_key).await + } + + async fn minimum_operator_stake(&self) -> anyhow::Result { + self.gateway_get_min_stake_amount().await + } + + async fn locked_operator_stake(&self, operator: &[u8; 20]) -> anyhow::Result { + self.stake_mana_lock_stake_of(operator).await + } + + 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 +81,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 +89,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 +102,92 @@ impl RequestAuthorizer { ) .map_err(|_| unauthorized("invalid request signature"))?; self.record_nonce(&signer.to_string(), nonce)?; + Ok(signer) + } + + /// Checks current Operator registration, stake, graph ownership, and instance membership. + 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 operator_address = + chain.operator_address(&signer_bytes).await.map_err(contract_unavailable)?; + if operator_address == [0; 20] { + return Err(forbidden("operator signer is not registered")); + } + + let minimum_stake = chain.minimum_operator_stake().await.map_err(contract_unavailable)?; + let locked_stake = + chain.locked_operator_stake(&operator_address).await.map_err(contract_unavailable)?; + if locked_stake < minimum_stake { + return Err(forbidden("operator signer has insufficient locked stake")); + } + + 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 +203,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 +226,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 +256,144 @@ 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 { + operator_addresses: HashMap<[u8; 32], [u8; 20]>, + locked_stakes: HashMap<[u8; 20], u64>, + minimum_stake: u64, + graphs: HashMap, + instance_graphs: HashMap>, + watchtowers: HashSet, + fail_queries: bool, + } + + #[derive(Default)] + pub(crate) struct TestAuthorizationChain { + state: Mutex, + } + + impl TestAuthorizationChain { + pub(crate) fn set_operator( + &self, + public_key: XOnlyPublicKey, + address: [u8; 20], + locked_stake: u64, + minimum_stake: u64, + ) { + let mut state = self.state.lock().unwrap(); + state.operator_addresses.insert(public_key.serialize(), address); + state.locked_stakes.insert(address, locked_stake); + state.minimum_stake = minimum_stake; + } + + pub(crate) fn remove_operator(&self, public_key: &XOnlyPublicKey) { + let mut state = self.state.lock().unwrap(); + if let Some(address) = state.operator_addresses.remove(&public_key.serialize()) { + state.locked_stakes.remove(&address); + } + } + + 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 operator_address(&self, public_key: &[u8; 32]) -> anyhow::Result<[u8; 20]> { + let state = self.state.lock().unwrap(); + ensure_available(&state)?; + Ok(state.operator_addresses.get(public_key).copied().unwrap_or([0; 20])) + } + + async fn minimum_operator_stake(&self) -> anyhow::Result { + let state = self.state.lock().unwrap(); + ensure_available(&state)?; + Ok(state.minimum_stake) + } + + async fn locked_operator_stake(&self, operator: &[u8; 20]) -> anyhow::Result { + let state = self.state.lock().unwrap(); + ensure_available(&state)?; + Ok(state.locked_stakes.get(operator).copied().unwrap_or_default()) + } + + 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::OperatorProofTimeoutUpdateRequest; use proof_builder::api_auth::{ProofBuilderAuthHeaders, sign_proof_builder_request}; use secp256k1::{Keypair, SECP256K1}; - use serde::Serialize; #[derive(Serialize)] struct TestBody { @@ -144,6 +405,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 +422,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 +440,7 @@ mod tests { assert!( authorizer - .authorize( + .authenticate( &headers, ProofBuilderAuthRole::Operator, "POST", @@ -189,7 +452,7 @@ mod tests { ); assert_eq!( authorizer - .authorize( + .authenticate( &headers, ProofBuilderAuthRole::Operator, "POST", @@ -204,58 +467,254 @@ mod tests { } #[test] - fn rejects_cross_role_and_mismatched_watchtower_identity() { + fn rejects_gateway_address_changed_after_signing() { 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, + let authorizer = + RequestAuthorizer::new(chains(gateway(1), Arc::new(TestAuthorizationChain::default()))); + let signed_body = OperatorProofTimeoutUpdateRequest { + instance_id: Uuid::new_v4().to_string(), + graph_id: Uuid::new_v4().to_string(), + gateway_address: Some(gateway(1).to_string()), + }; + let signed = sign_proof_builder_request( + &operator, ProofBuilderAuthRole::Operator, "POST", - "/v1/proofs/operator_proofs", - &operator_body, + "/v1/proofs/operator_proofs_timeout", + &signed_body, ) .unwrap(); + let tampered_body = OperatorProofTimeoutUpdateRequest { + gateway_address: Some(gateway(2).to_string()), + ..signed_body + }; + assert_eq!( authorizer - .authorize( - &headers(&cross_role), + .authenticate( + &headers(&signed), ProofBuilderAuthRole::Operator, "POST", - "/v1/proofs/operator_proofs", - &operator_body, + "/v1/proofs/operator_proofs_timeout", + &tampered_body, None, ) .unwrap_err() .0, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn operator_authorization_uses_current_registration_stake_and_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_operator(&operator, None, &instance_id, &graph_id) + .await + .unwrap_err() + .0, + StatusCode::FORBIDDEN + ); + + chain.set_operator(operator, [1; 20], 99, 100); + assert_eq!( + authorizer + .authorize_operator(&operator, None, &instance_id, &graph_id) + .await + .unwrap_err() + .0, + StatusCode::FORBIDDEN + ); + + chain.set_operator(operator, [1; 20], 100, 100); + assert_eq!( + authorizer + .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() + ); + + chain.remove_operator(&operator); + assert_eq!( + authorizer + .authorize_operator(&operator, None, &instance_id, &graph_id) + .await + .unwrap_err() + .0, + StatusCode::FORBIDDEN + ); + } + + #[tokio::test] + async fn operator_authorization_rejects_instance_mismatch_and_query_failure() { + 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_operator(operator, [1; 20], 100, 100); + 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 }; + chain.set_fail_queries(true); + assert_eq!( + authorizer + .authorize_operator(&operator, None, &instance_id, &graph_id) + .await + .unwrap_err() + .0, + StatusCode::SERVICE_UNAVAILABLE + ); + } + + #[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, + StatusCode::FORBIDDEN + ); + } + + #[tokio::test] + async fn authorization_selects_gateway_and_does_not_cache_chain_state() { + let operator = keypair(7).x_only_public_key().0; + let watchtower = keypair(9).x_only_public_key().0; + let instance_id = Uuid::new_v4(); + let graph_id = Uuid::new_v4(); + let gateway_a = gateway(1); + let gateway_b = gateway(2); + let chain_a = Arc::new(TestAuthorizationChain::default()); + let chain_b = Arc::new(TestAuthorizationChain::default()); + chain_a.set_operator(operator, [1; 20], 100, 100); + chain_a.set_graph(instance_id, graph_id, operator); + chain_a.add_watchtower(watchtower); + let chain_a_trait: Arc = chain_a.clone(); + let chain_b_trait: Arc = chain_b; + let authorizer = RequestAuthorizer::new(HashMap::from([ + (gateway_a, chain_a_trait), + (gateway_b, chain_b_trait), + ])); + let gateway_a = gateway_a.to_string(); + let gateway_b = gateway_b.to_string(); + + assert!( + authorizer + .authorize_operator(&operator, Some(&gateway_a), &instance_id, &graph_id) + .await + .is_ok() + ); + assert_eq!( + authorizer + .authorize_operator(&operator, Some(&gateway_b), &instance_id, &graph_id) + .await + .unwrap_err() + .0, + StatusCode::FORBIDDEN + ); + assert!(authorizer.authorize_watchtower(&watchtower, Some(&gateway_a)).await.is_ok()); + assert_eq!( + authorizer.authorize_watchtower(&watchtower, Some(&gateway_b)).await.unwrap_err().0, + StatusCode::FORBIDDEN + ); + assert_eq!( + authorizer.authorize_watchtower(&watchtower, None).await.unwrap_err().0, + StatusCode::BAD_REQUEST + ); + assert_eq!( + authorizer.authorize_watchtower(&watchtower, Some("invalid")).await.unwrap_err().0, + StatusCode::BAD_REQUEST + ); + assert_eq!( + authorizer + .authorize_watchtower( + &watchtower, + Some(&Address::from_slice(&[3; 20]).to_string()), ) + .await .unwrap_err() .0, StatusCode::FORBIDDEN ); + + chain_a.remove_operator(&operator); + chain_a.remove_watchtower(&watchtower); + assert_eq!( + authorizer + .authorize_operator(&operator, Some(&gateway_a), &instance_id, &graph_id) + .await + .unwrap_err() + .0, + StatusCode::FORBIDDEN + ); + assert_eq!( + authorizer.authorize_watchtower(&watchtower, Some(&gateway_a)).await.unwrap_err().0, + StatusCode::FORBIDDEN + ); } } diff --git a/proof-builder-rpc/src/api/mod.rs b/proof-builder-rpc/src/api/mod.rs index 1a4463cd9..2f08b8eb8 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,17 @@ 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_operator(operator.x_only_public_key().0, [1; 20], 100, 100); + 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 +239,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 +282,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 +322,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 +349,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 +397,114 @@ 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_operator(operator.x_only_public_key().0, [1; 20], 100, 100); + 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 417452474..50e750c16 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 822219738..e294bd735 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) @@ -60,60 +33,13 @@ impl ProofBuilderConfig { } } -/// 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()); + fn example_config_loads_without_static_api_allowlists() { + let path = format!("{}/proof-builder.toml.example", env!("CARGO_MANIFEST_DIR")); + ProofBuilderConfig::new(&path).unwrap(); } } diff --git a/proof-builder-rpc/src/main.rs b/proof-builder-rpc/src/main.rs index 6cfe04102..48980c941 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,84 @@ 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}") + })?; + let stake_management = + discovery_client.gateway_get_stake_management().await.with_context(|| { + format!("failed to discover StakeManagement from Gateway {gateway_address}") + })?; + anyhow::ensure!( + committee_management != [0; 20], + "Gateway {gateway_address} returned a zero CommitteeManagement address" + ); + anyhow::ensure!( + stake_management != [0; 20], + "Gateway {gateway_address} returned a zero StakeManagement address" + ); + let config = gateway_config + .with_committee_management_address(Some(Address::from_slice(&committee_management))) + .with_stake_management_address(Some(Address::from_slice(&stake_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 +240,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()); + } +} From 1bb6d180c552c7a7846e3fddf78b8d28d4eecb62 Mon Sep 17 00:00:00 2001 From: Blake <0xblake.sg@gmail.com> Date: Wed, 19 Aug 2026 20:51:13 +0800 Subject: [PATCH 2/3] feat(proof builder): remove operator stake verification --- proof-builder-rpc/README.md | 8 +-- proof-builder-rpc/src/api/auth.rs | 111 ++---------------------------- proof-builder-rpc/src/api/mod.rs | 2 - proof-builder-rpc/src/main.rs | 11 +-- 4 files changed, 9 insertions(+), 123 deletions(-) diff --git a/proof-builder-rpc/README.md b/proof-builder-rpc/README.md index 19ec8eab1..b2c1eb0fb 100644 --- a/proof-builder-rpc/README.md +++ b/proof-builder-rpc/README.md @@ -26,10 +26,10 @@ GOAT_GATEWAY_CONTRACT_ADDRESS=, ``` Proof Builder accepts one or more comma-separated Gateway addresses. All configured Gateways use -the same GOAT network and RPC. It discovers the CommitteeManagement and StakeManagement contracts -through each Gateway and exits if any configured contract set cannot be initialized. Operator -requests require current registration, sufficient locked stake, graph ownership, and a matching -instance/graph relation. +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. diff --git a/proof-builder-rpc/src/api/auth.rs b/proof-builder-rpc/src/api/auth.rs index 8fffacb88..da08ac5d6 100644 --- a/proof-builder-rpc/src/api/auth.rs +++ b/proof-builder-rpc/src/api/auth.rs @@ -20,12 +20,6 @@ pub(crate) type AuthorizationChains = HashMap anyhow::Result<[u8; 20]>; - /// Returns the Gateway's current minimum Operator stake. - async fn minimum_operator_stake(&self) -> anyhow::Result; - /// Returns the stake currently locked by one registered Operator address. - async fn locked_operator_stake(&self, operator: &[u8; 20]) -> anyhow::Result; /// 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. @@ -36,18 +30,6 @@ pub(crate) trait AuthorizationChain: Send + Sync { #[async_trait] impl AuthorizationChain for GOATClient { - async fn operator_address(&self, public_key: &[u8; 32]) -> anyhow::Result<[u8; 20]> { - self.stake_mana_pubkey_to_address(public_key).await - } - - async fn minimum_operator_stake(&self) -> anyhow::Result { - self.gateway_get_min_stake_amount().await - } - - async fn locked_operator_stake(&self, operator: &[u8; 20]) -> anyhow::Result { - self.stake_mana_lock_stake_of(operator).await - } - async fn graph_data(&self, graph_id: &Uuid) -> anyhow::Result { self.gateway_get_graph_data(graph_id).await } @@ -105,7 +87,7 @@ impl RequestAuthorizer { Ok(signer) } - /// Checks current Operator registration, stake, graph ownership, and instance membership. + /// Checks current graph ownership and instance membership for an Operator. pub(crate) async fn authorize_operator( &self, signer: &XOnlyPublicKey, @@ -115,19 +97,6 @@ impl RequestAuthorizer { ) -> AuthResult<()> { let chain = self.chain_for_gateway(gateway_address)?; let signer_bytes = signer.serialize(); - let operator_address = - chain.operator_address(&signer_bytes).await.map_err(contract_unavailable)?; - if operator_address == [0; 20] { - return Err(forbidden("operator signer is not registered")); - } - - let minimum_stake = chain.minimum_operator_stake().await.map_err(contract_unavailable)?; - let locked_stake = - chain.locked_operator_stake(&operator_address).await.map_err(contract_unavailable)?; - if locked_stake < minimum_stake { - return Err(forbidden("operator signer has insufficient locked stake")); - } - 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")); @@ -263,9 +232,6 @@ pub(crate) mod test_support { #[derive(Default)] struct TestAuthorizationState { - operator_addresses: HashMap<[u8; 32], [u8; 20]>, - locked_stakes: HashMap<[u8; 20], u64>, - minimum_stake: u64, graphs: HashMap, instance_graphs: HashMap>, watchtowers: HashSet, @@ -278,26 +244,6 @@ pub(crate) mod test_support { } impl TestAuthorizationChain { - pub(crate) fn set_operator( - &self, - public_key: XOnlyPublicKey, - address: [u8; 20], - locked_stake: u64, - minimum_stake: u64, - ) { - let mut state = self.state.lock().unwrap(); - state.operator_addresses.insert(public_key.serialize(), address); - state.locked_stakes.insert(address, locked_stake); - state.minimum_stake = minimum_stake; - } - - pub(crate) fn remove_operator(&self, public_key: &XOnlyPublicKey) { - let mut state = self.state.lock().unwrap(); - if let Some(address) = state.operator_addresses.remove(&public_key.serialize()) { - state.locked_stakes.remove(&address); - } - } - 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)); @@ -323,24 +269,6 @@ pub(crate) mod test_support { #[async_trait] impl AuthorizationChain for TestAuthorizationChain { - async fn operator_address(&self, public_key: &[u8; 32]) -> anyhow::Result<[u8; 20]> { - let state = self.state.lock().unwrap(); - ensure_available(&state)?; - Ok(state.operator_addresses.get(public_key).copied().unwrap_or([0; 20])) - } - - async fn minimum_operator_stake(&self) -> anyhow::Result { - let state = self.state.lock().unwrap(); - ensure_available(&state)?; - Ok(state.minimum_stake) - } - - async fn locked_operator_stake(&self, operator: &[u8; 20]) -> anyhow::Result { - let state = self.state.lock().unwrap(); - ensure_available(&state)?; - Ok(state.locked_stakes.get(operator).copied().unwrap_or_default()) - } - async fn graph_data(&self, graph_id: &Uuid) -> anyhow::Result { let state = self.state.lock().unwrap(); ensure_available(&state)?; @@ -506,7 +434,7 @@ mod tests { } #[tokio::test] - async fn operator_authorization_uses_current_registration_stake_and_graph_owner() { + 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(); @@ -523,26 +451,6 @@ mod tests { StatusCode::FORBIDDEN ); - chain.set_operator(operator, [1; 20], 99, 100); - assert_eq!( - authorizer - .authorize_operator(&operator, None, &instance_id, &graph_id) - .await - .unwrap_err() - .0, - StatusCode::FORBIDDEN - ); - - chain.set_operator(operator, [1; 20], 100, 100); - assert_eq!( - authorizer - .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 @@ -557,16 +465,6 @@ mod tests { assert!( authorizer.authorize_operator(&operator, None, &instance_id, &graph_id).await.is_ok() ); - - chain.remove_operator(&operator); - assert_eq!( - authorizer - .authorize_operator(&operator, None, &instance_id, &graph_id) - .await - .unwrap_err() - .0, - StatusCode::FORBIDDEN - ); } #[tokio::test] @@ -576,7 +474,6 @@ mod tests { let other_instance_id = Uuid::new_v4(); let graph_id = Uuid::new_v4(); let chain = Arc::new(TestAuthorizationChain::default()); - chain.set_operator(operator, [1; 20], 100, 100); chain.set_graph(other_instance_id, graph_id, operator); let authorizer = RequestAuthorizer::new(chains(gateway(1), chain.clone())); @@ -644,6 +541,7 @@ mod tests { #[tokio::test] async fn authorization_selects_gateway_and_does_not_cache_chain_state() { let operator = keypair(7).x_only_public_key().0; + let other_operator = keypair(8).x_only_public_key().0; let watchtower = keypair(9).x_only_public_key().0; let instance_id = Uuid::new_v4(); let graph_id = Uuid::new_v4(); @@ -651,7 +549,6 @@ mod tests { let gateway_b = gateway(2); let chain_a = Arc::new(TestAuthorizationChain::default()); let chain_b = Arc::new(TestAuthorizationChain::default()); - chain_a.set_operator(operator, [1; 20], 100, 100); chain_a.set_graph(instance_id, graph_id, operator); chain_a.add_watchtower(watchtower); let chain_a_trait: Arc = chain_a.clone(); @@ -702,7 +599,7 @@ mod tests { StatusCode::FORBIDDEN ); - chain_a.remove_operator(&operator); + chain_a.set_graph_owner(graph_id, other_operator); chain_a.remove_watchtower(&watchtower); assert_eq!( authorizer diff --git a/proof-builder-rpc/src/api/mod.rs b/proof-builder-rpc/src/api/mod.rs index 2f08b8eb8..1758e4d2d 100644 --- a/proof-builder-rpc/src/api/mod.rs +++ b/proof-builder-rpc/src/api/mod.rs @@ -224,7 +224,6 @@ mod tests { 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_operator(operator.x_only_public_key().0, [1; 20], 100, 100); authorization_chain.set_graph( Uuid::parse_str(&instance_id)?, Uuid::parse_str(&graph_id)?, @@ -408,7 +407,6 @@ mod tests { let gateway_b = gateway(2); let gateway_unknown = gateway(3); let chain_a = Arc::new(TestAuthorizationChain::default()); - chain_a.set_operator(operator.x_only_public_key().0, [1; 20], 100, 100); chain_a.set_graph( Uuid::parse_str(&instance_id)?, Uuid::parse_str(&graph_id)?, diff --git a/proof-builder-rpc/src/main.rs b/proof-builder-rpc/src/main.rs index 48980c941..e76972d79 100644 --- a/proof-builder-rpc/src/main.rs +++ b/proof-builder-rpc/src/main.rs @@ -169,21 +169,12 @@ async fn goat_clients_from_env() -> anyhow::Result { discovery_client.gateway_get_committee_management().await.with_context(|| { format!("failed to discover CommitteeManagement from Gateway {gateway_address}") })?; - let stake_management = - discovery_client.gateway_get_stake_management().await.with_context(|| { - format!("failed to discover StakeManagement from Gateway {gateway_address}") - })?; anyhow::ensure!( committee_management != [0; 20], "Gateway {gateway_address} returned a zero CommitteeManagement address" ); - anyhow::ensure!( - stake_management != [0; 20], - "Gateway {gateway_address} returned a zero StakeManagement address" - ); let config = gateway_config - .with_committee_management_address(Some(Address::from_slice(&committee_management))) - .with_stake_management_address(Some(Address::from_slice(&stake_management))); + .with_committee_management_address(Some(Address::from_slice(&committee_management))); let chain: Arc = Arc::new(GOATClient::new(config, network)); chains.insert(gateway_address, chain); } From 1fdd138f7426cec26bcd1684fc5b6668addcbf8b Mon Sep 17 00:00:00 2001 From: Blake <0xblake.sg@gmail.com> Date: Wed, 19 Aug 2026 21:49:06 +0800 Subject: [PATCH 3/3] remove unused tests and redundant code --- proof-builder-rpc/src/api/auth.rs | 129 +----------------------------- proof-builder-rpc/src/config.rs | 11 --- 2 files changed, 1 insertion(+), 139 deletions(-) diff --git a/proof-builder-rpc/src/api/auth.rs b/proof-builder-rpc/src/api/auth.rs index da08ac5d6..e4cbc8bff 100644 --- a/proof-builder-rpc/src/api/auth.rs +++ b/proof-builder-rpc/src/api/auth.rs @@ -319,7 +319,6 @@ pub(crate) mod test_support { mod tests { use super::test_support::TestAuthorizationChain; use super::*; - use proof_builder::OperatorProofTimeoutUpdateRequest; use proof_builder::api_auth::{ProofBuilderAuthHeaders, sign_proof_builder_request}; use secp256k1::{Keypair, SECP256K1}; @@ -394,45 +393,6 @@ mod tests { ); } - #[test] - fn rejects_gateway_address_changed_after_signing() { - let operator = keypair(7); - let authorizer = - RequestAuthorizer::new(chains(gateway(1), Arc::new(TestAuthorizationChain::default()))); - let signed_body = OperatorProofTimeoutUpdateRequest { - instance_id: Uuid::new_v4().to_string(), - graph_id: Uuid::new_v4().to_string(), - gateway_address: Some(gateway(1).to_string()), - }; - let signed = sign_proof_builder_request( - &operator, - ProofBuilderAuthRole::Operator, - "POST", - "/v1/proofs/operator_proofs_timeout", - &signed_body, - ) - .unwrap(); - let tampered_body = OperatorProofTimeoutUpdateRequest { - gateway_address: Some(gateway(2).to_string()), - ..signed_body - }; - - assert_eq!( - authorizer - .authenticate( - &headers(&signed), - ProofBuilderAuthRole::Operator, - "POST", - "/v1/proofs/operator_proofs_timeout", - &tampered_body, - None, - ) - .unwrap_err() - .0, - StatusCode::UNAUTHORIZED - ); - } - #[tokio::test] async fn operator_authorization_checks_graph_owner() { let operator = keypair(7).x_only_public_key().0; @@ -468,7 +428,7 @@ mod tests { } #[tokio::test] - async fn operator_authorization_rejects_instance_mismatch_and_query_failure() { + 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(); @@ -485,16 +445,6 @@ mod tests { .0, StatusCode::FORBIDDEN ); - - chain.set_fail_queries(true); - assert_eq!( - authorizer - .authorize_operator(&operator, None, &instance_id, &graph_id) - .await - .unwrap_err() - .0, - StatusCode::SERVICE_UNAVAILABLE - ); } #[tokio::test] @@ -537,81 +487,4 @@ mod tests { StatusCode::FORBIDDEN ); } - - #[tokio::test] - async fn authorization_selects_gateway_and_does_not_cache_chain_state() { - let operator = keypair(7).x_only_public_key().0; - let other_operator = keypair(8).x_only_public_key().0; - let watchtower = keypair(9).x_only_public_key().0; - let instance_id = Uuid::new_v4(); - let graph_id = Uuid::new_v4(); - let gateway_a = gateway(1); - let gateway_b = gateway(2); - let chain_a = Arc::new(TestAuthorizationChain::default()); - let chain_b = Arc::new(TestAuthorizationChain::default()); - chain_a.set_graph(instance_id, graph_id, operator); - chain_a.add_watchtower(watchtower); - let chain_a_trait: Arc = chain_a.clone(); - let chain_b_trait: Arc = chain_b; - let authorizer = RequestAuthorizer::new(HashMap::from([ - (gateway_a, chain_a_trait), - (gateway_b, chain_b_trait), - ])); - let gateway_a = gateway_a.to_string(); - let gateway_b = gateway_b.to_string(); - - assert!( - authorizer - .authorize_operator(&operator, Some(&gateway_a), &instance_id, &graph_id) - .await - .is_ok() - ); - assert_eq!( - authorizer - .authorize_operator(&operator, Some(&gateway_b), &instance_id, &graph_id) - .await - .unwrap_err() - .0, - StatusCode::FORBIDDEN - ); - assert!(authorizer.authorize_watchtower(&watchtower, Some(&gateway_a)).await.is_ok()); - assert_eq!( - authorizer.authorize_watchtower(&watchtower, Some(&gateway_b)).await.unwrap_err().0, - StatusCode::FORBIDDEN - ); - assert_eq!( - authorizer.authorize_watchtower(&watchtower, None).await.unwrap_err().0, - StatusCode::BAD_REQUEST - ); - assert_eq!( - authorizer.authorize_watchtower(&watchtower, Some("invalid")).await.unwrap_err().0, - StatusCode::BAD_REQUEST - ); - assert_eq!( - authorizer - .authorize_watchtower( - &watchtower, - Some(&Address::from_slice(&[3; 20]).to_string()), - ) - .await - .unwrap_err() - .0, - StatusCode::FORBIDDEN - ); - - chain_a.set_graph_owner(graph_id, other_operator); - chain_a.remove_watchtower(&watchtower); - assert_eq!( - authorizer - .authorize_operator(&operator, Some(&gateway_a), &instance_id, &graph_id) - .await - .unwrap_err() - .0, - StatusCode::FORBIDDEN - ); - assert_eq!( - authorizer.authorize_watchtower(&watchtower, Some(&gateway_a)).await.unwrap_err().0, - StatusCode::FORBIDDEN - ); - } } diff --git a/proof-builder-rpc/src/config.rs b/proof-builder-rpc/src/config.rs index e294bd735..95eb73861 100644 --- a/proof-builder-rpc/src/config.rs +++ b/proof-builder-rpc/src/config.rs @@ -32,14 +32,3 @@ impl ProofBuilderConfig { Ok(new_args) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn example_config_loads_without_static_api_allowlists() { - let path = format!("{}/proof-builder.toml.example", env!("CARGO_MANIFEST_DIR")); - ProofBuilderConfig::new(&path).unwrap(); - } -}