From 0a84c26564b31fa4c28c8c2b4f7b3cde55b09f3d Mon Sep 17 00:00:00 2001 From: Ameya Deshmukh Date: Mon, 28 Sep 2026 14:44:50 +0530 Subject: [PATCH 1/3] feat(faucet): add validated token funding strategies (SEI-662) --- .env.machine-funding.example | 7 + machine-funding-server/src/assets.rs | 298 ++++++++++++++++++ machine-funding-server/src/base_chain.rs | 1 + machine-funding-server/src/config.rs | 87 ++++- machine-funding-server/src/lib.rs | 1 + .../tests/redis_semantics.rs | 1 + 6 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 machine-funding-server/src/assets.rs diff --git a/.env.machine-funding.example b/.env.machine-funding.example index 385b040..5d4a16c 100644 --- a/.env.machine-funding.example +++ b/.env.machine-funding.example @@ -21,3 +21,10 @@ 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, parsed only when INTERNAL_FUNDING_BASE_ENABLED=true. +# Catalog registration does not enable additional transfer endpoints or mint execution. +# Amounts are decimal base-unit strings; mint arguments below are whole tokens. +# Permissionless mint strategies require contract review, not selector detection. +# INTERNAL_FUNDING_BASE_ADDITIONAL_ERC20_TOKENS='[{"contract_address":"0x10b5Be494C2962A7B318aFB63f0Ee30b959D000b","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}}]' +# For manually supplied inventory, use 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..cb403dd --- /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 rusd() -> FundingToken { + FundingToken { + contract_address: "0x10b5Be494C2962A7B318aFB63f0Ee30b959D000b" + .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 rusd_uses_whole_tokens_and_bounded_chunks() { + let token = rusd(); + 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 = rusd(); + 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 = rusd(); + 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 = rusd(); + 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 = rusd(); + token.decimals = 19; + assert_eq!(token.validate(), Err(AssetError::InvalidIdentity)); + token = rusd(); + token.global_budget = U256::from(1); + assert_eq!(token.validate(), Err(AssetError::InvalidLimits)); + token = rusd(); + token.funding = FundingStrategy::CallerMintWholeTokens { + max_tokens_per_call: 100, + max_calls: 33, + }; + assert_eq!(token.validate(), Err(AssetError::InvalidMintLimits)); + let json = r#"{"contract_address":"0x10b5be494c2962a7b318afb63f0ee30b959d000b","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..e76c25c 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_ADDITIONAL_ERC20_TOKENS"; +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, @@ -426,12 +444,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(|value| { + serde_json::from_str(&value) + .map_err(|_| ConfigError::Invalid(ADDITIONAL_BASE_TOKENS_VAR)) + }) + .transpose()? + .unwrap_or_default(); + if additional_erc20_tokens + .iter() + .any(|token| token.contract_address == reserve_address) + { + return Err(ConfigError::Invalid(ADDITIONAL_BASE_TOKENS_VAR)); + } + 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 +485,11 @@ fn parse_base_config( DEFAULT_BASE_CONFIRMATIONS, "INTERNAL_FUNDING_BASE_CONFIRMATIONS", )?, - })) + }; + config + .token_catalog() + .map_err(|_| ConfigError::Invalid(ADDITIONAL_BASE_TOKENS_VAR))?; + Ok(Some(config)) } fn validate_private_key(value: &str, name: &'static str) -> Result<(), ConfigError> { @@ -595,6 +631,53 @@ 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":"0x10b5Be494C2962A7B318aFB63f0Ee30b959D000b","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}}]"#; + values.insert(ADDITIONAL_BASE_TOKENS_VAR, json.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) + ); + values.insert( + ADDITIONAL_BASE_TOKENS_VAR, + json.replace( + "0x10b5Be494C2962A7B318aFB63f0Ee30b959D000b", + &original.token_address.to_checksum(None), + ), + ); + assert!(matches!( + Config::from_lookup(|key| values.get(key).cloned()) + .unwrap() + .base, + BaseActivation::Invalid(_) + )); + values.insert(ADDITIONAL_BASE_TOKENS_VAR, "not json".into()); + values.insert("INTERNAL_FUNDING_BASE_ENABLED", "false".into()); + assert_eq!( + Config::from_lookup(|key| values.get(key).cloned()) + .unwrap() + .base, + BaseActivation::Disabled + ); + } + #[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), From cc8f00336ec6bd891400447c3c579ed2c25618d1 Mon Sep 17 00:00:00 2001 From: Ameya Deshmukh Date: Mon, 28 Sep 2026 14:49:02 +0530 Subject: [PATCH 2/3] test: use generic token fixtures and examples --- .env.machine-funding.example | 2 +- machine-funding-server/src/assets.rs | 22 +++++++++++----------- machine-funding-server/src/config.rs | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.env.machine-funding.example b/.env.machine-funding.example index 5d4a16c..2dcef54 100644 --- a/.env.machine-funding.example +++ b/.env.machine-funding.example @@ -26,5 +26,5 @@ INTERNAL_FUNDING_ERC20_USDC_RATE_WINDOW_SECONDS=3600 # Catalog registration does not enable additional transfer endpoints or mint execution. # Amounts are decimal base-unit strings; mint arguments below are whole tokens. # Permissionless mint strategies require contract review, not selector detection. -# INTERNAL_FUNDING_BASE_ADDITIONAL_ERC20_TOKENS='[{"contract_address":"0x10b5Be494C2962A7B318aFB63f0Ee30b959D000b","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}}]' +# INTERNAL_FUNDING_BASE_ADDITIONAL_ERC20_TOKENS='[{"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}}]' # For manually supplied inventory, use funding: {"kind":"manual_inventory"}. diff --git a/machine-funding-server/src/assets.rs b/machine-funding-server/src/assets.rs index cb403dd..1ffba1e 100644 --- a/machine-funding-server/src/assets.rs +++ b/machine-funding-server/src/assets.rs @@ -178,9 +178,9 @@ fn decimal_amount<'de, D: Deserializer<'de>>(deserializer: D) -> Result FundingToken { + fn mintable_token() -> FundingToken { FundingToken { - contract_address: "0x10b5Be494C2962A7B318aFB63f0Ee30b959D000b" + contract_address: "0x1111111111111111111111111111111111111111" .parse() .unwrap(), decimals: 6, @@ -195,8 +195,8 @@ mod tests { } #[test] - fn rusd_uses_whole_tokens_and_bounded_chunks() { - let token = rusd(); + 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(); @@ -218,7 +218,7 @@ mod tests { #[test] fn manual_inventory_can_pay_but_cannot_replenish() { - let mut token = rusd(); + let mut token = mintable_token(); token.funding = FundingStrategy::ManualInventory; assert!(token .replenishment(U256::from(10), U256::from(10)) @@ -233,7 +233,7 @@ mod tests { #[test] fn replenishment_accounts_for_existing_balance_and_floor() { - let mut token = rusd(); + 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)) @@ -252,7 +252,7 @@ mod tests { #[test] fn catalog_defaults_are_stable_and_unknown_contracts_do_not_fallback() { - let default = rusd(); + let default = mintable_token(); let mut other = default.clone(); other.contract_address = Address::with_last_byte(1); other.funding = FundingStrategy::ManualInventory; @@ -268,19 +268,19 @@ mod tests { #[test] fn unsafe_limits_and_ambiguous_amounts_are_rejected() { - let mut token = rusd(); + let mut token = mintable_token(); token.decimals = 19; assert_eq!(token.validate(), Err(AssetError::InvalidIdentity)); - token = rusd(); + token = mintable_token(); token.global_budget = U256::from(1); assert_eq!(token.validate(), Err(AssetError::InvalidLimits)); - token = rusd(); + 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":"0x10b5be494c2962a7b318afb63f0ee30b959d000b","decimals":6,"max_transfer":"100","global_budget":"200","reserve_floor":"0","funding":{"kind":"manual_inventory"}}"#; + 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() diff --git a/machine-funding-server/src/config.rs b/machine-funding-server/src/config.rs index e76c25c..23b9968 100644 --- a/machine-funding-server/src/config.rs +++ b/machine-funding-server/src/config.rs @@ -637,7 +637,7 @@ mod tests { 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":"0x10b5Be494C2962A7B318aFB63f0Ee30b959D000b","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 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}}]"#; values.insert(ADDITIONAL_BASE_TOKENS_VAR, json.into()); let config = Config::from_lookup(|key| values.get(key).cloned()).unwrap(); let base = config.base.enabled().unwrap(); @@ -658,7 +658,7 @@ mod tests { values.insert( ADDITIONAL_BASE_TOKENS_VAR, json.replace( - "0x10b5Be494C2962A7B318aFB63f0Ee30b959D000b", + "0x1111111111111111111111111111111111111111", &original.token_address.to_checksum(None), ), ); From 10bff3e44677a20ba9f2d2d2d2d48ce23deee4d7 Mon Sep 17 00:00:00 2001 From: Ameya Deshmukh Date: Mon, 28 Sep 2026 18:06:52 +0530 Subject: [PATCH 3/3] feat: load token catalog from a JSON file --- .env.machine-funding.example | 10 +++-- .gitignore | 3 ++ funding-tokens.example.json | 24 ++++++++++ machine-funding-server/src/config.rs | 65 +++++++++++++++++++++++----- 4 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 funding-tokens.example.json diff --git a/.env.machine-funding.example b/.env.machine-funding.example index 2dcef54..3da3d2a 100644 --- a/.env.machine-funding.example +++ b/.env.machine-funding.example @@ -22,9 +22,11 @@ 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, parsed only when INTERNAL_FUNDING_BASE_ENABLED=true. +# 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 below are whole tokens. +# Amounts are decimal base-unit strings; mint arguments are whole tokens. # Permissionless mint strategies require contract review, not selector detection. -# INTERNAL_FUNDING_BASE_ADDITIONAL_ERC20_TOKENS='[{"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}}]' -# For manually supplied inventory, use funding: {"kind":"manual_inventory"}. +# 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/config.rs b/machine-funding-server/src/config.rs index 23b9968..8e2cf8c 100644 --- a/machine-funding-server/src/config.rs +++ b/machine-funding-server/src/config.rs @@ -16,7 +16,7 @@ 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_ADDITIONAL_ERC20_TOKENS"; +const ADDITIONAL_BASE_TOKENS_VAR: &str = "INTERNAL_FUNDING_BASE_TOKEN_CATALOG_FILE"; const BASE_TOKEN_DECIMALS: u8 = 6; #[derive(Clone)] @@ -151,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}")] @@ -445,9 +453,9 @@ fn parse_base_config( "INTERNAL_FUNDING_BASE_ERC20_USDC_RESERVE_FLOOR", )?; let additional_erc20_tokens: Vec = lookup(ADDITIONAL_BASE_TOKENS_VAR) - .map(|value| { - serde_json::from_str(&value) - .map_err(|_| ConfigError::Invalid(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(); @@ -455,7 +463,7 @@ fn parse_base_config( .iter() .any(|token| token.contract_address == reserve_address) { - return Err(ConfigError::Invalid(ADDITIONAL_BASE_TOKENS_VAR)); + return Err(ConfigError::CatalogReserveAddress); } let config = BaseConfig { rpc_url, @@ -488,7 +496,7 @@ fn parse_base_config( }; config .token_catalog() - .map_err(|_| ConfigError::Invalid(ADDITIONAL_BASE_TOKENS_VAR))?; + .map_err(ConfigError::CatalogValidation)?; Ok(Some(config)) } @@ -638,7 +646,10 @@ mod tests { 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}}]"#; - values.insert(ADDITIONAL_BASE_TOKENS_VAR, json.into()); + 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()); @@ -655,20 +666,30 @@ mod tests { base.additional_erc20_tokens[0].max_transfer, U256::from(250_000_000) ); - values.insert( - ADDITIONAL_BASE_TOKENS_VAR, + 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(_) )); - values.insert(ADDITIONAL_BASE_TOKENS_VAR, "not json".into()); + 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()) @@ -678,6 +699,28 @@ mod tests { ); } + #[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();