diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 34c9366..25dce09 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,8 +1,13 @@ name: Build and Push Docker Image on: + push: + branches: [main] workflow_dispatch: +permissions: + contents: read + jobs: docker: runs-on: ubuntu-latest @@ -18,7 +23,7 @@ jobs: - name: Extract commit digest id: vars - run: echo "sha8=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT + run: echo "sha8=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" - name: Build and Push Docker image uses: docker/build-push-action@v6 diff --git a/README.md b/README.md index 8e4f0bf..f131cb1 100644 --- a/README.md +++ b/README.md @@ -125,3 +125,11 @@ For a graceful key rotation (phased removal): 1. Treat JWT signer keys as sensitive credentials. 2. Rotate keys regularly for better security. + +### Scroll mainnet + +The mainnet deployment uses `--forward-gas-prices` to forward `eth_gasPrice` and `eth_maxPriorityFeePerGas` to the bootnode-backed upstream. The default remains zero for existing gas-free deployments. `--disable-withdraw-proofs` disables both withdrawal-proof RPCs with an explicit error; it is used on mainnet because that service is not deployed there. Without that flag, configure `withdraw_proofs_url` for deployments that provide the service. + +The mainnet instance is deliberately limited to one replica: SIWE nonces are stored in process memory. A restart invalidates outstanding nonces. RPC access still requires the method's usual proxy authorization in addition to Cloudflare Access. + +Every push to main publishes a Docker Hub image at `scrolltech/cloak-rpc-auth-proxy` tagged with the first eight characters of the commit SHA. Deployment is a separate reviewed Helm tag update. Docker Hub tags can be overwritten; verify the published digest and use a digest for subsequent reviewed promotions when reproducibility matters. See the Helm chart README for image selection and secret provisioning steps. diff --git a/src/auth/auth_middleware.rs b/src/auth/auth_middleware.rs index 143af86..1a82800 100644 --- a/src/auth/auth_middleware.rs +++ b/src/auth/auth_middleware.rs @@ -85,7 +85,7 @@ mod tests { // ----------- Test with admin key ----------- let admin_key = &admin_keys[0]; let mut map = HeaderMap::new(); - let value = HeaderValue::from_str(&format!("Bearer {}", admin_key)).unwrap(); + let value = HeaderValue::from_str(&format!("Bearer {admin_key}")).unwrap(); map.insert("authorization", value); // Should grant Full access diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs index 7c11f71..20eb18c 100644 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -50,7 +50,7 @@ impl JwtSigner { // Ensure default_kid exists in the key map if !map.contains_key(default_kid) { - anyhow::bail!("default_kid '{}' not found in jwt_signer_keys", default_kid); + anyhow::bail!("default_kid '{default_kid}' not found in jwt_signer_keys"); } Ok(Self { @@ -90,7 +90,7 @@ impl JwtSigner { let entry = self .keys .get(&kid) - .ok_or_else(|| anyhow::anyhow!("JWT signing key kid {} not found", kid))?; + .ok_or_else(|| anyhow::anyhow!("JWT signing key kid {kid} not found"))?; let token_data = decode(token.as_ref(), &entry.decoding, &Validation::default())?; Ok(token_data.claims) diff --git a/src/auth/siwe.rs b/src/auth/siwe.rs index 58c9d80..59cb9bc 100644 --- a/src/auth/siwe.rs +++ b/src/auth/siwe.rs @@ -1,5 +1,5 @@ // jsonrpsee 0.26 `#[rpc]` expands to async_trait code that trips this lint. -#![expect(clippy::double_must_use)] +#![allow(clippy::double_must_use)] use alloy::primitives::Bytes; use chrono::Utc; diff --git a/src/config.rs b/src/config.rs index 08a2dd9..01564c9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,14 @@ struct CliArgs { /// Withdraw-proofs RPC endpoint to relay proxy requests to, e.g. http://cloak-withdraw-proofs:8545 #[arg(long)] withdraw_proofs_url: Option, + + /// Forward gas price methods to the upstream instead of returning zero. + #[arg(long)] + forward_gas_prices: bool, + + /// Disable withdrawal proof RPCs when no proof service is available. + #[arg(long)] + disable_withdraw_proofs: bool, } /// Structure of the config file @@ -28,7 +36,9 @@ pub struct AppConfig { pub bind_address: String, #[serde(default = "default_validium_url")] pub validium_url: String, - pub withdraw_proofs_url: String, + pub withdraw_proofs_url: Option, + #[serde(default)] + pub forward_gas_prices: bool, pub admin_keys: Vec, pub jwt_expiry_secs: usize, pub default_kid: String, @@ -62,7 +72,14 @@ pub fn load_config() -> anyhow::Result { cfg.validium_url = val; } if let Some(val) = args.withdraw_proofs_url { - cfg.withdraw_proofs_url = val; + cfg.withdraw_proofs_url = Some(val); + } + + if args.forward_gas_prices { + cfg.forward_gas_prices = true; + } + if args.disable_withdraw_proofs { + cfg.withdraw_proofs_url = None; } // Validate bind_address format @@ -84,13 +101,11 @@ pub fn load_config() -> anyhow::Result { } // Validate withdraw_proofs_url format - if !cfg.withdraw_proofs_url.starts_with("http://") - && !cfg.withdraw_proofs_url.starts_with("https://") + if let Some(url) = &cfg.withdraw_proofs_url + && !url.starts_with("http://") + && !url.starts_with("https://") { - anyhow::bail!( - "Invalid withdraw_proofs_url: {}. Must start with http:// or https://", - cfg.withdraw_proofs_url - ); + anyhow::bail!("Invalid withdraw_proofs_url: {url}. Must start with http:// or https://"); } Ok(cfg) @@ -132,7 +147,10 @@ mod tests { // Check values assert_eq!(cfg.bind_address, "127.0.0.1:12345"); assert_eq!(cfg.validium_url, "http://example.com:8545"); - assert_eq!(cfg.withdraw_proofs_url, "http://example.com:8546"); + assert_eq!( + cfg.withdraw_proofs_url.as_deref(), + Some("http://example.com:8546") + ); assert_eq!( cfg.admin_keys, vec![ diff --git a/src/main.rs b/src/main.rs index 1fa9cb0..f6a415f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,11 +26,14 @@ fn all_apis( jwt: JwtSigner, jwt_expiry_secs: usize, validium_url: &str, - withdraw_proofs_url: &str, + withdraw_proofs_url: Option<&str>, + forward_gas_prices: bool, ) -> anyhow::Result> { let auth_server = SiweAuthRpcImpl::new(jwt, jwt_expiry_secs); - let eth_proxy_server = RpcProxyImpl::new(validium_url, withdraw_proofs_url)?; - let scroll_proxy_server = RpcProxyImpl::new(validium_url, withdraw_proofs_url)?; + let eth_proxy_server = + RpcProxyImpl::new(validium_url, withdraw_proofs_url, forward_gas_prices)?; + let scroll_proxy_server = + RpcProxyImpl::new(validium_url, withdraw_proofs_url, forward_gas_prices)?; let mut module = RpcModule::new(()); module.merge(SiweAuthRpcServer::into_rpc(auth_server))?; @@ -72,13 +75,14 @@ pub async fn run_server() -> anyhow::Result { let addr = server.local_addr()?; info!("Server is listening on {addr}"); info!("Validium endpoint is {}", cfg.validium_url); - info!("Withdraw proofs endpoint is {}", cfg.withdraw_proofs_url); + info!("Withdraw proofs endpoint is {:?}", cfg.withdraw_proofs_url); let handle = server.start(all_apis( jwt, cfg.jwt_expiry_secs, &cfg.validium_url, - &cfg.withdraw_proofs_url, + cfg.withdraw_proofs_url.as_deref(), + cfg.forward_gas_prices, )?); handle.stopped().await; Ok(addr) diff --git a/src/proxy/interface.rs b/src/proxy/interface.rs index d24007b..bda883e 100644 --- a/src/proxy/interface.rs +++ b/src/proxy/interface.rs @@ -1,5 +1,5 @@ // jsonrpsee 0.26 `#[rpc]` expands to async_trait code that trips this lint. -#![expect(clippy::double_must_use)] +#![allow(clippy::double_must_use)] use jsonrpsee::proc_macros::rpc; diff --git a/src/proxy/server.rs b/src/proxy/server.rs index a86e4bd..0f51b13 100644 --- a/src/proxy/server.rs +++ b/src/proxy/server.rs @@ -63,19 +63,24 @@ fn only_full_access(ext: &Extensions) -> RpcResult<()> { pub struct RpcProxyImpl { validium_client: HttpClient, - withdraw_proofs_client: HttpClient, + withdraw_proofs_client: Option, + forward_gas_prices: bool, } impl RpcProxyImpl { pub fn new( validium_url: impl AsRef, - withdraw_proofs_url: impl AsRef, + withdraw_proofs_url: Option<&str>, + forward_gas_prices: bool, ) -> anyhow::Result { let validium_client = HttpClient::builder().build(validium_url)?; - let withdraw_proofs_client = HttpClient::builder().build(withdraw_proofs_url)?; + let withdraw_proofs_client = withdraw_proofs_url + .map(|url| HttpClient::builder().build(url)) + .transpose()?; Ok(Self { validium_client, withdraw_proofs_client, + forward_gas_prices, }) } } @@ -106,11 +111,14 @@ impl ScrollRpcProxyServer for RpcProxyImpl { return Err(unauthorized()); } + let client = self + .withdraw_proofs_client + .as_ref() + .ok_or_else(|| internal_error("withdrawal proof RPCs are not configured"))?; // proxy call - let ws = - ScrollRpcProxyClient::withdrawals_by_transaction(&self.withdraw_proofs_client, tx_hash) - .await - .map_err(proxy_call_failed)?; + let ws = ScrollRpcProxyClient::withdrawals_by_transaction(client, tx_hash) + .await + .map_err(proxy_call_failed)?; if ws.is_empty() || access == &AccessLevel::Full { return Ok(ws); @@ -144,13 +152,14 @@ impl ScrollRpcProxyServer for RpcProxyImpl { return Err(unauthorized()); } + let client = self + .withdraw_proofs_client + .as_ref() + .ok_or_else(|| internal_error("withdrawal proof RPCs are not configured"))?; // proxy call - let maybe_w = ScrollRpcProxyClient::withdrawal_by_message_hash( - &self.withdraw_proofs_client, - message_hash, - ) - .await - .map_err(proxy_call_failed)?; + let maybe_w = ScrollRpcProxyClient::withdrawal_by_message_hash(client, message_hash) + .await + .map_err(proxy_call_failed)?; if maybe_w.is_none() || access == &AccessLevel::Full { return Ok(maybe_w); @@ -344,11 +353,19 @@ impl EthRpcProxyServer for RpcProxyImpl { } async fn gas_price(&self, _ext: &Extensions) -> RpcResult { - Ok(U256::ZERO) // gas is free + if self.forward_gas_prices { + proxy_call!(self.validium_client, gas_price) + } else { + Ok(U256::ZERO) + } } async fn max_priority_fee_per_gas(&self, _ext: &Extensions) -> RpcResult { - Ok(U256::ZERO) // gas is free + if self.forward_gas_prices { + proxy_call!(self.validium_client, max_priority_fee_per_gas) + } else { + Ok(U256::ZERO) + } } async fn fee_history( @@ -406,6 +423,8 @@ impl EthRpcProxyServer for RpcProxyImpl { #[cfg(test)] mod tests { use super::*; + use jsonrpsee::{RpcModule, server::ServerBuilder}; + use alloy::consensus::{SignableTransaction, TxEip1559, TxEip4844, TxEnvelope, TxLegacy}; use alloy::eips::eip2718::Encodable2718; use alloy::primitives::TxKind; @@ -413,6 +432,72 @@ mod tests { use alloy::signers::local::PrivateKeySigner; use jsonrpsee::types::error::INVALID_PARAMS_CODE; + #[tokio::test] + async fn gas_prices_follow_configured_mode() { + let server = ServerBuilder::default().build("127.0.0.1:0").await.unwrap(); + let address = server.local_addr().unwrap(); + let mut methods = RpcModule::new(()); + methods + .register_method("eth_gasPrice", |_, _, _| "0x2a") + .unwrap(); + methods + .register_method("eth_maxPriorityFeePerGas", |_, _, _| "0x7") + .unwrap(); + let handle = server.start(methods); + let url = format!("http://{address}"); + let free = RpcProxyImpl::new(&url, None, false).unwrap(); + let forwarded = RpcProxyImpl::new(&url, None, true).unwrap(); + let ext = Extensions::new(); + assert_eq!( + EthRpcProxyServer::gas_price(&free, &ext).await.unwrap(), + U256::ZERO + ); + assert_eq!( + EthRpcProxyServer::max_priority_fee_per_gas(&free, &ext) + .await + .unwrap(), + U256::ZERO + ); + assert_eq!( + EthRpcProxyServer::gas_price(&forwarded, &ext) + .await + .unwrap(), + U256::from(42) + ); + assert_eq!( + EthRpcProxyServer::max_priority_fee_per_gas(&forwarded, &ext) + .await + .unwrap(), + U256::from(7) + ); + handle.stop().unwrap(); + } + + #[tokio::test] + async fn missing_withdrawal_service_is_explicit() { + let proxy = RpcProxyImpl::new("http://127.0.0.1:1", None, true).unwrap(); + let mut ext = Extensions::new(); + ext.insert(AccessLevel::Full); + let err = ScrollRpcProxyServer::withdrawals_by_transaction(&proxy, &ext, B256::ZERO) + .await + .unwrap_err(); + assert!( + err.data() + .unwrap() + .get() + .contains("withdrawal proof RPCs are not configured") + ); + let err = ScrollRpcProxyServer::withdrawal_by_message_hash(&proxy, &ext, B256::ZERO) + .await + .unwrap_err(); + assert!( + err.data() + .unwrap() + .get() + .contains("withdrawal proof RPCs are not configured") + ); + } + fn signed_eip1559(signer: &PrivateKeySigner) -> TxEnvelope { let tx = TxEip1559 { chain_id: 33333333,