From c624336b246163a81cebdb5524c82ef1b32979ed Mon Sep 17 00:00:00 2001 From: Ameya Deshmukh Date: Thu, 3 Sep 2026 16:43:34 +0530 Subject: [PATCH] feat: add Base Sepolia internal funding and controlled test USDC Additive Base Sepolia support for the machine funding server, behind INTERNAL_FUNDING_BASE_ENABLED. Existing Seismic routes, budgets, ledger keys, and deployment commands are unchanged. - BaseChainDriver: standard EVM (EIP-1559) driver where the dedicated reserve key sends native ETH gas drips and plain ERC20 USDC transfers; startup preflight checks chain id, signer/address match, token code, six decimals, and both reserves against configured floors - routes POST /api/internal/base/gas, POST /api/internal/base/erc20-usdc/ transfers, GET /api/internal/base/readiness (503 reserve_low below either floor); same bearer auth, Redis idempotency, budgets, rate limits - every Base ledger key is scoped to asset + chain id + token + reserve - refuses a Base key that reuses a Seismic funding key unless explicitly approved via INTERNAL_FUNDING_BASE_ALLOW_SHARED_KEY - DeployBaseTestnetUSDC.s.sol deploys the six-decimal TestnetUSDC on Base and mints the initial supply into the reserve; deploy_base_erc20_usdc_ contract alias records the returned address and runs --check-base - risk register: Base section --- Cargo.lock | 2 + Cargo.toml | 1 + contracts/script/DeployBaseTestnetUSDC.s.sol | 36 ++ deploy/bash_aliases | 79 +++ machine-funding-server/Cargo.toml | 1 + machine-funding-server/RISK_REGISTER.md | 29 + machine-funding-server/src/base_chain.rs | 540 ++++++++++++++++++ machine-funding-server/src/chain.rs | 25 +- machine-funding-server/src/config.rs | 302 +++++++++- machine-funding-server/src/http.rs | 201 ++++++- machine-funding-server/src/lib.rs | 8 +- machine-funding-server/src/main.rs | 55 +- machine-funding-server/src/model.rs | 54 ++ machine-funding-server/src/service.rs | 44 +- .../tests/redis_semantics.rs | 525 ++++++++++++++++- 15 files changed, 1854 insertions(+), 48 deletions(-) create mode 100644 contracts/script/DeployBaseTestnetUSDC.s.sol create mode 100644 machine-funding-server/src/base_chain.rs diff --git a/Cargo.lock b/Cargo.lock index 251a499..5588189 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,7 @@ dependencies = [ "alloy-primitives", "alloy-rlp", "borsh", + "k256", "serde", "thiserror 2.0.20", ] @@ -1777,6 +1778,7 @@ name = "faucet-machine-funding" version = "0.1.0" dependencies = [ "alloy-chains", + "alloy-consensus", "alloy-contract", "alloy-eips", "alloy-json-rpc", diff --git a/Cargo.toml b/Cargo.toml index 72003eb..97da6aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ axum = "0.7.9" seismic-alloy-network = { git = "https://github.com/SeismicSystems/seismic-alloy.git", rev = "c8fad0b93da53f2b423d2f6720abdb4a6cbb9082" } seismic-alloy-provider = { git = "https://github.com/SeismicSystems/seismic-alloy.git", rev = "c8fad0b93da53f2b423d2f6720abdb4a6cbb9082" } seismic-alloy-rpc-types = { git = "https://github.com/SeismicSystems/seismic-alloy.git", rev = "c8fad0b93da53f2b423d2f6720abdb4a6cbb9082" } +alloy-consensus = "=1.1.0" alloy-contract = "=1.1.0" alloy-chains = "=0.2.17" alloy-eips = "=1.1.0" diff --git a/contracts/script/DeployBaseTestnetUSDC.s.sol b/contracts/script/DeployBaseTestnetUSDC.s.sol new file mode 100644 index 0000000..966cd17 --- /dev/null +++ b/contracts/script/DeployBaseTestnetUSDC.s.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.30; + +import {Script, console} from "forge-std/Script.sol"; +import {TestnetUSDC} from "../src/TestnetUSDC.sol"; + +/// @notice Deploys the controlled six-decimal test USDC on Base Sepolia and +/// mints the initial supply into the machine-funding reserve. There is no +/// faucet contract on Base: the reserve key transfers directly, so the +/// reserve holds the tokens and the deployer keeps mint authority. +contract DeployBaseTestnetUSDCScript is Script { + uint256 internal constant DEFAULT_INITIAL_RESERVE_SUPPLY = 1_000_000e6; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("BASE_DEPLOYER_PRIVATE_KEY"); + address reserveAccount = vm.envAddress("INTERNAL_FUNDING_BASE_ADDRESS"); + uint256 initialSupply = vm.envOr("BASE_ERC20_USDC_INITIAL_RESERVE_SUPPLY", DEFAULT_INITIAL_RESERVE_SUPPLY); + + address deployer = vm.addr(deployerPrivateKey); + require(reserveAccount != address(0), "Invalid reserve account"); + require(initialSupply > 0, "Invalid initial supply"); + vm.startBroadcast(deployerPrivateKey); + + TestnetUSDC usdc = new TestnetUSDC(deployer); + usdc.mint(reserveAccount, initialSupply); + + vm.stopBroadcast(); + + console.log("=== Base Testnet USDC Deployment ==="); + console.log("USDC token:", address(usdc)); + console.log("Chain ID:", block.chainid); + console.log("Token owner / minter:", deployer); + console.log("Machine funding reserve:", reserveAccount); + console.log("Initial reserve supply (USDC, 6d):", initialSupply); + } +} diff --git a/deploy/bash_aliases b/deploy/bash_aliases index 0544aaf..f0a35ca 100644 --- a/deploy/bash_aliases +++ b/deploy/bash_aliases @@ -152,6 +152,12 @@ deploy_machine_faucet_server() ( http://127.0.0.1:3002/api/internal/erc20-usdc/readiness echo fi + if [ "${INTERNAL_FUNDING_BASE_ENABLED:-false}" = true ]; then + curl --fail --silent --show-error \ + -H "Authorization: Bearer $INTERNAL_FUNDING_TOKEN" \ + http://127.0.0.1:3002/api/internal/base/readiness + echo + fi ) deploy_contracts() ( @@ -282,3 +288,76 @@ deploy_erc20_usdc_contracts() ( echo "ERC20 USDC preflight passed and configuration is enabled in $machine_env" echo "activate it with: deploy_machine_faucet_server" ) + +# Base Sepolia test USDC: a plain six-decimal ERC20 minted into the dedicated +# Base reserve. No faucet contract is deployed on Base — the reserve key +# transfers directly — so only the token address is recorded. The human must +# have funded INTERNAL_FUNDING_BASE_ADDRESS with Base Sepolia ETH first; the +# deployer key pays for the deployment itself. Uses stock foundry (forge/cast) +# because Base is a standard EVM network. +deploy_base_erc20_usdc_contract() ( + set -e + _faucet_lock_acquire deploy_base_erc20_usdc_contract + machine_env=$_faucet_repo/.env.machine-funding + + _faucet_require_file "$machine_env" + _faucet_require_command jq + _faucet_require_command cast + _faucet_require_command forge + + set -a + source "$machine_env" + set +a + _faucet_require_env BASE_RPC_URL + _faucet_require_env BASE_CHAIN_ID + _faucet_require_env BASE_DEPLOYER_PRIVATE_KEY + _faucet_require_env INTERNAL_FUNDING_BASE_ADDRESS + _faucet_require_env INTERNAL_FUNDING_BASE_PRIVATE_KEY + _faucet_require_env INTERNAL_FUNDING_BASE_GAS_ETH_AMOUNT + _faucet_require_env INTERNAL_FUNDING_MAX_BASE_ERC20_USDC_AMOUNT + _faucet_require_env INTERNAL_FUNDING_GLOBAL_BASE_GAS_ETH_BUDGET + _faucet_require_env INTERNAL_FUNDING_GLOBAL_BASE_ERC20_USDC_BUDGET + + chain_id=$(cast chain-id --rpc-url "$BASE_RPC_URL") + [ "$chain_id" = "$BASE_CHAIN_ID" ] || { + echo "BASE_RPC_URL reports chain id $chain_id, expected BASE_CHAIN_ID=$BASE_CHAIN_ID" >&2 + exit 1 + } + + _faucet_set_env_value "$machine_env" INTERNAL_FUNDING_BASE_ENABLED false + + cd "$_faucet_repo/contracts" + forge script script/DeployBaseTestnetUSDC.s.sol \ + --rpc-url "$BASE_RPC_URL" \ + --broadcast \ + --private-key "$BASE_DEPLOYER_PRIVATE_KEY" + + broadcast=$_faucet_repo/contracts/broadcast/DeployBaseTestnetUSDC.s.sol/$chain_id/run-latest.json + _faucet_require_file "$broadcast" + usdc_address=$(jq -er ' + [.transactions[] + | select(.contractName == "TestnetUSDC") + | .contractAddress + | select(. != null)] + | last + ' "$broadcast") + [[ "$usdc_address" =~ ^0x[0-9a-fA-F]{40}$ ]] || { + echo "invalid Base ERC20 USDC address in $broadcast: $usdc_address" >&2 + exit 1 + } + + _faucet_set_env_value "$machine_env" BASE_ERC20_USDC_TOKEN_ADDRESS "$usdc_address" + + cd "$_faucet_repo" + cargo build --release --locked -p faucet-machine-funding + INTERNAL_FUNDING_BASE_ENABLED=true \ + BASE_ERC20_USDC_TOKEN_ADDRESS="$usdc_address" \ + target/release/faucet-machine-funding --check-base + + _faucet_set_env_value "$machine_env" INTERNAL_FUNDING_BASE_ENABLED true + + echo "deployed Base Sepolia test USDC: $usdc_address" + echo "Base preflight passed and configuration is enabled in $machine_env" + echo "record BASE_ERC20_USDC_ADDRESS=$usdc_address on the sandbox machine; integrations must read it from there, never hardcode it" + echo "activate it with: deploy_machine_faucet_server" +) diff --git a/machine-funding-server/Cargo.toml b/machine-funding-server/Cargo.toml index 13fe43b..efec459 100644 --- a/machine-funding-server/Cargo.toml +++ b/machine-funding-server/Cargo.toml @@ -38,6 +38,7 @@ uuid.workspace = true url.workspace = true [dev-dependencies] +alloy-consensus = { workspace = true, features = ["k256"] } http-body-util.workspace = true tempfile = "3.21.0" tower = { workspace = true, features = ["util"] } diff --git a/machine-funding-server/RISK_REGISTER.md b/machine-funding-server/RISK_REGISTER.md index 89a3659..4bdf7bd 100644 --- a/machine-funding-server/RISK_REGISTER.md +++ b/machine-funding-server/RISK_REGISTER.md @@ -241,6 +241,35 @@ actor can consume it before other testers. Likewise, a poisoned queue can keep legitimate work behind low-value requests. Per-principal quotas and a bounded or fair queue are separate future controls. +## Base Sepolia Funding + +The Base Sepolia routes (`/api/internal/base/gas`, `/api/internal/base/erc20-usdc/transfers`, +`/api/internal/base/readiness`) reuse every existing control: the same nginx +source-IP gate and bearer token, the same Redis reservation, idempotency, and +operator serialization, and per-asset budgets and rate windows. What differs: + +- **The signer is the reserve.** There is no faucet contract on Base, so the + reserve key holds both the native ETH gas reserve and the ERC20 USDC supply + and transfers them directly. A stolen Base key drains both balances outright; + the containment is the small, explicitly funded reserve and the dedicated key. + `INTERNAL_FUNDING_BASE_PRIVATE_KEY` is refused when it matches a Seismic + funding key unless `INTERNAL_FUNDING_BASE_ALLOW_SHARED_KEY=true` is set with + explicit approval. +- **Native gas is real value on other networks.** Base Sepolia ETH has no + real-world value, but the same key format on Base mainnet would. The chain id + is verified at connect time and on every readiness probe, and every Base + ledger key is scoped to `chain id + token + reserve`, so a mispointed RPC + fails closed rather than replaying a testnet ledger against another network. +- **Reserve depletion is a diagnostic, not a surprise.** Startup preflight and + `/api/internal/base/readiness` compare both balances to configured floors + (`INTERNAL_FUNDING_BASE_ETH_RESERVE_FLOOR`, + `INTERNAL_FUNDING_BASE_ERC20_USDC_RESERVE_FLOOR`); readiness answers + `503 reserve_low` below either floor so an alert fires before a drip fails + on-chain with `insufficient funds`, which is otherwise a terminal `422`. +- **EIP-1559 fees are estimated, not capped.** The estimate is doubled for + headroom without a configured ceiling, the same deferred control as the + Seismic gas price. + ## Deferred Hardening ### Staging Hardening diff --git a/machine-funding-server/src/base_chain.rs b/machine-funding-server/src/base_chain.rs new file mode 100644 index 0000000..a6c69ea --- /dev/null +++ b/machine-funding-server/src/base_chain.rs @@ -0,0 +1,540 @@ +//! Chain driver for Base Sepolia: a standard EVM network where the reserve +//! key sends native ETH gas drips and plain ERC20 USDC transfers itself, with +//! no faucet contract in between. Transactions are EIP-1559, signed locally, +//! and persisted before broadcast like the Seismic driver's. + +use crate::{ + chain::{ + chain_error, classify_broadcast_error, deployment_identity_error, rpc_timeout, ChainDriver, + }, + config::BaseConfig, + model::{ + ChainResult, FundingAsset, FundingInput, PreparedTransaction, ReserveDiagnostic, + ServiceError, + }, +}; +use alloy_eips::eip2718::Encodable2718; +use alloy_network::{Ethereum, EthereumWallet, TransactionBuilder}; +use alloy_primitives::{keccak256, Bytes, TxKind, B256, U256}; +use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_types_eth::{TransactionInput, TransactionReceipt, TransactionRequest}; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::{sol, SolCall}; +use async_trait::async_trait; +use std::{str::FromStr, time::Duration}; +use tokio::time::{sleep, timeout}; +use url::Url; + +const ETH_TRANSFER_GAS_LIMIT: u64 = 21_000; +const ERC20_TRANSFER_GAS_LIMIT: u64 = 120_000; +const FEE_HEADROOM_MULTIPLIER: u128 = 2; +const RECEIPT_POLL_INTERVAL: Duration = Duration::from_millis(500); +const EXPECTED_TOKEN_DECIMALS: u8 = 6; + +sol! { + #[sol(rpc)] + interface IERC20 { + function decimals() external view returns (uint8); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + } +} + +#[derive(Clone)] +pub struct BaseChainDriver { + provider: RootProvider, + wallet: EthereumWallet, + config: BaseConfig, + operator_key: String, + receipt_timeout: Duration, +} + +impl BaseChainDriver { + /// Connect and run the full preflight: chain id, signer/address match, + /// token code, six decimals, and both reserves above their floors. + pub async fn connect( + config: &BaseConfig, + receipt_timeout: Duration, + ) -> Result { + let rpc_url = Url::parse(&config.rpc_url).map_err(chain_error)?; + let provider = RootProvider::::new_http(rpc_url); + let chain_id = rpc_timeout(provider.get_chain_id()).await?; + if chain_id != config.chain_id { + return Err(chain_error(format!( + "Base RPC chain id {chain_id} does not match BASE_CHAIN_ID {}", + config.chain_id + ))); + } + let signer = PrivateKeySigner::from_str(&config.private_key).map_err(chain_error)?; + if signer.address() != config.reserve_address { + return Err(chain_error( + "INTERNAL_FUNDING_BASE_PRIVATE_KEY does not match INTERNAL_FUNDING_BASE_ADDRESS", + )); + } + let driver = Self { + provider, + wallet: EthereumWallet::from(signer), + operator_key: format!("{chain_id}:{:#x}", config.reserve_address), + config: config.clone(), + receipt_timeout, + }; + driver.validate_token().await?; + driver.require_reserves().await?; + Ok(driver) + } + + pub fn config(&self) -> &BaseConfig { + &self.config + } + + async fn validate_token(&self) -> Result<(), ServiceError> { + let code = + rpc_timeout(async { self.provider.get_code_at(self.config.token_address).await }) + .await?; + if code.is_empty() { + return Err(chain_error( + "BASE_ERC20_USDC_TOKEN_ADDRESS has no deployed bytecode", + )); + } + let token = IERC20::new(self.config.token_address, &self.provider); + let decimals = rpc_timeout(async { token.decimals().call().await }).await?; + if decimals != EXPECTED_TOKEN_DECIMALS { + return Err(chain_error(format!( + "Base ERC20 USDC token has {decimals} decimals; expected {EXPECTED_TOKEN_DECIMALS}" + ))); + } + Ok(()) + } + + /// Reads both reserve balances and compares them to the configured floors. + pub async fn reserves(&self) -> Result { + let native_balance = + rpc_timeout(async { self.provider.get_balance(self.config.reserve_address).await }) + .await?; + let token = IERC20::new(self.config.token_address, &self.provider); + let erc20_usdc_balance = + rpc_timeout(async { token.balanceOf(self.config.reserve_address).call().await }) + .await?; + let diagnostic = ReserveDiagnostic { + chain_id: self.config.chain_id, + reserve_address: self.config.reserve_address.to_checksum(None), + native_balance: native_balance.to_string(), + native_floor: self.config.eth_reserve_floor.to_string(), + native_low: native_balance < self.config.eth_reserve_floor, + erc20_usdc_balance: erc20_usdc_balance.to_string(), + erc20_usdc_floor: self.config.erc20_usdc_reserve_floor.to_string(), + erc20_usdc_low: erc20_usdc_balance < self.config.erc20_usdc_reserve_floor, + }; + if diagnostic.is_low() { + tracing::warn!( + native_balance = %diagnostic.native_balance, + native_floor = %diagnostic.native_floor, + erc20_usdc_balance = %diagnostic.erc20_usdc_balance, + erc20_usdc_floor = %diagnostic.erc20_usdc_floor, + "Base reserve is below its configured floor" + ); + } + Ok(diagnostic) + } + + async fn require_reserves(&self) -> Result<(), ServiceError> { + let diagnostic = self.reserves().await?; + if diagnostic.native_low { + return Err(chain_error(format!( + "Base reserve ETH balance {} is below floor {}", + diagnostic.native_balance, diagnostic.native_floor + ))); + } + if diagnostic.erc20_usdc_low { + return Err(chain_error(format!( + "Base reserve ERC20 USDC balance {} is below floor {}", + diagnostic.erc20_usdc_balance, diagnostic.erc20_usdc_floor + ))); + } + Ok(()) + } + + fn transaction_request( + &self, + input: &FundingInput, + nonce: u64, + max_fee_per_gas: u128, + max_priority_fee_per_gas: u128, + ) -> Result { + let (to, value, data, gas) = match input.asset { + FundingAsset::BaseEth => ( + input.recipient, + input.amount, + Bytes::new(), + ETH_TRANSFER_GAS_LIMIT, + ), + FundingAsset::BaseErc20Usdc => ( + self.config.token_address, + U256::ZERO, + IERC20::transferCall { + to: input.recipient, + amount: input.amount, + } + .abi_encode() + .into(), + ERC20_TRANSFER_GAS_LIMIT, + ), + FundingAsset::Susdc | FundingAsset::SusdcGas | FundingAsset::Erc20Usdc => { + return Err(deployment_identity_error()); + } + }; + Ok(TransactionRequest { + from: Some(self.config.reserve_address), + to: Some(TxKind::Call(to)), + max_fee_per_gas: Some(max_fee_per_gas), + max_priority_fee_per_gas: Some(max_priority_fee_per_gas), + gas: Some(gas), + value: Some(value), + input: TransactionInput::from(data), + nonce: Some(nonce), + chain_id: Some(self.config.chain_id), + ..Default::default() + }) + } + + async fn sign_transaction( + &self, + input: &FundingInput, + nonce: u64, + max_fee_per_gas: u128, + max_priority_fee_per_gas: u128, + ) -> Result { + let request = + self.transaction_request(input, nonce, max_fee_per_gas, max_priority_fee_per_gas)?; + let envelope = TransactionBuilder::::build(request, &self.wallet) + .await + .map_err(chain_error)?; + let serialized = envelope.encoded_2718(); + let hash = keccak256(&serialized); + Ok(PreparedTransaction { + hash: format!("{hash:#x}"), + nonce, + serialized_transaction: format!("0x{}", hex::encode(serialized)), + }) + } + + async fn receipt_result(&self, hash: B256) -> Result { + match timeout(self.receipt_timeout, async { + loop { + let receipt = rpc_timeout(self.provider.get_transaction_receipt(hash)).await?; + match receipt_status(receipt.as_ref()) { + Some(ChainResult::Reverted) => return Ok(ChainResult::Reverted), + Some(ChainResult::Success) => { + let Some(receipt_block) = receipt + .as_ref() + .and_then(|confirmed| confirmed.block_number) + else { + return Err(ServiceError::internal()); + }; + let latest_block = rpc_timeout(self.provider.get_block_number()).await?; + let required_block = receipt_block + .saturating_add(self.config.confirmations.saturating_sub(1)); + if latest_block >= required_block { + return Ok(ChainResult::Success); + } + } + _ => {} + } + sleep(RECEIPT_POLL_INTERVAL).await; + } + }) + .await + { + Ok(result) => result, + Err(_) => Ok(ChainResult::Pending), + } + } +} + +#[async_trait] +impl ChainDriver for BaseChainDriver { + fn operator_key(&self) -> &str { + &self.operator_key + } + + /// Only Base assets bound to this exact deployment are signed; a request + /// persisted under a rotated token or reserve is rejected before signing. + fn validate_input(&self, input: &FundingInput) -> Result<(), ServiceError> { + if input.asset.is_base() + && input.deployment_identity.is_none() + && input.network_identity.as_ref() == Some(&self.config.identity()) + { + return Ok(()); + } + Err(deployment_identity_error()) + } + + async fn pending_nonce(&self) -> Result { + rpc_timeout(async { + self.provider + .get_transaction_count(self.config.reserve_address) + .pending() + .await + }) + .await + } + + async fn prepare( + &self, + input: &FundingInput, + nonce: u64, + ) -> Result { + let estimate = rpc_timeout(self.provider.estimate_eip1559_fees()).await?; + let max_fee_per_gas = estimate + .max_fee_per_gas + .checked_mul(FEE_HEADROOM_MULTIPLIER) + .ok_or_else(|| chain_error("RPC fee estimate overflow"))?; + self.sign_transaction( + input, + nonce, + max_fee_per_gas, + estimate.max_priority_fee_per_gas, + ) + .await + } + + async fn broadcast_and_confirm( + &self, + transaction: &PreparedTransaction, + ) -> Result { + let raw = hex::decode(transaction.serialized_transaction.trim_start_matches("0x")) + .map_err(chain_error)?; + let expected_hash = B256::from_str(&transaction.hash).map_err(chain_error)?; + match timeout( + crate::chain::RPC_REQUEST_TIMEOUT, + self.provider.send_raw_transaction(&raw), + ) + .await + { + Ok(Ok(pending)) if *pending.tx_hash() != expected_hash => { + return Ok(ChainResult::Rejected( + "RPC returned a different transaction hash".into(), + )); + } + Ok(Ok(_)) => {} + Ok(Err(error)) => { + let message = error.to_string(); + if let Some(result) = classify_broadcast_error(&message) { + return Ok(result); + } + tracing::warn!(%message, hash = %transaction.hash, "Base raw transaction broadcast was not acknowledged"); + } + Err(_) => { + tracing::warn!(hash = %transaction.hash, "Base raw transaction broadcast timed out"); + } + } + self.receipt_result(expected_hash).await + } + + async fn health(&self) -> Result<(), ServiceError> { + let chain_id = rpc_timeout(self.provider.get_chain_id()).await?; + if chain_id != self.config.chain_id { + return Err(chain_error(format!( + "Base RPC chain id changed from {} to {chain_id}", + self.config.chain_id + ))); + } + self.validate_token().await?; + self.require_reserves().await + } + + async fn reserve_diagnostic(&self) -> Result, ServiceError> { + self.reserves().await.map(Some) + } +} + +fn receipt_status(receipt: Option<&TransactionReceipt>) -> Option { + let receipt = receipt?; + receipt.block_number?; + receipt.block_hash?; + if receipt.status() { + Some(ChainResult::Success) + } else { + Some(ChainResult::Reverted) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::transaction::SignerRecoverable; + use alloy_consensus::{Transaction, TxEnvelope}; + use alloy_eips::eip2718::Decodable2718; + use alloy_network::TxSigner; + use alloy_primitives::Address; + + fn base_config(reserve: Address) -> BaseConfig { + BaseConfig { + rpc_url: "http://127.0.0.1:1".into(), + chain_id: 84532, + private_key: format!("0x{}", "3".repeat(64)), + reserve_address: reserve, + token_address: Address::with_last_byte(0x10), + gas_eth_amount: U256::from(1_000_000_000_000_000u64), + max_erc20_usdc_amount: U256::from(250_000_000u64), + global_gas_eth_budget: U256::from(100_000_000_000_000_000u64), + global_erc20_usdc_budget: U256::from(1_000_000_000u64), + eth_reserve_floor: U256::from(1_000_000_000_000_000u64), + erc20_usdc_reserve_floor: U256::from(250_000_000u64), + rate_limit: 10, + rate_window: Duration::from_secs(3_600), + confirmations: 1, + } + } + + fn driver() -> BaseChainDriver { + let signer = PrivateKeySigner::from_str(&format!("0x{}", "3".repeat(64))).unwrap(); + let reserve = signer.address(); + BaseChainDriver { + provider: RootProvider::::new_http(Url::parse("http://127.0.0.1:1").unwrap()), + wallet: EthereumWallet::from(signer), + config: base_config(reserve), + operator_key: format!("84532:{reserve:#x}"), + receipt_timeout: Duration::from_millis(10), + } + } + + fn input(asset: FundingAsset, amount: u64) -> FundingInput { + FundingInput { + asset, + deployment_identity: None, + network_identity: Some(driver().config.identity()), + idempotency_key: "base-order-123".into(), + recipient: Address::with_last_byte(0xa1), + recipient_text: "0x00000000000000000000000000000000000000A1".into(), + amount: U256::from(amount), + reason: "order_payout".into(), + } + } + + #[tokio::test] + async fn eth_gas_drip_is_a_plain_value_transfer_signed_by_the_reserve() { + let driver = driver(); + let gas = input(FundingAsset::BaseEth, 1_000_000_000_000_000); + let prepared = driver + .sign_transaction(&gas, 7, 2_000_000_000, 1_000_000) + .await + .unwrap(); + let raw = hex::decode(prepared.serialized_transaction.trim_start_matches("0x")).unwrap(); + assert_eq!(prepared.hash, format!("{:#x}", keccak256(&raw))); + let envelope = TxEnvelope::decode_2718(&mut raw.as_slice()).unwrap(); + assert!(envelope.is_eip1559()); + assert_eq!(envelope.chain_id(), Some(84532)); + assert_eq!(envelope.nonce(), 7); + assert_eq!(envelope.to(), Some(gas.recipient)); + assert_eq!(envelope.value(), gas.amount); + assert!(envelope.input().is_empty()); + assert_eq!(envelope.gas_limit(), ETH_TRANSFER_GAS_LIMIT); + assert_eq!( + envelope.recover_signer().unwrap(), + driver.config.reserve_address + ); + } + + #[test] + fn erc20_transfer_targets_the_token_with_zero_value() { + let driver = driver(); + let usdc = input(FundingAsset::BaseErc20Usdc, 12_500_000); + let request = driver + .transaction_request(&usdc, 9, 2_000_000_000, 1_000_000) + .unwrap(); + assert_eq!(request.to, Some(TxKind::Call(driver.config.token_address))); + assert_eq!(request.value, Some(U256::ZERO)); + assert_eq!(request.gas, Some(ERC20_TRANSFER_GAS_LIMIT)); + assert_eq!(request.chain_id, Some(84532)); + let calldata = request.input.input().unwrap(); + let decoded = IERC20::transferCall::abi_decode(calldata).unwrap(); + assert_eq!(decoded.to, usdc.recipient); + assert_eq!(decoded.amount, usdc.amount); + } + + #[test] + fn only_base_assets_bound_to_this_deployment_are_accepted() { + let driver = driver(); + assert!(driver + .validate_input(&input(FundingAsset::BaseEth, 1)) + .is_ok()); + assert!(driver + .validate_input(&input(FundingAsset::BaseErc20Usdc, 1)) + .is_ok()); + + let mut seismic = input(FundingAsset::Susdc, 1); + seismic.network_identity = None; + assert_eq!( + driver.validate_input(&seismic).unwrap_err().code, + "deployment_identity_mismatch" + ); + assert!(driver.transaction_request(&seismic, 1, 1, 1).is_err()); + + let mut rotated = input(FundingAsset::BaseErc20Usdc, 1); + rotated.network_identity = Some(crate::model::BaseNetworkIdentity { + token_address: Address::with_last_byte(0x11).to_checksum(None), + ..driver.config.identity() + }); + assert_eq!( + driver.validate_input(&rotated).unwrap_err().code, + "deployment_identity_mismatch" + ); + + let mut unscoped = input(FundingAsset::BaseEth, 1); + unscoped.network_identity = None; + assert!(driver.validate_input(&unscoped).is_err()); + } + + #[test] + fn wallet_signs_for_the_configured_reserve() { + let driver = driver(); + assert_eq!( + TxSigner::::address( + &PrivateKeySigner::from_str(&driver.config.private_key).unwrap() + ), + driver.config.reserve_address + ); + } + + #[test] + fn receipts_distinguish_pending_success_and_revert() { + fn receipt(status: u8, block_number: Option) -> TransactionReceipt { + let block_number = block_number + .map(|number| format!("\"0x{number:x}\"")) + .unwrap_or_else(|| "null".into()); + let bloom = "0".repeat(512); + serde_json::from_str(&format!( + r#"{{ + "blockHash":"0x{block_hash}", + "blockNumber":{block_number}, + "contractAddress":null, + "cumulativeGasUsed":"0x5208", + "effectiveGasPrice":"0x1", + "from":"0x0000000000000000000000000000000000000001", + "gasUsed":"0x5208", + "logs":[], + "logsBloom":"0x{bloom}", + "status":"0x{status:x}", + "to":"0x0000000000000000000000000000000000000002", + "transactionHash":"0x{transaction_hash}", + "transactionIndex":"0x0", + "type":"0x2" + }}"#, + block_hash = "1".repeat(64), + transaction_hash = "2".repeat(64), + )) + .unwrap() + } + assert_eq!(receipt_status(None), None); + assert_eq!(receipt_status(Some(&receipt(1, None))), None); + assert_eq!( + receipt_status(Some(&receipt(0, Some(12)))), + Some(ChainResult::Reverted) + ); + assert_eq!( + receipt_status(Some(&receipt(1, Some(12)))), + Some(ChainResult::Success) + ); + } +} diff --git a/machine-funding-server/src/chain.rs b/machine-funding-server/src/chain.rs index 1771e80..8bcd4be 100644 --- a/machine-funding-server/src/chain.rs +++ b/machine-funding-server/src/chain.rs @@ -1,6 +1,9 @@ use crate::{ config::{Config, Erc20UsdcConfig}, - model::{ChainResult, FundingAsset, FundingInput, PreparedTransaction, ServiceError}, + model::{ + ChainResult, FundingAsset, FundingInput, PreparedTransaction, ReserveDiagnostic, + ServiceError, + }, }; use alloy_eips::eip2718::Encodable2718; use alloy_network::TransactionBuilder; @@ -21,7 +24,7 @@ const LEGACY_TRANSACTION_TYPE: u8 = 0; const TRANSACTION_GAS_LIMIT: u64 = 500_000; const GAS_PRICE_MULTIPLIER: u128 = 2; const RECEIPT_POLL_INTERVAL: Duration = Duration::from_millis(500); -const RPC_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +pub(crate) const RPC_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); const TERMINAL_BROADCAST_ERRORS: &[&str] = &[ "insufficient funds", "intrinsic gas too low", @@ -73,6 +76,11 @@ pub trait ChainDriver: Clone + Send + Sync + 'static { transaction: &PreparedTransaction, ) -> Result; async fn health(&self) -> Result<(), ServiceError>; + /// Reserve balances for operators; `None` for drivers whose reserves are + /// held by a contract rather than the signer. + async fn reserve_diagnostic(&self) -> Result, ServiceError> { + Ok(None) + } } #[derive(Clone)] @@ -177,6 +185,7 @@ impl EvmChainDriver { } .abi_encode() .into(), + FundingAsset::BaseEth | FundingAsset::BaseErc20Usdc => Bytes::new(), } } @@ -188,6 +197,7 @@ impl EvmChainDriver { .as_ref() .map(|config| config.faucet_address) .ok_or_else(deployment_identity_error), + FundingAsset::BaseEth | FundingAsset::BaseErc20Usdc => Err(deployment_identity_error()), } } @@ -393,7 +403,7 @@ impl ChainDriver for EvmChainDriver { Ok(()) } (FundingAsset::Susdc | FundingAsset::SusdcGas, None) - if input.deployment_identity.is_none() => + if input.deployment_identity.is_none() && input.network_identity.is_none() => { Ok(()) } @@ -472,7 +482,7 @@ impl ChainDriver for EvmChainDriver { } } -fn classify_broadcast_error(message: &str) -> Option { +pub(crate) fn classify_broadcast_error(message: &str) -> Option { let lower = message.to_ascii_lowercase(); TERMINAL_BROADCAST_ERRORS .iter() @@ -491,7 +501,7 @@ fn receipt_status(receipt: Option<&SeismicTransactionReceipt>) -> Option( +pub(crate) async fn rpc_timeout( future: impl std::future::Future>, ) -> Result where @@ -503,7 +513,7 @@ where .map_err(chain_error) } -fn chain_error(error: impl std::fmt::Display) -> ServiceError { +pub(crate) fn chain_error(error: impl std::fmt::Display) -> ServiceError { tracing::error!(%error, "machine funding chain operation failed"); ServiceError::new( 502, @@ -512,7 +522,7 @@ fn chain_error(error: impl std::fmt::Display) -> ServiceError { ) } -fn deployment_identity_error() -> ServiceError { +pub(crate) fn deployment_identity_error() -> ServiceError { ServiceError::new( 409, "deployment_identity_mismatch", @@ -549,6 +559,7 @@ mod tests { FundingInput { asset: FundingAsset::Susdc, deployment_identity: None, + network_identity: None, idempotency_key: "order-123".into(), recipient: Address::with_last_byte(0xa1), recipient_text: "0x00000000000000000000000000000000000000A1".into(), diff --git a/machine-funding-server/src/config.rs b/machine-funding-server/src/config.rs index f6ddbdb..704fdb5 100644 --- a/machine-funding-server/src/config.rs +++ b/machine-funding-server/src/config.rs @@ -1,4 +1,4 @@ -use crate::model::Erc20DeploymentIdentity; +use crate::model::{BaseNetworkIdentity, Erc20DeploymentIdentity}; use alloy_primitives::{Address, U256}; use std::{env, net::SocketAddr, str::FromStr, time::Duration}; use thiserror::Error; @@ -14,6 +14,7 @@ pub const MAX_REQUEST_TIMEOUT_MS: u64 = 45_000; const MINIMUM_TOKEN_LENGTH: usize = 32; const MINIMUM_GAS_SUSDC_AMOUNT: u64 = 100_000; const MAX_REDIS_INTEGER: u64 = i64::MAX as u64; +const DEFAULT_BASE_CONFIRMATIONS: u64 = 1; #[derive(Clone)] pub struct Config { @@ -30,6 +31,7 @@ pub struct Config { pub global_susdc_budget: U256, pub global_gas_susdc_budget: U256, pub erc20_usdc: Erc20UsdcActivation, + pub base: BaseActivation, pub rate_limit: u64, pub rate_window: Duration, pub confirmations: u64, @@ -63,6 +65,52 @@ impl Erc20UsdcConfig { } } +/// Base Sepolia funding: a dedicated reserve key holds native ETH for gas +/// drips and standard ERC20 USDC for payouts, and sends both directly. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BaseConfig { + pub rpc_url: String, + pub chain_id: u64, + pub private_key: String, + pub reserve_address: Address, + pub token_address: Address, + pub gas_eth_amount: U256, + pub max_erc20_usdc_amount: U256, + pub global_gas_eth_budget: U256, + pub global_erc20_usdc_budget: U256, + pub eth_reserve_floor: U256, + pub erc20_usdc_reserve_floor: U256, + pub rate_limit: u64, + pub rate_window: Duration, + pub confirmations: u64, +} + +impl BaseConfig { + pub fn identity(&self) -> BaseNetworkIdentity { + BaseNetworkIdentity { + chain_id: self.chain_id, + token_address: self.token_address.to_checksum(None), + reserve_address: self.reserve_address.to_checksum(None), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BaseActivation { + Disabled, + Invalid(String), + Enabled(Box), +} + +impl BaseActivation { + pub fn enabled(&self) -> Option<&BaseConfig> { + match self { + Self::Enabled(config) => Some(config), + Self::Disabled | Self::Invalid(_) => None, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum Erc20UsdcActivation { Disabled, @@ -91,6 +139,8 @@ pub enum ConfigError { BelowMinimum(&'static str, u64), #[error("single-transfer amount exceeds its global budget")] BudgetBelowTransfer, + #[error("{0} reuses a Seismic funding key; set INTERNAL_FUNDING_BASE_ALLOW_SHARED_KEY=true only with explicit approval")] + SharedFundingKey(&'static str), } impl Config { @@ -165,6 +215,18 @@ impl Config { Ok(None) => Erc20UsdcActivation::Disabled, Err(error) => Erc20UsdcActivation::Invalid(error.to_string()), }; + let seismic_keys = [ + private_key.as_str(), + erc20_usdc + .enabled() + .map(|config| config.private_key.as_str()) + .unwrap_or_default(), + ]; + let base = match parse_base_config(&lookup, &seismic_keys) { + Ok(Some(config)) => BaseActivation::Enabled(Box::new(config)), + Ok(None) => BaseActivation::Disabled, + Err(error) => BaseActivation::Invalid(error.to_string()), + }; Ok(Self { bind_addr, @@ -180,6 +242,7 @@ impl Config { global_susdc_budget, global_gas_susdc_budget, erc20_usdc, + base, rate_limit: parse_u64( lookup("INTERNAL_FUNDING_RATE_LIMIT"), DEFAULT_RATE_LIMIT, @@ -285,6 +348,114 @@ fn parse_erc20_usdc_config( })) } +/// Every Base variable is inert until `INTERNAL_FUNDING_BASE_ENABLED=true`; +/// an invalid block then leaves Base unavailable without touching the +/// Seismic services, mirroring the ERC20 USDC activation. +fn parse_base_config( + lookup: &impl Fn(&str) -> Option, + seismic_private_keys: &[&str], +) -> Result, ConfigError> { + let enabled = match lookup("INTERNAL_FUNDING_BASE_ENABLED").as_deref() { + None | Some("false") => false, + Some("true") => true, + Some(_) => return Err(ConfigError::Invalid("INTERNAL_FUNDING_BASE_ENABLED")), + }; + if !enabled { + return Ok(None); + } + let required = |name: &'static str| lookup(name).ok_or(ConfigError::Missing(name)); + let rpc_url = required("BASE_RPC_URL")?; + if url::Url::parse(&rpc_url).is_err() { + return Err(ConfigError::Invalid("BASE_RPC_URL")); + } + let chain_id: u64 = required("BASE_CHAIN_ID")? + .parse() + .map_err(|_| ConfigError::Invalid("BASE_CHAIN_ID"))?; + if chain_id == 0 { + return Err(ConfigError::Invalid("BASE_CHAIN_ID")); + } + let private_key = required("INTERNAL_FUNDING_BASE_PRIVATE_KEY")?; + validate_private_key(&private_key, "INTERNAL_FUNDING_BASE_PRIVATE_KEY")?; + let shared_key_allowed = + lookup("INTERNAL_FUNDING_BASE_ALLOW_SHARED_KEY").as_deref() == Some("true"); + if !shared_key_allowed + && seismic_private_keys + .iter() + .any(|key| !key.is_empty() && key.eq_ignore_ascii_case(&private_key)) + { + return Err(ConfigError::SharedFundingKey( + "INTERNAL_FUNDING_BASE_PRIVATE_KEY", + )); + } + let reserve_address = Address::from_str(&required("INTERNAL_FUNDING_BASE_ADDRESS")?) + .map_err(|_| ConfigError::Invalid("INTERNAL_FUNDING_BASE_ADDRESS"))?; + let token_address = Address::from_str(&required("BASE_ERC20_USDC_TOKEN_ADDRESS")?) + .map_err(|_| ConfigError::Invalid("BASE_ERC20_USDC_TOKEN_ADDRESS"))?; + if reserve_address == Address::ZERO || token_address == Address::ZERO { + return Err(ConfigError::Invalid("INTERNAL_FUNDING_BASE_ADDRESS")); + } + if token_address == reserve_address { + return Err(ConfigError::Invalid("BASE_ERC20_USDC_TOKEN_ADDRESS")); + } + let gas_eth_amount = parse_u256( + required("INTERNAL_FUNDING_BASE_GAS_ETH_AMOUNT")?, + "INTERNAL_FUNDING_BASE_GAS_ETH_AMOUNT", + )?; + let max_erc20_usdc_amount = parse_u256( + required("INTERNAL_FUNDING_MAX_BASE_ERC20_USDC_AMOUNT")?, + "INTERNAL_FUNDING_MAX_BASE_ERC20_USDC_AMOUNT", + )?; + let global_gas_eth_budget = parse_u256( + required("INTERNAL_FUNDING_GLOBAL_BASE_GAS_ETH_BUDGET")?, + "INTERNAL_FUNDING_GLOBAL_BASE_GAS_ETH_BUDGET", + )?; + let global_erc20_usdc_budget = parse_u256( + required("INTERNAL_FUNDING_GLOBAL_BASE_ERC20_USDC_BUDGET")?, + "INTERNAL_FUNDING_GLOBAL_BASE_ERC20_USDC_BUDGET", + )?; + if gas_eth_amount > global_gas_eth_budget || max_erc20_usdc_amount > global_erc20_usdc_budget { + return Err(ConfigError::BudgetBelowTransfer); + } + let eth_reserve_floor = parse_u256( + lookup("INTERNAL_FUNDING_BASE_ETH_RESERVE_FLOOR") + .unwrap_or_else(|| gas_eth_amount.to_string()), + "INTERNAL_FUNDING_BASE_ETH_RESERVE_FLOOR", + )?; + let erc20_usdc_reserve_floor = parse_u256( + lookup("INTERNAL_FUNDING_BASE_ERC20_USDC_RESERVE_FLOOR") + .unwrap_or_else(|| max_erc20_usdc_amount.to_string()), + "INTERNAL_FUNDING_BASE_ERC20_USDC_RESERVE_FLOOR", + )?; + Ok(Some(BaseConfig { + rpc_url, + chain_id, + private_key, + reserve_address, + token_address, + gas_eth_amount, + max_erc20_usdc_amount, + global_gas_eth_budget, + global_erc20_usdc_budget, + eth_reserve_floor, + erc20_usdc_reserve_floor, + rate_limit: parse_u64( + lookup("INTERNAL_FUNDING_BASE_RATE_LIMIT"), + DEFAULT_RATE_LIMIT, + "INTERNAL_FUNDING_BASE_RATE_LIMIT", + )?, + rate_window: Duration::from_secs(parse_u64( + lookup("INTERNAL_FUNDING_BASE_RATE_WINDOW_SECONDS"), + DEFAULT_RATE_WINDOW_SECONDS, + "INTERNAL_FUNDING_BASE_RATE_WINDOW_SECONDS", + )?), + confirmations: parse_u64( + lookup("INTERNAL_FUNDING_BASE_CONFIRMATIONS"), + DEFAULT_BASE_CONFIRMATIONS, + "INTERNAL_FUNDING_BASE_CONFIRMATIONS", + )?, + })) +} + fn validate_private_key(value: &str, name: &'static str) -> Result<(), ConfigError> { if value.len() != 66 || !value.starts_with("0x") || hex::decode(&value[2..]).is_err() { return Err(ConfigError::Invalid(name)); @@ -378,6 +549,40 @@ mod tests { ); } + fn enable_base(values: &mut HashMap<&'static str, String>) { + values.insert("INTERNAL_FUNDING_BASE_ENABLED", "true".into()); + values.insert("BASE_RPC_URL", "https://sepolia.base.org".into()); + values.insert("BASE_CHAIN_ID", "84532".into()); + values.insert( + "INTERNAL_FUNDING_BASE_PRIVATE_KEY", + format!("0x{}", "3".repeat(64)), + ); + values.insert( + "INTERNAL_FUNDING_BASE_ADDRESS", + "0x5A0b54D5dc17e0AadC383d2db43B0a0D3E029c4c".into(), + ); + values.insert( + "BASE_ERC20_USDC_TOKEN_ADDRESS", + "0x036CbD53842c5426634e7929541eC2318f3dCF7e".into(), + ); + values.insert( + "INTERNAL_FUNDING_BASE_GAS_ETH_AMOUNT", + "1000000000000000".into(), + ); + values.insert( + "INTERNAL_FUNDING_MAX_BASE_ERC20_USDC_AMOUNT", + "250000000".into(), + ); + values.insert( + "INTERNAL_FUNDING_GLOBAL_BASE_GAS_ETH_BUDGET", + "100000000000000000".into(), + ); + values.insert( + "INTERNAL_FUNDING_GLOBAL_BASE_ERC20_USDC_BUDGET", + "1000000000".into(), + ); + } + #[test] fn loads_defaults_and_required_limits() { let values = valid_env(); @@ -387,6 +592,101 @@ mod tests { assert_eq!(config.receipt_timeout, Duration::from_secs(20)); assert_eq!(config.request_timeout, Duration::from_secs(45)); assert_eq!(config.erc20_usdc, Erc20UsdcActivation::Disabled); + assert_eq!(config.base, BaseActivation::Disabled); + } + + #[test] + fn loads_base_only_when_explicitly_enabled_with_its_own_scope() { + let mut values = valid_env(); + values.insert("BASE_RPC_URL", "not a url".into()); + let config = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + assert_eq!(config.base, BaseActivation::Disabled); + + enable_base(&mut values); + let config = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + let base = config.base.enabled().unwrap(); + assert_eq!(base.chain_id, 84532); + assert_eq!(base.gas_eth_amount, U256::from(1_000_000_000_000_000u64)); + assert_eq!(base.eth_reserve_floor, base.gas_eth_amount); + assert_eq!(base.erc20_usdc_reserve_floor, base.max_erc20_usdc_amount); + assert_eq!(base.confirmations, DEFAULT_BASE_CONFIRMATIONS); + assert_eq!(base.rate_limit, DEFAULT_RATE_LIMIT); + assert_eq!( + base.identity().scope(), + concat!( + "base:84532:", + "0x036cbd53842c5426634e7929541ec2318f3dcf7e:", + "0x5a0b54d5dc17e0aadc383d2db43b0a0d3e029c4c" + ) + ); + assert_eq!(config.erc20_usdc, Erc20UsdcActivation::Disabled); + } + + #[test] + fn base_reserve_floors_and_budgets_are_validated() { + let mut values = valid_env(); + enable_base(&mut values); + values.insert( + "INTERNAL_FUNDING_BASE_ETH_RESERVE_FLOOR", + "5000000000000000".into(), + ); + let config = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + assert_eq!( + config.base.enabled().unwrap().eth_reserve_floor, + U256::from(5_000_000_000_000_000u64) + ); + + values.insert( + "INTERNAL_FUNDING_GLOBAL_BASE_GAS_ETH_BUDGET", + "999999999999999".into(), + ); + let config = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + assert_eq!( + config.base, + BaseActivation::Invalid(ConfigError::BudgetBelowTransfer.to_string()) + ); + } + + #[test] + fn invalid_base_config_does_not_prevent_legacy_startup() { + let mut values = valid_env(); + values.insert("INTERNAL_FUNDING_BASE_ENABLED", "true".into()); + let config = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + assert!(matches!(config.base, BaseActivation::Invalid(_))); + + enable_base(&mut values); + values.insert("BASE_CHAIN_ID", "0".into()); + let config = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + assert!(matches!(config.base, BaseActivation::Invalid(_))); + + enable_base(&mut values); + values.insert( + "BASE_ERC20_USDC_TOKEN_ADDRESS", + "0x5A0b54D5dc17e0AadC383d2db43B0a0D3E029c4c".into(), + ); + let config = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + assert!(matches!(config.base, BaseActivation::Invalid(_))); + } + + #[test] + fn base_refuses_a_reused_seismic_key_unless_explicitly_approved() { + let mut values = valid_env(); + enable_base(&mut values); + values.insert( + "INTERNAL_FUNDING_BASE_PRIVATE_KEY", + format!("0x{}", "1".repeat(64)), + ); + let config = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + assert_eq!( + config.base, + BaseActivation::Invalid( + ConfigError::SharedFundingKey("INTERNAL_FUNDING_BASE_PRIVATE_KEY").to_string() + ) + ); + + values.insert("INTERNAL_FUNDING_BASE_ALLOW_SHARED_KEY", "true".into()); + let config = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + assert!(config.base.enabled().is_some()); } #[test] diff --git a/machine-funding-server/src/http.rs b/machine-funding-server/src/http.rs index 64c1b16..e803708 100644 --- a/machine-funding-server/src/http.rs +++ b/machine-funding-server/src/http.rs @@ -2,9 +2,9 @@ use crate::{ chain::ChainDriver, model::{ ErrorBody, ErrorEnvelope, FundingAsset, FundingInput, FundingResponse, GasRequest, - ServiceError, SusdcRequest, + ReserveDiagnostic, ServiceError, SusdcRequest, }, - service::FundingService, + service::{base_unavailable, FundingService}, store::FundingStore, }; use alloy_primitives::{Address, U256}; @@ -33,9 +33,18 @@ pub enum Erc20FundingService { Enabled(Box>), } -struct RouterState { +/// Base Sepolia funding behind its own driver type: the Seismic services +/// and this one never share a signer, queue, or ledger scope. +pub enum BaseFundingService { + Disabled, + Unavailable, + Enabled(Box>), +} + +struct RouterState { legacy: FundingService, erc20_usdc: Erc20FundingService, + base: BaseFundingService, } pub fn router(legacy: FundingService) -> Router @@ -53,31 +62,58 @@ pub fn router_with_erc20( where S: FundingStore, D: ChainDriver, +{ + router_with_networks::(legacy, erc20_usdc, BaseFundingService::Disabled) +} + +pub fn router_with_networks( + legacy: FundingService, + erc20_usdc: Erc20FundingService, + base: BaseFundingService, +) -> Router +where + S: FundingStore, + D: ChainDriver, + B: ChainDriver, { Router::new() .route("/api/internal/health", get(liveness)) - .route("/api/internal/readiness", get(readiness::)) - .route("/api/internal/transfers", post(transfer::)) - .route("/api/internal/gas", post(gas::)) + .route("/api/internal/readiness", get(readiness::)) + .route("/api/internal/transfers", post(transfer::)) + .route("/api/internal/gas", post(gas::)) .route( "/api/internal/erc20-usdc/transfers", - post(erc20_usdc_transfer::), + post(erc20_usdc_transfer::), ) .route( "/api/internal/erc20-usdc/readiness", - get(erc20_usdc_readiness::), + get(erc20_usdc_readiness::), + ) + .route("/api/internal/base/gas", post(base_gas::)) + .route( + "/api/internal/base/erc20-usdc/transfers", + post(base_erc20_usdc_transfer::), + ) + .route( + "/api/internal/base/readiness", + get(base_readiness::), ) - .with_state(Arc::new(RouterState { legacy, erc20_usdc })) + .with_state(Arc::new(RouterState { + legacy, + erc20_usdc, + base, + })) } -async fn transfer( - State(state): State>>, +async fn transfer( + State(state): State>>, headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result, ServiceError> where S: FundingStore, D: ChainDriver, + B: ChainDriver, { let service = &state.legacy; authorize(&headers, &service.config().token)?; @@ -94,14 +130,15 @@ where .map(Json) } -async fn gas( - State(state): State>>, +async fn gas( + State(state): State>>, headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result, ServiceError> where S: FundingStore, D: ChainDriver, + B: ChainDriver, { let service = &state.legacy; authorize(&headers, &service.config().token)?; @@ -117,14 +154,15 @@ where .map(Json) } -async fn erc20_usdc_transfer( - State(state): State>>, +async fn erc20_usdc_transfer( + State(state): State>>, headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result, ServiceError> where S: FundingStore, D: ChainDriver, + B: ChainDriver, { authorize(&headers, &state.legacy.config().token)?; let service = erc20_service(&state)?; @@ -152,17 +190,135 @@ where .map(Json) } +/// Native ETH gas drip on Base. The amount is fixed by configuration; the +/// request names only the recipient, like the sUSDC gas route. +async fn base_gas( + State(state): State>>, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ServiceError> +where + S: FundingStore, + D: ChainDriver, + B: ChainDriver, +{ + authorize(&headers, &state.legacy.config().token)?; + let service = base_service(&state)?; + let base = service + .config() + .base + .enabled() + .ok_or_else(base_unavailable)?; + let Json(request) = payload.map_err(invalid_json)?; + let common = validate_common(request.idempotency_key, request.recipient, request.reason)?; + service + .execute(FundingInput { + asset: FundingAsset::BaseEth, + network_identity: Some(base.identity()), + amount: base.gas_eth_amount, + ..common + }) + .await + .map(Json) +} + +async fn base_erc20_usdc_transfer( + State(state): State>>, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ServiceError> +where + S: FundingStore, + D: ChainDriver, + B: ChainDriver, +{ + authorize(&headers, &state.legacy.config().token)?; + let service = base_service(&state)?; + let base = service + .config() + .base + .enabled() + .ok_or_else(base_unavailable)?; + let Json(request) = payload.map_err(invalid_json)?; + let common = validate_common(request.idempotency_key, request.recipient, request.reason)?; + let amount = parse_amount(&request.amount, base.max_erc20_usdc_amount)?; + service + .execute(FundingInput { + asset: FundingAsset::BaseErc20Usdc, + network_identity: Some(base.identity()), + amount, + ..common + }) + .await + .map(Json) +} + +/// Readiness plus the reserve diagnostic: `200` while both reserves are at +/// or above their floors, `503 reserve_low` once either drops below, so an +/// operator alert fires before a drip actually fails on-chain. +async fn base_readiness( + State(state): State>>, + headers: HeaderMap, +) -> Result, ServiceError> +where + S: FundingStore, + D: ChainDriver, + B: ChainDriver, +{ + authorize(&headers, &state.legacy.config().token)?; + let service = base_service(&state)?; + let reserves = service.reserve_diagnostic().await?; + if reserves.as_ref().is_some_and(ReserveDiagnostic::is_low) { + return Err(ServiceError::new( + 503, + "reserve_low", + "Base reserve is below its configured floor", + )); + } + Ok(Json(BaseHealth { + status: "ok", + reserves, + })) +} + +fn base_service( + state: &RouterState, +) -> Result<&FundingService, ServiceError> +where + S: FundingStore, + D: ChainDriver, + B: ChainDriver, +{ + match &state.base { + BaseFundingService::Enabled(service) => Ok(service), + BaseFundingService::Disabled => Err(ServiceError::new( + 404, + "base_disabled", + "Base funding is not enabled", + )), + BaseFundingService::Unavailable => Err(base_unavailable()), + } +} + +#[derive(Serialize)] +struct BaseHealth { + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + reserves: Option, +} + async fn liveness() -> Json { Json(Health { status: "ok" }) } -async fn readiness( - State(state): State>>, +async fn readiness( + State(state): State>>, headers: HeaderMap, ) -> Result, ServiceError> where S: FundingStore, D: ChainDriver, + B: ChainDriver, { let service = &state.legacy; authorize(&headers, &service.config().token)?; @@ -170,23 +326,27 @@ where Ok(Json(Health { status: "ok" })) } -async fn erc20_usdc_readiness( - State(state): State>>, +async fn erc20_usdc_readiness( + State(state): State>>, headers: HeaderMap, ) -> Result, ServiceError> where S: FundingStore, D: ChainDriver, + B: ChainDriver, { authorize(&headers, &state.legacy.config().token)?; erc20_service(&state)?.health().await?; Ok(Json(Health { status: "ok" })) } -fn erc20_service(state: &RouterState) -> Result<&FundingService, ServiceError> +fn erc20_service( + state: &RouterState, +) -> Result<&FundingService, ServiceError> where S: FundingStore, D: ChainDriver, + B: ChainDriver, { match &state.erc20_usdc { Erc20FundingService::Enabled(service) => Ok(service), @@ -271,6 +431,7 @@ fn validate_common( Ok(FundingInput { asset: FundingAsset::Susdc, deployment_identity: None, + network_identity: None, idempotency_key, recipient, recipient_text: recipient.to_checksum(None), diff --git a/machine-funding-server/src/lib.rs b/machine-funding-server/src/lib.rs index 38b43af..1a9b489 100644 --- a/machine-funding-server/src/lib.rs +++ b/machine-funding-server/src/lib.rs @@ -1,3 +1,4 @@ +pub mod base_chain; pub mod chain; pub mod config; pub mod http; @@ -5,8 +6,11 @@ pub mod model; pub mod service; pub mod store; +pub use base_chain::BaseChainDriver; pub use chain::{ChainDriver, EvmChainDriver}; -pub use config::{Config, Erc20UsdcActivation}; -pub use http::{router, router_with_erc20, Erc20FundingService}; +pub use config::{BaseActivation, Config, Erc20UsdcActivation}; +pub use http::{ + router, router_with_erc20, router_with_networks, BaseFundingService, Erc20FundingService, +}; pub use service::FundingService; pub use store::RedisStore; diff --git a/machine-funding-server/src/main.rs b/machine-funding-server/src/main.rs index 5bb41be..362fc22 100644 --- a/machine-funding-server/src/main.rs +++ b/machine-funding-server/src/main.rs @@ -1,7 +1,7 @@ use faucet_machine_funding::store::FundingStore; use faucet_machine_funding::{ - router_with_erc20, ChainDriver, Config, Erc20FundingService, Erc20UsdcActivation, - EvmChainDriver, FundingService, RedisStore, + router_with_networks, BaseActivation, BaseChainDriver, BaseFundingService, ChainDriver, Config, + Erc20FundingService, Erc20UsdcActivation, EvmChainDriver, FundingService, RedisStore, }; use std::{env, io}; use tokio::{net::TcpListener, signal}; @@ -18,19 +18,66 @@ async fn main() -> Result<(), Box> { if env::args().any(|argument| argument == "--check-erc20-usdc") { return check_erc20_usdc(&config, &store).await; } + if env::args().any(|argument| argument == "--check-base") { + return check_base(&config, &store).await; + } let driver = EvmChainDriver::connect(&config).await?; let bind_addr = config.bind_addr; let legacy = FundingService::new(store.clone(), driver, config.clone()); legacy.health().await?; - let erc20_usdc = erc20_service(&config, store).await; + let erc20_usdc = erc20_service(&config, store.clone()).await; + let base = base_service(&config, store).await; let listener = TcpListener::bind(bind_addr).await?; tracing::info!(%bind_addr, "machine funding service listening"); - axum::serve(listener, router_with_erc20(legacy, erc20_usdc)) + axum::serve(listener, router_with_networks(legacy, erc20_usdc, base)) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) } +/// Startup preflight for Base: chain id, signer/address match, native ETH +/// reserve, token code, six decimals, and the ERC20 reserve. +async fn check_base(config: &Config, store: &RedisStore) -> Result<(), Box> { + let base = config.base.enabled().ok_or_else(|| { + io::Error::other("valid enabled Base configuration is required for preflight") + })?; + store.ping().await?; + let driver = BaseChainDriver::connect(base, config.receipt_timeout).await?; + let reserves = driver.reserves().await?; + tracing::info!( + native_balance = %reserves.native_balance, + erc20_usdc_balance = %reserves.erc20_usdc_balance, + "Base funding preflight passed" + ); + Ok(()) +} + +async fn base_service( + config: &Config, + store: RedisStore, +) -> BaseFundingService { + match &config.base { + BaseActivation::Disabled => BaseFundingService::Disabled, + BaseActivation::Invalid(error) => { + tracing::error!(%error, "Base funding configuration is invalid"); + BaseFundingService::Unavailable + } + BaseActivation::Enabled(base) => { + match BaseChainDriver::connect(base, config.receipt_timeout).await { + Ok(driver) => BaseFundingService::Enabled(Box::new(FundingService::new( + store, + driver, + config.clone(), + ))), + Err(error) => { + tracing::error!(%error, "Base funding preflight failed"); + BaseFundingService::Unavailable + } + } + } + } +} + async fn check_erc20_usdc( config: &Config, store: &RedisStore, diff --git a/machine-funding-server/src/model.rs b/machine-funding-server/src/model.rs index 5113123..bd966b9 100644 --- a/machine-funding-server/src/model.rs +++ b/machine-funding-server/src/model.rs @@ -8,6 +8,8 @@ pub enum FundingAsset { Susdc, SusdcGas, Erc20Usdc, + BaseEth, + BaseErc20Usdc, } impl FundingAsset { @@ -16,14 +18,21 @@ impl FundingAsset { Self::Susdc => "susdc", Self::SusdcGas => "susdc_gas", Self::Erc20Usdc => "erc20_usdc", + Self::BaseEth => "base_eth", + Self::BaseErc20Usdc => "base_erc20_usdc", } } + + pub fn is_base(self) -> bool { + matches!(self, Self::BaseEth | Self::BaseErc20Usdc) + } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct FundingInput { pub asset: FundingAsset, pub deployment_identity: Option, + pub network_identity: Option, pub idempotency_key: String, pub recipient: Address, pub recipient_text: String, @@ -31,6 +40,27 @@ pub struct FundingInput { pub reason: String, } +/// The Base deployment a request is bound to. Every idempotency record, +/// budget, and rate key on Base carries this scope, so a token redeploy or a +/// reserve-key rotation starts a fresh ledger instead of replaying the old one. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct BaseNetworkIdentity { + pub chain_id: u64, + pub token_address: String, + pub reserve_address: String, +} + +impl BaseNetworkIdentity { + pub fn scope(&self) -> String { + format!( + "base:{}:{}:{}", + self.chain_id, + self.token_address.to_ascii_lowercase(), + self.reserve_address.to_ascii_lowercase() + ) + } +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct Erc20DeploymentIdentity { pub chain_id: u64, @@ -58,6 +88,8 @@ pub struct PersistedInput { pub asset: FundingAsset, #[serde(default, skip_serializing_if = "Option::is_none")] pub deployment_identity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network_identity: Option, pub idempotency_key: String, pub recipient: String, pub amount: String, @@ -69,6 +101,7 @@ impl From<&FundingInput> for PersistedInput { Self { asset: input.asset, deployment_identity: input.deployment_identity.clone(), + network_identity: input.network_identity.clone(), idempotency_key: input.idempotency_key.clone(), recipient: input.recipient_text.clone(), amount: input.amount.to_string(), @@ -90,6 +123,7 @@ impl TryFrom<&PersistedInput> for FundingInput { Ok(Self { asset: input.asset, deployment_identity: input.deployment_identity.clone(), + network_identity: input.network_identity.clone(), idempotency_key: input.idempotency_key.clone(), recipient, recipient_text: input.recipient.clone(), @@ -229,6 +263,26 @@ impl ServiceError { } } +/// Reserve health of a funding signer, for operators: balances alongside +/// the configured floors and whether either has been breached. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct ReserveDiagnostic { + pub chain_id: u64, + pub reserve_address: String, + pub native_balance: String, + pub native_floor: String, + pub native_low: bool, + pub erc20_usdc_balance: String, + pub erc20_usdc_floor: String, + pub erc20_usdc_low: bool, +} + +impl ReserveDiagnostic { + pub fn is_low(&self) -> bool { + self.native_low || self.erc20_usdc_low + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum ChainResult { Success, diff --git a/machine-funding-server/src/service.rs b/machine-funding-server/src/service.rs index b5b1670..461ec95 100644 --- a/machine-funding-server/src/service.rs +++ b/machine-funding-server/src/service.rs @@ -3,7 +3,7 @@ use crate::{ config::Config, model::{ ChainResult, FundingAsset, FundingInput, FundingRecord, FundingResponse, PersistedInput, - ReservationResult, ServiceError, + ReservationResult, ReserveDiagnostic, ServiceError, }, store::{FundingStore, Reservation}, }; @@ -100,6 +100,11 @@ where tokio::try_join!(self.store.ping(), self.driver.health()).map(|_| ()) } + pub async fn reserve_diagnostic(&self) -> Result, ServiceError> { + self.store.ping().await?; + self.driver.reserve_diagnostic().await + } + pub async fn execute(&self, input: FundingInput) -> Result { match timeout(self.config.request_timeout, self.execute_inner(input)).await { Ok(result) => result, @@ -432,9 +437,19 @@ where FundingAsset::Susdc => Ok(self.config.global_susdc_budget), FundingAsset::SusdcGas => Ok(self.config.global_gas_susdc_budget), FundingAsset::Erc20Usdc => self.erc20_config().map(|config| config.global_budget), + FundingAsset::BaseEth => self + .base_config() + .map(|config| config.global_gas_eth_budget), + FundingAsset::BaseErc20Usdc => self + .base_config() + .map(|config| config.global_erc20_usdc_budget), } } + /// Ledger scope for an input: Seismic assets are keyed by asset alone + /// (the pre-multi-network layout), ERC20 USDC by its contract deployment, + /// and Base assets by asset plus network identity — so every Base key, + /// budget, and rate window is bound to one chain, token, and reserve. fn asset_scope(&self, input: &FundingInput) -> Result { match input.asset { FundingAsset::Erc20Usdc => input @@ -442,6 +457,11 @@ where .as_ref() .map(|identity| identity.scope()) .ok_or_else(erc20_unavailable), + FundingAsset::BaseEth | FundingAsset::BaseErc20Usdc => input + .network_identity + .as_ref() + .map(|identity| format!("{}:{}", input.asset.key(), identity.scope())) + .ok_or_else(base_unavailable), FundingAsset::Susdc | FundingAsset::SusdcGas => Ok(input.asset.key().to_owned()), } } @@ -449,6 +469,9 @@ where fn rate_limit(&self, asset: FundingAsset) -> Result { match asset { FundingAsset::Erc20Usdc => self.erc20_config().map(|config| config.rate_limit), + FundingAsset::BaseEth | FundingAsset::BaseErc20Usdc => { + self.base_config().map(|config| config.rate_limit) + } FundingAsset::Susdc | FundingAsset::SusdcGas => Ok(self.config.rate_limit), } } @@ -456,6 +479,9 @@ where fn rate_window(&self, asset: FundingAsset) -> Result { match asset { FundingAsset::Erc20Usdc => self.erc20_config().map(|config| config.rate_window), + FundingAsset::BaseEth | FundingAsset::BaseErc20Usdc => { + self.base_config().map(|config| config.rate_window) + } FundingAsset::Susdc | FundingAsset::SusdcGas => Ok(self.config.rate_window), } } @@ -466,6 +492,10 @@ where .enabled() .ok_or_else(erc20_unavailable) } + + fn base_config(&self) -> Result<&crate::config::BaseConfig, ServiceError> { + self.config.base.enabled().ok_or_else(base_unavailable) + } } fn terminal_result( @@ -497,9 +527,13 @@ fn erc20_unavailable() -> ServiceError { ) } +pub(crate) fn base_unavailable() -> ServiceError { + ServiceError::new(503, "base_unavailable", "Base funding is unavailable") +} + fn record_key(input: &FundingInput, asset_scope: &str) -> String { match input.asset { - FundingAsset::Erc20Usdc => format!( + FundingAsset::Erc20Usdc | FundingAsset::BaseEth | FundingAsset::BaseErc20Usdc => format!( "machine-funding:{asset_scope}:idempotency:{}", input.idempotency_key ), @@ -527,7 +561,9 @@ fn recipient_rate_key(input: &FundingInput, asset_scope: &str) -> String { fn global_budget_key(asset: FundingAsset, asset_scope: &str) -> String { match asset { - FundingAsset::Erc20Usdc => format!("machine-funding:budget:{asset_scope}"), + FundingAsset::Erc20Usdc | FundingAsset::BaseEth | FundingAsset::BaseErc20Usdc => { + format!("machine-funding:budget:{asset_scope}") + } FundingAsset::Susdc | FundingAsset::SusdcGas => { format!("machine-funding:budget:{}", asset.key()) } @@ -536,7 +572,7 @@ fn global_budget_key(asset: FundingAsset, asset_scope: &str) -> String { fn fingerprint(input: &FundingInput, asset_scope: &str) -> String { let value = match input.asset { - FundingAsset::Erc20Usdc => format!( + FundingAsset::Erc20Usdc | FundingAsset::BaseEth | FundingAsset::BaseErc20Usdc => format!( "{}\n{}\n{}\n{}", asset_scope, input.recipient_text.to_ascii_lowercase(), diff --git a/machine-funding-server/tests/redis_semantics.rs b/machine-funding-server/tests/redis_semantics.rs index bf22c18..b0e928d 100644 --- a/machine-funding-server/tests/redis_semantics.rs +++ b/machine-funding-server/tests/redis_semantics.rs @@ -6,14 +6,14 @@ use axum::{ }; use faucet_machine_funding::{ chain::ChainDriver, - config::{Erc20UsdcActivation, Erc20UsdcConfig}, + config::{BaseActivation, BaseConfig, Erc20UsdcActivation, Erc20UsdcConfig}, model::{ - ChainResult, Erc20DeploymentIdentity, FundingAsset, FundingInput, FundingRecord, - PersistedInput, PreparedTransaction, ServiceError, + BaseNetworkIdentity, ChainResult, Erc20DeploymentIdentity, FundingAsset, FundingInput, + FundingRecord, PersistedInput, PreparedTransaction, ReserveDiagnostic, ServiceError, }, - router, router_with_erc20, + router, router_with_erc20, router_with_networks, store::FundingStore, - Config, Erc20FundingService, FundingService, RedisStore, + BaseFundingService, Config, Erc20FundingService, FundingService, RedisStore, }; use http_body_util::BodyExt; use redis::AsyncCommands; @@ -107,6 +107,8 @@ struct FakeDriver { inner: Arc>, operator_key: String, deployment_identity: Option, + network_identity: Option, + reserves: Option, } struct FakeState { @@ -127,9 +129,21 @@ impl FakeDriver { })), operator_key: "5124:0xmachine".into(), deployment_identity: None, + network_identity: None, + reserves: None, } } + fn with_network_identity(mut self, identity: BaseNetworkIdentity) -> Self { + self.network_identity = Some(identity); + self + } + + fn with_reserves(mut self, reserves: ReserveDiagnostic) -> Self { + self.reserves = Some(reserves); + self + } + fn with_operator_key(mut self, operator_key: &str) -> Self { self.operator_key = operator_key.into(); self @@ -158,6 +172,23 @@ impl ChainDriver for FakeDriver { } fn validate_input(&self, input: &FundingInput) -> Result<(), ServiceError> { + let mismatch = || { + ServiceError::new( + 409, + "deployment_identity_mismatch", + "Funding request belongs to a different contract deployment", + ) + }; + if let Some(identity) = &self.network_identity { + return if input.asset.is_base() && input.network_identity.as_ref() == Some(identity) { + Ok(()) + } else { + Err(mismatch()) + }; + } + if input.network_identity.is_some() || input.asset.is_base() { + return Err(mismatch()); + } match (&self.deployment_identity, input.asset) { (Some(identity), FundingAsset::Erc20Usdc) if input.deployment_identity.as_ref() == Some(identity) => @@ -169,11 +200,7 @@ impl ChainDriver for FakeDriver { { Ok(()) } - _ => Err(ServiceError::new( - 409, - "deployment_identity_mismatch", - "Funding request belongs to a different contract deployment", - )), + _ => Err(mismatch()), } } @@ -213,6 +240,10 @@ impl ChainDriver for FakeDriver { async fn health(&self) -> Result<(), ServiceError> { Ok(()) } + + async fn reserve_diagnostic(&self) -> Result, ServiceError> { + Ok(self.reserves.clone()) + } } fn config(redis_url: String) -> Config { @@ -230,6 +261,7 @@ fn config(redis_url: String) -> Config { global_susdc_budget: U256::from(1_000_000_000u64), global_gas_susdc_budget: U256::from(10_000_000u64), erc20_usdc: Erc20UsdcActivation::Disabled, + base: BaseActivation::Disabled, rate_limit: 10, rate_window: Duration::from_secs(60), confirmations: 1, @@ -243,6 +275,7 @@ fn request(key: &str, amount: u64) -> FundingInput { FundingInput { asset: FundingAsset::Susdc, deployment_identity: None, + network_identity: None, idempotency_key: key.into(), recipient: Address::from_str(RECIPIENT).unwrap(), recipient_text: RECIPIENT.into(), @@ -280,6 +313,63 @@ fn erc20_request(config: &Config, key: &str, amount: u64) -> FundingInput { input } +const BASE_TOKEN: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; +const BASE_RESERVE: &str = "0x5A0b54D5dc17e0AadC383d2db43B0a0D3E029c4c"; +const BASE_GAS_WEI: u64 = 1_000_000_000_000_000; + +fn base_config() -> BaseConfig { + BaseConfig { + rpc_url: "http://127.0.0.1:8546".into(), + chain_id: 84532, + private_key: format!("0x{}", "3".repeat(64)), + reserve_address: Address::from_str(BASE_RESERVE).unwrap(), + token_address: Address::from_str(BASE_TOKEN).unwrap(), + gas_eth_amount: U256::from(BASE_GAS_WEI), + max_erc20_usdc_amount: U256::from(250_000_000u64), + global_gas_eth_budget: U256::from(100_000_000_000_000_000u64), + global_erc20_usdc_budget: U256::from(1_000_000_000u64), + eth_reserve_floor: U256::from(BASE_GAS_WEI), + erc20_usdc_reserve_floor: U256::from(250_000_000u64), + rate_limit: 10, + rate_window: Duration::from_secs(60), + confirmations: 1, + } +} + +fn enable_base(config: &mut Config) { + config.base = BaseActivation::Enabled(Box::new(base_config())); +} + +fn base_identity(config: &Config) -> BaseNetworkIdentity { + config.base.enabled().unwrap().identity() +} + +fn base_request(config: &Config, asset: FundingAsset, key: &str, amount: u64) -> FundingInput { + let mut input = request(key, amount); + input.asset = asset; + input.network_identity = Some(base_identity(config)); + input +} + +fn base_driver(config: &Config, results: impl IntoIterator) -> FakeDriver { + FakeDriver::new(results) + .with_operator_key("84532:0xbasereserve") + .with_network_identity(base_identity(config)) +} + +fn healthy_reserves() -> ReserveDiagnostic { + ReserveDiagnostic { + chain_id: 84532, + reserve_address: BASE_RESERVE.into(), + native_balance: "50000000000000000".into(), + native_floor: BASE_GAS_WEI.to_string(), + native_low: false, + erc20_usdc_balance: "1000000000".into(), + erc20_usdc_floor: "250000000".into(), + erc20_usdc_low: false, + } +} + #[test] fn legacy_records_without_deployment_identity_remain_readable() { let record: FundingRecord = serde_json::from_str( @@ -769,3 +859,418 @@ async fn erc20_usdc_route_preserves_machine_funding_wire_shape() { assert_eq!(body["amount"], "12500000"); assert_eq!(body["replayed"], false); } + +#[tokio::test] +async fn base_records_are_scoped_to_asset_and_network_identity() { + let redis = TestRedis::start().await; + let legacy_driver = FakeDriver::new([ChainResult::Success]); + let legacy_service = FundingService::new( + redis.store().await, + legacy_driver.clone(), + config(redis.url.clone()), + ); + let mut base_config = config(redis.url.clone()); + enable_base(&mut base_config); + let identity = base_identity(&base_config); + let driver = base_driver(&base_config, [ChainResult::Success, ChainResult::Success]); + let base_service = + FundingService::new(redis.store().await, driver.clone(), base_config.clone()); + + legacy_service + .execute(request("shared-key", 12_500_000)) + .await + .unwrap(); + let gas = base_service + .execute(base_request( + &base_config, + FundingAsset::BaseEth, + "shared-key", + BASE_GAS_WEI, + )) + .await + .unwrap(); + let usdc = base_service + .execute(base_request( + &base_config, + FundingAsset::BaseErc20Usdc, + "shared-key", + 12_500_000, + )) + .await + .unwrap(); + assert_ne!(gas.transaction_hash, usdc.transaction_hash); + assert!(!gas.replayed && !usdc.replayed); + + let mut connection = redis.raw_connection().await; + for key in [ + "machine-funding:idempotency:shared-key".to_owned(), + format!( + "machine-funding:base_eth:{}:idempotency:shared-key", + identity.scope() + ), + format!( + "machine-funding:base_erc20_usdc:{}:idempotency:shared-key", + identity.scope() + ), + ] { + let record: Option = connection.get(&key).await.unwrap(); + assert!(record.is_some(), "{key} should exist"); + } + assert_eq!(legacy_driver.counts(), (1, 1)); + assert_eq!(driver.counts(), (2, 2)); +} + +#[tokio::test] +async fn base_gas_replays_identically_and_never_double_funds() { + let redis = TestRedis::start().await; + let mut base_config = config(redis.url.clone()); + enable_base(&mut base_config); + let driver = base_driver(&base_config, [ChainResult::Success]); + let service = FundingService::new(redis.store().await, driver.clone(), base_config.clone()); + let gas = base_request( + &base_config, + FundingAsset::BaseEth, + "wallet-1", + BASE_GAS_WEI, + ); + + let (first, second) = tokio::join!(service.execute(gas.clone()), service.execute(gas.clone())); + let first = first.unwrap(); + let second = second.unwrap(); + assert_eq!(first.transaction_hash, second.transaction_hash); + assert_ne!(first.replayed, second.replayed); + let third = service.execute(gas).await.unwrap(); + assert!(third.replayed); + assert_eq!(third.amount, BASE_GAS_WEI.to_string()); + assert_eq!(driver.counts(), (1, 1)); +} + +#[tokio::test] +async fn base_budgets_and_rate_limits_are_independent_from_seismic() { + let redis = TestRedis::start().await; + let mut base_config = config(redis.url.clone()); + enable_base(&mut base_config); + let BaseActivation::Enabled(limits) = &mut base_config.base else { + unreachable!(); + }; + limits.global_erc20_usdc_budget = U256::from(20u64); + limits.global_gas_eth_budget = U256::from(BASE_GAS_WEI); + limits.rate_limit = 1; + let driver = base_driver(&base_config, [ChainResult::Success, ChainResult::Success]); + let service = FundingService::new(redis.store().await, driver.clone(), base_config.clone()); + + service + .execute(base_request( + &base_config, + FundingAsset::BaseErc20Usdc, + "usdc-1", + 15, + )) + .await + .unwrap(); + let rate_error = service + .execute(base_request( + &base_config, + FundingAsset::BaseErc20Usdc, + "usdc-2", + 1, + )) + .await + .unwrap_err(); + assert_eq!(rate_error.code, "rate_limited"); + let mut other = base_request(&base_config, FundingAsset::BaseErc20Usdc, "usdc-3", 10); + other.recipient = Address::with_last_byte(0xa2); + other.recipient_text = other.recipient.to_checksum(None); + assert_eq!( + service.execute(other).await.unwrap_err().code, + "global_budget_exceeded" + ); + + let mut gas = base_request(&base_config, FundingAsset::BaseEth, "gas-1", BASE_GAS_WEI); + gas.recipient = Address::with_last_byte(0xa3); + gas.recipient_text = gas.recipient.to_checksum(None); + service.execute(gas).await.unwrap(); + let mut depleted = base_request(&base_config, FundingAsset::BaseEth, "gas-2", BASE_GAS_WEI); + depleted.recipient = Address::with_last_byte(0xa4); + depleted.recipient_text = depleted.recipient.to_checksum(None); + assert_eq!( + service.execute(depleted).await.unwrap_err().code, + "global_budget_exceeded" + ); + + let legacy_driver = FakeDriver::new([ChainResult::Success]); + let legacy_service = FundingService::new( + redis.store().await, + legacy_driver.clone(), + config(redis.url.clone()), + ); + legacy_service + .execute(request("legacy-1", 10)) + .await + .unwrap(); + assert_eq!(legacy_driver.counts(), (1, 1)); + assert_eq!(driver.counts(), (2, 2)); +} + +#[tokio::test] +async fn base_depleted_reserve_is_terminal_and_never_rebroadcast() { + let redis = TestRedis::start().await; + let mut base_config = config(redis.url.clone()); + enable_base(&mut base_config); + let driver = base_driver( + &base_config, + [ + ChainResult::Rejected("insufficient funds for gas * price + value".into()), + ChainResult::Success, + ], + ); + let service = FundingService::new(redis.store().await, driver.clone(), base_config.clone()); + let gas = base_request( + &base_config, + FundingAsset::BaseEth, + "gas-depleted", + BASE_GAS_WEI, + ); + let first = service.execute(gas.clone()).await.unwrap_err(); + let replay = service.execute(gas).await.unwrap_err(); + assert_eq!(first.status, 422); + assert_eq!(first.code, "transaction_rejected"); + assert_eq!(replay.code, "transaction_rejected"); + assert_eq!(driver.counts(), (1, 1)); +} + +#[tokio::test] +async fn base_requests_are_refused_by_a_seismic_driver_and_vice_versa() { + let redis = TestRedis::start().await; + let mut base_config = config(redis.url.clone()); + enable_base(&mut base_config); + let seismic = FundingService::new( + redis.store().await, + FakeDriver::new([ChainResult::Success]), + base_config.clone(), + ); + let wrong_driver = seismic + .execute(base_request( + &base_config, + FundingAsset::BaseEth, + "cross-1", + BASE_GAS_WEI, + )) + .await + .unwrap_err(); + assert_eq!(wrong_driver.code, "deployment_identity_mismatch"); + + let base = FundingService::new( + redis.store().await, + base_driver(&base_config, [ChainResult::Success]), + base_config.clone(), + ); + let wrong_asset = base.execute(request("cross-2", 10)).await.unwrap_err(); + assert_eq!(wrong_asset.code, "deployment_identity_mismatch"); + + let mut rotated = base_request(&base_config, FundingAsset::BaseErc20Usdc, "cross-3", 10); + rotated.network_identity = Some(BaseNetworkIdentity { + token_address: Address::with_last_byte(0x11).to_checksum(None), + ..base_identity(&base_config) + }); + assert_eq!( + base.execute(rotated).await.unwrap_err().code, + "deployment_identity_mismatch" + ); +} + +#[tokio::test] +async fn base_routes_are_disabled_by_default_and_require_auth() { + let redis = TestRedis::start().await; + let legacy = FundingService::new( + redis.store().await, + FakeDriver::new([]), + config(redis.url.clone()), + ); + let app = router_with_erc20(legacy, Erc20FundingService::Disabled); + let body = r#"{"idempotency_key":"base-1","recipient":"0x00000000000000000000000000000000000000A1","reason":"wallet_registration"}"#; + + let unauthorized = app + .clone() + .oneshot( + Request::post("/api/internal/base/gas") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + + for path in [ + "/api/internal/base/gas", + "/api/internal/base/erc20-usdc/transfers", + ] { + let disabled = app + .clone() + .oneshot( + Request::post(path) + .header(header::CONTENT_TYPE, "application/json") + .header( + header::AUTHORIZATION, + "Bearer a-secure-machine-token-with-32-characters", + ) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(disabled.status(), StatusCode::NOT_FOUND, "{path}"); + } + let readiness = app + .oneshot( + Request::get("/api/internal/base/readiness") + .header( + header::AUTHORIZATION, + "Bearer a-secure-machine-token-with-32-characters", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(readiness.status(), StatusCode::NOT_FOUND); +} + +async fn json_body(response: axum::response::Response) -> serde_json::Value { + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap() +} + +#[tokio::test] +async fn base_routes_preserve_the_machine_funding_wire_and_report_reserves() { + let redis = TestRedis::start().await; + let legacy = FundingService::new( + redis.store().await, + FakeDriver::new([]), + config(redis.url.clone()), + ); + let mut base_config = config(redis.url.clone()); + enable_base(&mut base_config); + let driver = base_driver(&base_config, [ChainResult::Success, ChainResult::Success]) + .with_reserves(healthy_reserves()); + let base = FundingService::new(redis.store().await, driver, base_config); + let app = router_with_networks( + legacy, + Erc20FundingService::Disabled, + BaseFundingService::Enabled(Box::new(base)), + ); + let auth = "Bearer a-secure-machine-token-with-32-characters"; + + let gas = app + .clone() + .oneshot( + Request::post("/api/internal/base/gas") + .header(header::CONTENT_TYPE, "application/json") + .header(header::AUTHORIZATION, auth) + .body(Body::from( + r#"{"idempotency_key":"base-gas-1","recipient":"0x00000000000000000000000000000000000000A1","reason":"wallet_registration"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(gas.status(), StatusCode::OK); + let gas = json_body(gas).await; + assert_eq!(gas["idempotency_key"], "base-gas-1"); + assert_eq!(gas["amount"], BASE_GAS_WEI.to_string()); + assert_eq!(gas["replayed"], false); + + let over_limit = app + .clone() + .oneshot( + Request::post("/api/internal/base/erc20-usdc/transfers") + .header(header::CONTENT_TYPE, "application/json") + .header(header::AUTHORIZATION, auth) + .body(Body::from( + r#"{"idempotency_key":"base-usdc-big","recipient":"0x00000000000000000000000000000000000000A1","amount":"250000001","reason":"order_payout"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(over_limit.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(over_limit).await["error"]["code"], + "amount_exceeds_limit" + ); + + let usdc = app + .clone() + .oneshot( + Request::post("/api/internal/base/erc20-usdc/transfers") + .header(header::CONTENT_TYPE, "application/json") + .header(header::AUTHORIZATION, auth) + .body(Body::from( + r#"{"idempotency_key":"base-usdc-1","recipient":"0x00000000000000000000000000000000000000A1","amount":"12500000","reason":"order_payout"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(usdc.status(), StatusCode::OK); + let usdc = json_body(usdc).await; + assert_eq!(usdc["amount"], "12500000"); + assert_eq!( + usdc["recipient"], + "0x00000000000000000000000000000000000000A1" + ); + + let readiness = app + .oneshot( + Request::get("/api/internal/base/readiness") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(readiness.status(), StatusCode::OK); + let readiness = json_body(readiness).await; + assert_eq!(readiness["status"], "ok"); + assert_eq!(readiness["reserves"]["chain_id"], 84532); + assert_eq!(readiness["reserves"]["native_low"], false); +} + +#[tokio::test] +async fn base_readiness_reports_a_low_reserve_as_unavailable() { + let redis = TestRedis::start().await; + let legacy = FundingService::new( + redis.store().await, + FakeDriver::new([]), + config(redis.url.clone()), + ); + let mut base_config = config(redis.url.clone()); + enable_base(&mut base_config); + let mut low = healthy_reserves(); + low.native_balance = "1".into(); + low.native_low = true; + let base = FundingService::new( + redis.store().await, + base_driver(&base_config, []).with_reserves(low), + base_config, + ); + let app = router_with_networks( + legacy, + Erc20FundingService::Disabled, + BaseFundingService::Enabled(Box::new(base)), + ); + let readiness = app + .oneshot( + Request::get("/api/internal/base/readiness") + .header( + header::AUTHORIZATION, + "Bearer a-secure-machine-token-with-32-characters", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(readiness.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(json_body(readiness).await["error"]["code"], "reserve_low"); +}