From ddced5ecfcc0bcbb18d9989007e73b0a13815bef Mon Sep 17 00:00:00 2001 From: Kevin Chang Date: Wed, 28 Oct 2026 13:27:34 +0800 Subject: [PATCH 1/5] feat: support mainnet proxy and publish ECR images --- .github/workflows/ecr.yml | 29 ++++++++++ README.md | 8 +++ src/config.rs | 33 +++++++++-- src/main.rs | 14 +++-- src/proxy/server.rs | 119 +++++++++++++++++++++++++++++++++----- 5 files changed, 177 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/ecr.yml diff --git a/.github/workflows/ecr.yml b/.github/workflows/ecr.yml new file mode 100644 index 0000000..a94442e --- /dev/null +++ b/.github/workflows/ecr.yml @@ -0,0 +1,29 @@ +name: Publish ECR image + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + publish: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::186312734932:role/rpc-auth-proxy-github-actions-ecr-push + aws-region: us-west-2 + - uses: aws-actions/amazon-ecr-login@v2 + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + push: true + tags: >- + 186312734932.dkr.ecr.us-west-2.amazonaws.com/rpc-auth-proxy:${{ github.event_name == 'workflow_dispatch' && 'mainnet-bootstrap' || github.sha }} diff --git a/README.md b/README.md index 8e4f0bf..731755c 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 an immutable ECR image tagged with the full commit SHA; deployment is a separate reviewed Helm tag update. See the Helm chart README for the one-time bootstrap and secret provisioning steps. diff --git a/src/config.rs b/src/config.rs index 08a2dd9..0a1b080 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,12 +101,13 @@ 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 + url ); } @@ -132,7 +150,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/server.rs b/src/proxy/server.rs index 325566b..23d4912 100644 --- a/src/proxy/server.rs +++ b/src/proxy/server.rs @@ -46,19 +46,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, }) } } @@ -89,11 +94,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); @@ -127,13 +135,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); @@ -327,11 +336,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( @@ -389,3 +406,75 @@ impl EthRpcProxyServer for RpcProxyImpl { proxy_call!(self.validium_client, logs, filter) } } + +#[cfg(test)] +mod tests { + use super::*; + use jsonrpsee::{RpcModule, server::ServerBuilder}; + + #[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", |_, _, _| U256::from(42)) + .unwrap(); + methods + .register_method("eth_maxPriorityFeePerGas", |_, _, _| U256::from(7)) + .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") + ); + } +} From dfc2e88a8ca692407035db05b149b16c637c358d Mon Sep 17 00:00:00 2001 From: Kevin Chang Date: Wed, 28 Oct 2026 13:29:09 +0800 Subject: [PATCH 2/5] test: return JSON-RPC gas quantities from mock --- src/proxy/server.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/proxy/server.rs b/src/proxy/server.rs index 23d4912..4a5cf16 100644 --- a/src/proxy/server.rs +++ b/src/proxy/server.rs @@ -418,10 +418,10 @@ mod tests { let address = server.local_addr().unwrap(); let mut methods = RpcModule::new(()); methods - .register_method("eth_gasPrice", |_, _, _| U256::from(42)) + .register_method("eth_gasPrice", |_, _, _| "0x2a") .unwrap(); methods - .register_method("eth_maxPriorityFeePerGas", |_, _, _| U256::from(7)) + .register_method("eth_maxPriorityFeePerGas", |_, _, _| "0x7") .unwrap(); let handle = server.start(methods); let url = format!("http://{address}"); From ecb34e9811877a3aa0df79be66ef4a9e23d4805c Mon Sep 17 00:00:00 2001 From: Kevin Chang Date: Wed, 28 Oct 2026 13:32:10 +0800 Subject: [PATCH 3/5] style: satisfy current clippy format checks --- src/auth/auth_middleware.rs | 2 +- src/auth/jwt.rs | 4 ++-- src/config.rs | 5 +---- 3 files changed, 4 insertions(+), 7 deletions(-) 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/config.rs b/src/config.rs index 0a1b080..01564c9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -105,10 +105,7 @@ pub fn load_config() -> anyhow::Result { && !url.starts_with("http://") && !url.starts_with("https://") { - anyhow::bail!( - "Invalid withdraw_proofs_url: {}. Must start with http:// or https://", - url - ); + anyhow::bail!("Invalid withdraw_proofs_url: {url}. Must start with http:// or https://"); } Ok(cfg) From d84d1fcfc4ddd6814d35e110b228d96ee9f8a17c Mon Sep 17 00:00:00 2001 From: Kevin Chang Date: Thu, 29 Oct 2026 11:02:31 +0800 Subject: [PATCH 4/5] feat: publish mainnet proxy through Docker Hub --- .github/workflows/docker.yml | 28 +++++++++++++++++++++++++--- .github/workflows/ecr.yml | 29 ----------------------------- README.md | 2 +- 3 files changed, 26 insertions(+), 33 deletions(-) delete mode 100644 .github/workflows/ecr.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 34c9366..0180c60 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,10 +1,22 @@ name: Build and Push Docker Image on: + push: + branches: [main] workflow_dispatch: + inputs: + mainnet_bootstrap: + description: Publish the one-time mainnet bootstrap tag from main + type: boolean + default: false + +permissions: + contents: read jobs: docker: + # Preserve manual builds on other branches, but only bootstrap from main. + if: ${{ !inputs.mainnet_bootstrap || github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest steps: - name: Checkout @@ -16,9 +28,19 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Extract commit digest + - name: Prepare image tags id: vars - run: echo "sha8=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT + env: + MAINNET_BOOTSTRAP: ${{ inputs.mainnet_bootstrap }} + run: | + { + echo 'tags<> "$GITHUB_OUTPUT" - name: Build and Push Docker image uses: docker/build-push-action@v6 @@ -26,4 +48,4 @@ jobs: context: . platforms: linux/amd64 push: true - tags: scrolltech/cloak-rpc-auth-proxy:${{ steps.vars.outputs.sha8 }} + tags: ${{ steps.vars.outputs.tags }} diff --git a/.github/workflows/ecr.yml b/.github/workflows/ecr.yml deleted file mode 100644 index a94442e..0000000 --- a/.github/workflows/ecr.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Publish ECR image - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - id-token: write - -jobs: - publish: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::186312734932:role/rpc-auth-proxy-github-actions-ecr-push - aws-region: us-west-2 - - uses: aws-actions/amazon-ecr-login@v2 - - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64 - push: true - tags: >- - 186312734932.dkr.ecr.us-west-2.amazonaws.com/rpc-auth-proxy:${{ github.event_name == 'workflow_dispatch' && 'mainnet-bootstrap' || github.sha }} diff --git a/README.md b/README.md index 731755c..7938d8f 100644 --- a/README.md +++ b/README.md @@ -132,4 +132,4 @@ The mainnet deployment uses `--forward-gas-prices` to forward `eth_gasPrice` and 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 an immutable ECR image tagged with the full commit SHA; deployment is a separate reviewed Helm tag update. See the Helm chart README for the one-time bootstrap and secret provisioning steps. +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 the one-time bootstrap and secret provisioning steps. From 147e71bef5231f8c3c7bc85442859b7426d2597f Mon Sep 17 00:00:00 2001 From: Kevin Chang Date: Thu, 29 Oct 2026 12:38:50 +0800 Subject: [PATCH 5/5] fix: publish only commit-tagged Docker images --- .github/workflows/docker.yml | 23 +++-------------------- README.md | 2 +- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0180c60..25dce09 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,19 +4,12 @@ on: push: branches: [main] workflow_dispatch: - inputs: - mainnet_bootstrap: - description: Publish the one-time mainnet bootstrap tag from main - type: boolean - default: false permissions: contents: read jobs: docker: - # Preserve manual builds on other branches, but only bootstrap from main. - if: ${{ !inputs.mainnet_bootstrap || github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest steps: - name: Checkout @@ -28,19 +21,9 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Prepare image tags + - name: Extract commit digest id: vars - env: - MAINNET_BOOTSTRAP: ${{ inputs.mainnet_bootstrap }} - run: | - { - echo 'tags<> "$GITHUB_OUTPUT" + run: echo "sha8=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" - name: Build and Push Docker image uses: docker/build-push-action@v6 @@ -48,4 +31,4 @@ jobs: context: . platforms: linux/amd64 push: true - tags: ${{ steps.vars.outputs.tags }} + tags: scrolltech/cloak-rpc-auth-proxy:${{ steps.vars.outputs.sha8 }} diff --git a/README.md b/README.md index 7938d8f..f131cb1 100644 --- a/README.md +++ b/README.md @@ -132,4 +132,4 @@ The mainnet deployment uses `--forward-gas-prices` to forward `eth_gasPrice` and 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 the one-time bootstrap and secret provisioning steps. +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.