diff --git a/.env.machine-funding.example b/.env.machine-funding.example index 385b040..3da3d2a 100644 --- a/.env.machine-funding.example +++ b/.env.machine-funding.example @@ -21,3 +21,12 @@ INTERNAL_FUNDING_MAX_ERC20_USDC_AMOUNT=250000000 INTERNAL_FUNDING_GLOBAL_ERC20_USDC_BUDGET=1000000000 INTERNAL_FUNDING_ERC20_USDC_RATE_LIMIT=10 INTERNAL_FUNDING_ERC20_USDC_RATE_WINDOW_SECONDS=3600 + +# Optional Base token catalog file, read once at startup when Base is enabled. +# Copy funding-tokens.example.json to funding-tokens.json and edit for this deployment. +# Use an absolute path; keep credentials outside the catalog and restart after edits. +# An unreadable/invalid file makes Base unavailable; no file preserves existing defaults. +# Catalog registration does not enable additional transfer endpoints or mint execution. +# Amounts are decimal base-unit strings; mint arguments are whole tokens. +# Permissionless mint strategies require contract review, not selector detection. +# INTERNAL_FUNDING_BASE_TOKEN_CATALOG_FILE=/home/ubuntu/faucet/funding-tokens.json diff --git a/.gitignore b/.gitignore index 21460e4..6dbffc9 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ dump.rdb # Soldeer dependencies (reproducible via soldeer.lock) contracts/dependencies/ + +# Deployment-specific token catalog +/funding-tokens.json diff --git a/funding-tokens.example.json b/funding-tokens.example.json new file mode 100644 index 0000000..19dfbee --- /dev/null +++ b/funding-tokens.example.json @@ -0,0 +1,24 @@ +[ + { + "contract_address": "0x1111111111111111111111111111111111111111", + "decimals": 6, + "max_transfer": "250000000", + "global_budget": "1000000000", + "reserve_floor": "0", + "funding": { + "kind": "caller_mint_whole_tokens", + "max_tokens_per_call": 100, + "max_calls": 4 + } + }, + { + "contract_address": "0x2222222222222222222222222222222222222222", + "decimals": 6, + "max_transfer": "250000000", + "global_budget": "1000000000", + "reserve_floor": "0", + "funding": { + "kind": "manual_inventory" + } + } +] diff --git a/machine-funding-server/src/assets.rs b/machine-funding-server/src/assets.rs new file mode 100644 index 0000000..1ffba1e --- /dev/null +++ b/machine-funding-server/src/assets.rs @@ -0,0 +1,298 @@ +use alloy_primitives::{Address, Bytes, U256}; +use alloy_sol_types::{sol, SolCall}; +use serde::{Deserialize, Deserializer}; +use std::collections::BTreeMap; +use thiserror::Error; + +const MAX_DECIMALS: u8 = 18; +const MAX_MINT_CALLS: u16 = 32; +const MAX_LEDGER_AMOUNT: u64 = i64::MAX as u64; + +sol! { + function mint(uint256 amount); +} + +/// Funding capabilities are operator-reviewed; a mint selector alone does not +/// establish permissionless access or the argument's units. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum FundingStrategy { + ManualInventory, + CallerMintWholeTokens { + max_tokens_per_call: u64, + max_calls: u16, + }, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct FundingToken { + pub contract_address: Address, + pub decimals: u8, + #[serde(deserialize_with = "decimal_amount")] + pub max_transfer: U256, + #[serde(deserialize_with = "decimal_amount")] + pub global_budget: U256, + #[serde(deserialize_with = "decimal_amount")] + pub reserve_floor: U256, + pub funding: FundingStrategy, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum AssetError { + #[error("invalid token address or decimals")] + InvalidIdentity, + #[error("token limits must fit the ledger and budget must cover a transfer")] + InvalidLimits, + #[error("minting must have positive, bounded per-call and per-request limits")] + InvalidMintLimits, + #[error("duplicate token contract")] + DuplicateContract, + #[error("manual reserve funding is required")] + ManualFundingRequired, + #[error("replenishment exceeds the configured mint call limit")] + MintLimitExceeded, + #[error("requested transfer exceeds the configured token limit")] + TransferLimitExceeded, +} + +/// A plan only: the executor must persist and confirm each mint separately +/// from the payout before retrying either operation. +#[derive(Debug, PartialEq, Eq)] +pub struct ReplenishmentPlan { + pub calldata: Vec, + pub minted_base_units: U256, +} + +impl FundingToken { + pub fn validate(&self) -> Result<(), AssetError> { + if self.contract_address == Address::ZERO || self.decimals > MAX_DECIMALS { + return Err(AssetError::InvalidIdentity); + } + let cap = U256::from(MAX_LEDGER_AMOUNT); + if self.max_transfer == U256::ZERO + || self.global_budget < self.max_transfer + || self.global_budget > cap + || self.reserve_floor > cap + { + return Err(AssetError::InvalidLimits); + } + if let FundingStrategy::CallerMintWholeTokens { + max_tokens_per_call, + max_calls, + } = self.funding + { + if max_tokens_per_call == 0 || max_calls == 0 || max_calls > MAX_MINT_CALLS { + return Err(AssetError::InvalidMintLimits); + } + } + Ok(()) + } + + pub fn replenishment( + &self, + balance: U256, + transfer: U256, + ) -> Result { + self.validate()?; + if transfer == U256::ZERO || transfer > self.max_transfer { + return Err(AssetError::TransferLimitExceeded); + } + let target = transfer.max(self.reserve_floor); + if balance >= target { + return Ok(ReplenishmentPlan { + calldata: Vec::new(), + minted_base_units: U256::ZERO, + }); + } + let FundingStrategy::CallerMintWholeTokens { + max_tokens_per_call, + max_calls, + } = self.funding + else { + return Err(AssetError::ManualFundingRequired); + }; + let scale = U256::from(10).pow(U256::from(self.decimals)); + let deficit = target - balance; + let whole_tokens = (deficit + scale - U256::from(1)) / scale; + let per_call = U256::from(max_tokens_per_call); + let count = (whole_tokens + per_call - U256::from(1)) / per_call; + if count > U256::from(max_calls) { + return Err(AssetError::MintLimitExceeded); + } + let mut remaining = whole_tokens; + let mut calldata = Vec::new(); + while remaining > U256::ZERO { + let amount = remaining.min(per_call); + calldata.push(mintCall { amount }.abi_encode().into()); + remaining -= amount; + } + Ok(ReplenishmentPlan { + calldata, + minted_base_units: whole_tokens * scale, + }) + } +} + +#[derive(Debug)] +pub struct FundingCatalog { + default: Address, + tokens: BTreeMap, +} + +impl FundingCatalog { + pub fn new( + default: FundingToken, + additional: impl IntoIterator, + ) -> Result { + let default_address = default.contract_address; + let mut tokens = BTreeMap::new(); + for token in std::iter::once(default).chain(additional) { + token.validate()?; + if tokens.insert(token.contract_address, token).is_some() { + return Err(AssetError::DuplicateContract); + } + } + Ok(Self { + default: default_address, + tokens, + }) + } + + pub fn resolve(&self, contract: Option
) -> Option<&FundingToken> { + self.tokens.get(&contract.unwrap_or(self.default)) + } +} + +fn decimal_amount<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(serde::de::Error::custom( + "expected decimal base-unit string", + )); + } + U256::from_str_radix(&value, 10).map_err(serde::de::Error::custom) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mintable_token() -> FundingToken { + FundingToken { + contract_address: "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(), + decimals: 6, + max_transfer: U256::from(500_000_000), + global_budget: U256::from(1_000_000_000), + reserve_floor: U256::ZERO, + funding: FundingStrategy::CallerMintWholeTokens { + max_tokens_per_call: 100, + max_calls: 4, + }, + } + } + + #[test] + fn mintable_token_uses_whole_tokens_and_bounded_chunks() { + let token = mintable_token(); + let plan = token + .replenishment(U256::ZERO, U256::from(250_000_001)) + .unwrap(); + let amounts: Vec<_> = plan + .calldata + .iter() + .map(|data| mintCall::abi_decode(data).unwrap().amount) + .collect(); + assert_eq!( + amounts, + vec![U256::from(100), U256::from(100), U256::from(51)] + ); + assert_eq!(plan.minted_base_units, U256::from(251_000_000)); + assert_eq!( + token.replenishment(U256::ZERO, U256::from(400_000_001)), + Err(AssetError::MintLimitExceeded) + ); + } + + #[test] + fn manual_inventory_can_pay_but_cannot_replenish() { + let mut token = mintable_token(); + token.funding = FundingStrategy::ManualInventory; + assert!(token + .replenishment(U256::from(10), U256::from(10)) + .unwrap() + .calldata + .is_empty()); + assert_eq!( + token.replenishment(U256::from(9), U256::from(10)), + Err(AssetError::ManualFundingRequired) + ); + } + + #[test] + fn replenishment_accounts_for_existing_balance_and_floor() { + let mut token = mintable_token(); + token.reserve_floor = U256::from(100_000_000); + let plan = token + .replenishment(U256::from(99_999_999), U256::from(1)) + .unwrap(); + assert_eq!(plan.minted_base_units, U256::from(1_000_000)); + assert_eq!( + mintCall::abi_decode(&plan.calldata[0]).unwrap().amount, + U256::from(1) + ); + assert!(token + .replenishment(U256::from(100_000_000), U256::from(1)) + .unwrap() + .calldata + .is_empty()); + } + + #[test] + fn catalog_defaults_are_stable_and_unknown_contracts_do_not_fallback() { + let default = mintable_token(); + let mut other = default.clone(); + other.contract_address = Address::with_last_byte(1); + other.funding = FundingStrategy::ManualInventory; + let catalog = FundingCatalog::new(default.clone(), [other.clone()]).unwrap(); + assert_eq!(catalog.resolve(None), Some(&default)); + assert_eq!(catalog.resolve(Some(other.contract_address)), Some(&other)); + assert!(catalog.resolve(Some(Address::with_last_byte(2))).is_none()); + assert!(matches!( + FundingCatalog::new(default.clone(), [default]), + Err(AssetError::DuplicateContract) + )); + } + + #[test] + fn unsafe_limits_and_ambiguous_amounts_are_rejected() { + let mut token = mintable_token(); + token.decimals = 19; + assert_eq!(token.validate(), Err(AssetError::InvalidIdentity)); + token = mintable_token(); + token.global_budget = U256::from(1); + assert_eq!(token.validate(), Err(AssetError::InvalidLimits)); + token = mintable_token(); + token.funding = FundingStrategy::CallerMintWholeTokens { + max_tokens_per_call: 100, + max_calls: 33, + }; + assert_eq!(token.validate(), Err(AssetError::InvalidMintLimits)); + let json = r#"{"contract_address":"0x1111111111111111111111111111111111111111","decimals":6,"max_transfer":"100","global_budget":"200","reserve_floor":"0","funding":{"kind":"manual_inventory"}}"#; + assert_eq!( + serde_json::from_str::(json) + .unwrap() + .max_transfer, + U256::from(100) + ); + assert!( + serde_json::from_str::(&json.replace("\"100\"", "\"0x64\"")).is_err() + ); + assert!(serde_json::from_str::( + &json.replace("manual_inventory", "auto_detect") + ) + .is_err()); + } +} diff --git a/machine-funding-server/src/base_chain.rs b/machine-funding-server/src/base_chain.rs index d20e6aa..212c602 100644 --- a/machine-funding-server/src/base_chain.rs +++ b/machine-funding-server/src/base_chain.rs @@ -557,6 +557,7 @@ mod tests { private_key: format!("0x{}", "3".repeat(64)), reserve_address: reserve, token_address: Address::with_last_byte(0x10), + additional_erc20_tokens: Vec::new(), 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), diff --git a/machine-funding-server/src/config.rs b/machine-funding-server/src/config.rs index 704fdb5..8e2cf8c 100644 --- a/machine-funding-server/src/config.rs +++ b/machine-funding-server/src/config.rs @@ -1,3 +1,4 @@ +use crate::assets::{AssetError, FundingCatalog, FundingStrategy, FundingToken}; use crate::model::{BaseNetworkIdentity, Erc20DeploymentIdentity}; use alloy_primitives::{Address, U256}; use std::{env, net::SocketAddr, str::FromStr, time::Duration}; @@ -15,6 +16,8 @@ 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; +const ADDITIONAL_BASE_TOKENS_VAR: &str = "INTERNAL_FUNDING_BASE_TOKEN_CATALOG_FILE"; +const BASE_TOKEN_DECIMALS: u8 = 6; #[derive(Clone)] pub struct Config { @@ -74,6 +77,7 @@ pub struct BaseConfig { pub private_key: String, pub reserve_address: Address, pub token_address: Address, + pub additional_erc20_tokens: Vec, pub gas_eth_amount: U256, pub max_erc20_usdc_amount: U256, pub global_gas_eth_budget: U256, @@ -86,6 +90,20 @@ pub struct BaseConfig { } impl BaseConfig { + pub fn token_catalog(&self) -> Result { + FundingCatalog::new( + FundingToken { + contract_address: self.token_address, + decimals: BASE_TOKEN_DECIMALS, + max_transfer: self.max_erc20_usdc_amount, + global_budget: self.global_erc20_usdc_budget, + reserve_floor: self.erc20_usdc_reserve_floor, + funding: FundingStrategy::ManualInventory, + }, + self.additional_erc20_tokens.clone(), + ) + } + pub fn identity(&self) -> BaseNetworkIdentity { BaseNetworkIdentity { chain_id: self.chain_id, @@ -133,6 +151,14 @@ pub enum ConfigError { Missing(&'static str), #[error("{0} is invalid")] Invalid(&'static str), + #[error("token catalog file could not be read: {0}")] + CatalogRead(std::io::Error), + #[error("invalid token catalog JSON: {0}")] + CatalogJson(serde_json::Error), + #[error("invalid token catalog: {0}")] + CatalogValidation(AssetError), + #[error("token catalog contract must not equal the reserve address")] + CatalogReserveAddress, #[error("{0} must be at least {1} characters")] TooShort(&'static str, usize), #[error("{0} must be at least {1}")] @@ -426,12 +452,26 @@ fn parse_base_config( .unwrap_or_else(|| max_erc20_usdc_amount.to_string()), "INTERNAL_FUNDING_BASE_ERC20_USDC_RESERVE_FLOOR", )?; - Ok(Some(BaseConfig { + let additional_erc20_tokens: Vec = lookup(ADDITIONAL_BASE_TOKENS_VAR) + .map(|path| { + let contents = std::fs::read_to_string(path).map_err(ConfigError::CatalogRead)?; + serde_json::from_str(&contents).map_err(ConfigError::CatalogJson) + }) + .transpose()? + .unwrap_or_default(); + if additional_erc20_tokens + .iter() + .any(|token| token.contract_address == reserve_address) + { + return Err(ConfigError::CatalogReserveAddress); + } + let config = BaseConfig { rpc_url, chain_id, private_key, reserve_address, token_address, + additional_erc20_tokens, gas_eth_amount, max_erc20_usdc_amount, global_gas_eth_budget, @@ -453,7 +493,11 @@ fn parse_base_config( DEFAULT_BASE_CONFIRMATIONS, "INTERNAL_FUNDING_BASE_CONFIRMATIONS", )?, - })) + }; + config + .token_catalog() + .map_err(ConfigError::CatalogValidation)?; + Ok(Some(config)) } fn validate_private_key(value: &str, name: &'static str) -> Result<(), ConfigError> { @@ -595,6 +639,88 @@ mod tests { assert_eq!(config.base, BaseActivation::Disabled); } + #[test] + fn additional_tokens_preserve_default_identity_and_require_explicit_strategy() { + let mut values = valid_env(); + enable_base(&mut values); + let original = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + let original = original.base.enabled().unwrap(); + let json = r#"[{"contract_address":"0x1111111111111111111111111111111111111111","decimals":6,"max_transfer":"250000000","global_budget":"1000000000","reserve_floor":"0","funding":{"kind":"caller_mint_whole_tokens","max_tokens_per_call":100,"max_calls":4}}]"#; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("tokens.json"); + std::fs::write(&path, json).unwrap(); + values.insert(ADDITIONAL_BASE_TOKENS_VAR, path.to_str().unwrap().into()); + let config = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + let base = config.base.enabled().unwrap(); + assert_eq!(base.identity(), original.identity()); + assert_eq!( + base.token_catalog() + .unwrap() + .resolve(None) + .unwrap() + .contract_address, + original.token_address + ); + assert_eq!(base.additional_erc20_tokens.len(), 1); + assert_eq!( + base.additional_erc20_tokens[0].max_transfer, + U256::from(250_000_000) + ); + std::fs::write( + &path, + json.replace( + "0x1111111111111111111111111111111111111111", + &original.token_address.to_checksum(None), + ), + ) + .unwrap(); + assert!(matches!( + Config::from_lookup(|key| values.get(key).cloned()) + .unwrap() + .base, + BaseActivation::Invalid(_) + )); + std::fs::write(&path, "not json").unwrap(); + let invalid = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + assert!( + matches!(invalid.base, BaseActivation::Invalid(message) if message.contains("invalid token catalog JSON")) + ); + std::fs::remove_file(&path).unwrap(); + let missing = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + assert!( + matches!(missing.base, BaseActivation::Invalid(message) if message.contains("could not be read")) + ); + values.insert("INTERNAL_FUNDING_BASE_ENABLED", "false".into()); + assert_eq!( + Config::from_lookup(|key| values.get(key).cloned()) + .unwrap() + .base, + BaseActivation::Disabled + ); + } + + #[test] + fn catalog_file_is_loaded_once_and_example_is_valid() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("tokens.json"); + std::fs::write(&path, include_str!("../../funding-tokens.example.json")).unwrap(); + let mut values = valid_env(); + enable_base(&mut values); + values.insert(ADDITIONAL_BASE_TOKENS_VAR, path.to_str().unwrap().into()); + let loaded = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + let base = loaded.base.enabled().unwrap(); + assert_eq!(base.additional_erc20_tokens.len(), 2); + std::fs::write(&path, "[]").unwrap(); + assert_eq!(base.additional_erc20_tokens.len(), 2); + let reloaded = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); + assert!(reloaded + .base + .enabled() + .unwrap() + .additional_erc20_tokens + .is_empty()); + } + #[test] fn loads_base_only_when_explicitly_enabled_with_its_own_scope() { let mut values = valid_env(); diff --git a/machine-funding-server/src/lib.rs b/machine-funding-server/src/lib.rs index 1a9b489..20aa279 100644 --- a/machine-funding-server/src/lib.rs +++ b/machine-funding-server/src/lib.rs @@ -1,3 +1,4 @@ +pub mod assets; pub mod base_chain; pub mod chain; pub mod config; diff --git a/machine-funding-server/tests/redis_semantics.rs b/machine-funding-server/tests/redis_semantics.rs index 14e4d41..17ab04c 100644 --- a/machine-funding-server/tests/redis_semantics.rs +++ b/machine-funding-server/tests/redis_semantics.rs @@ -334,6 +334,7 @@ fn base_config() -> BaseConfig { private_key: format!("0x{}", "3".repeat(64)), reserve_address: Address::from_str(BASE_RESERVE).unwrap(), token_address: Address::from_str(BASE_TOKEN).unwrap(), + additional_erc20_tokens: Vec::new(), 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),