diff --git a/crates/aptos-faucet/cli/src/main.rs b/crates/aptos-faucet/cli/src/main.rs index 981938464c2d7..ee190a25b8a4e 100644 --- a/crates/aptos-faucet/cli/src/main.rs +++ b/crates/aptos-faucet/cli/src/main.rs @@ -3,7 +3,8 @@ use anyhow::{Context, Result}; use aptos_faucet_core::funder::{ - ApiConnectionConfig, FunderTrait, MintFunder, TransactionSubmissionConfig, + ApiConnectionConfig, AssetConfig, FunderTrait, MintAssetConfig, MintFunder, + TransactionSubmissionConfig, DEFAULT_ASSET_NAME, }; use aptos_sdk::{ crypto::ed25519::Ed25519PublicKey, @@ -13,7 +14,11 @@ use aptos_sdk::{ }, }; use clap::Parser; -use std::{collections::HashSet, str::FromStr}; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + str::FromStr, +}; #[tokio::main] async fn main() -> Result<()> { @@ -45,6 +50,12 @@ pub struct FaucetCliArgs { #[clap(long)] pub mint_account_address: Option, + /// Path to the private key file for minting coins. + /// To manually generate a keypair, use generate-key: + /// `cargo run -p generate-keypair -- -o ` + #[clap(long, default_value = "/opt/aptos/etc/mint.key")] + pub key_file_path: PathBuf, + /// The maximum amount of gas in OCTA to spend on a single transaction. #[clap(long, default_value_t = 500_000)] pub max_gas_amount: u64, @@ -52,11 +63,11 @@ pub struct FaucetCliArgs { impl FaucetCliArgs { async fn run(&self) -> Result<()> { - // Get network root key based on the connection config. - let key = self - .api_connection_args - .get_key() - .context("Failed to build root key")?; + // Create an AssetConfig to get the key + let asset_config = AssetConfig::new(None, self.key_file_path.clone()); + + // Get network root key from the asset config. + let key = asset_config.get_key().context("Failed to build root key")?; // Build the account that the MintFunder will use. let faucet_account = LocalAccount::new( @@ -79,14 +90,29 @@ impl FaucetCliArgs { true, // wait_for_transactions ); + // Create asset configuration for the default asset + let base_asset_config = AssetConfig::new(None, self.key_file_path.clone()); + let mint_asset_config = MintAssetConfig::new( + base_asset_config, + self.mint_account_address, + false, // do_not_delegate is set to false - CLI uses delegation + ); + + // Build assets map with the default asset + let mut assets = HashMap::new(); + assets.insert(DEFAULT_ASSET_NAME.to_string(), mint_asset_config); + // Build the MintFunder service. - let mut mint_funder = MintFunder::new( + let mint_funder = MintFunder::new( self.api_connection_args.node_url.clone(), self.api_connection_args.api_key.clone(), self.api_connection_args.additional_headers.clone(), self.api_connection_args.chain_id, transaction_submission_config, faucet_account, + assets, + DEFAULT_ASSET_NAME.to_string(), + self.amount, ); // Create an account that we'll delegate mint functionality to, then use it. @@ -105,7 +131,7 @@ impl FaucetCliArgs { // Mint coins to each of the accounts. for account in accounts { let response = mint_funder - .fund(Some(self.amount), account, false, false) + .fund(Some(self.amount), account, None, false, false) .await; match response { Ok(response) => println!( diff --git a/crates/aptos-faucet/configs/testing_mint_funder_multi_assets.yaml b/crates/aptos-faucet/configs/testing_mint_funder_multi_assets.yaml new file mode 100644 index 0000000000000..7b55e676184b0 --- /dev/null +++ b/crates/aptos-faucet/configs/testing_mint_funder_multi_assets.yaml @@ -0,0 +1,19 @@ +server_config: + api_path_base: "" +metrics_server_config: + listen_port: 9105 +bypasser_configs: [] +checker_configs: [] +funder_config: + type: "MintFunder" + node_url: "http://127.0.0.1:8080" + chain_id: 4 + amount_to_fund: 100000000 + assets: + apt: + do_not_delegate: false + key_file_path: ".aptos/testnet/mint.key" + mint_account_address: "0xA550C18" +handler_config: + use_helpful_errors: true + return_rejections_early: false \ No newline at end of file diff --git a/crates/aptos-faucet/core/src/endpoints/fund.rs b/crates/aptos-faucet/core/src/endpoints/fund.rs index 1fc5470e47809..3abcd4e65ea5e 100644 --- a/crates/aptos-faucet/core/src/endpoints/fund.rs +++ b/crates/aptos-faucet/core/src/endpoints/fund.rs @@ -102,6 +102,7 @@ impl FundApi { async fn fund( &self, fund_request: Json, + asset: poem_openapi::param::Query>, // This automagically uses FromRequest to get this data from the request. // It takes into things like X-Forwarded-IP and X-Real-IP. source_ip: RealIp, @@ -110,7 +111,7 @@ impl FundApi { ) -> poem::Result, AptosTapErrorResponse> { let txns = self .components - .fund_inner(fund_request.0, source_ip, header_map, false) + .fund_inner(fund_request.0, source_ip, header_map, false, asset.0) .await?; Ok(Json(FundResponse { txn_hashes: get_hashes(&txns), @@ -132,6 +133,7 @@ impl FundApi { async fn is_eligible( &self, fund_request: Json, + asset: poem_openapi::param::Query>, // This automagically uses FromRequest to get this data from the request. // It takes into things like X-Forwarded-IP and X-Real-IP. source_ip: RealIp, @@ -149,10 +151,16 @@ impl FundApi { // Call Funder.fund with `check_only` set, meaning it only does the // initial set of checks without actually submitting any transactions - // to fund the account. + // to fund the account. Pass asset directly, funder will use its configured default if None. self.components .funder - .fund(fund_request.amount, checker_data.receiver, true, bypass) + .fund( + fund_request.amount, + checker_data.receiver, + asset.0, + true, + bypass, + ) .await?; Ok(()) @@ -281,15 +289,23 @@ impl FundApiComponents { // Same thing, this uses FromRequest. header_map: &HeaderMap, dry_run: bool, + asset: Option, ) -> poem::Result, AptosTapError> { let (checker_data, bypass, _semaphore_permit) = self .preprocess_request(&fund_request, source_ip, header_map, dry_run) .await?; - // Fund the account. + // Fund the account - pass asset directly, funder will use its configured default if None + let asset_for_logging = asset.clone(); let fund_result = self .funder - .fund(fund_request.amount, checker_data.receiver, false, bypass) + .fund( + fund_request.amount, + checker_data.receiver, + asset, + false, + bypass, + ) .await; // This might be empty if there is an error and we never got to the @@ -305,6 +321,7 @@ impl FundApiComponents { jwt_sub = jwt_sub(checker_data.headers.clone()).ok(), address = checker_data.receiver, requested_amount = fund_request.amount, + asset = asset_for_logging.as_deref().unwrap_or("default"), txn_hashes = txn_hashes, success = fund_result.is_ok(), ); @@ -387,7 +404,7 @@ pub async fn mint( }; let txns = fund_api_components .0 - .fund_inner(fund_request, source_ip, header_map, false) + .fund_inner(fund_request, source_ip, header_map, false, None) .await .map_err(|e| { poem::Error::from((e.status_and_retry_after().0, anyhow::anyhow!(e.message))) diff --git a/crates/aptos-faucet/core/src/funder/common.rs b/crates/aptos-faucet/core/src/funder/common.rs index abd6c0e73e31a..b3dab8a9c2d49 100644 --- a/crates/aptos-faucet/core/src/funder/common.rs +++ b/crates/aptos-faucet/core/src/funder/common.rs @@ -41,6 +41,9 @@ const MAX_NUM_OUTSTANDING_TRANSACTIONS: u64 = 15; const DEFAULT_KEY_FILE_PATH: &str = "/opt/aptos/etc/mint.key"; +/// Default asset name used when no asset is specified in requests. +pub const DEFAULT_ASSET_NAME: &str = "apt"; + /// This defines configuration for any Funder that needs to interact with a real /// blockchain API. This includes the MintFunder and the TransferFunder currently. /// @@ -62,20 +65,6 @@ pub struct ApiConnectionConfig { #[clap(skip)] pub additional_headers: Option>, - /// Path to the private key for creating test account and minting coins in - /// the MintFunder case, or for transferring coins in the TransferFunder case. - /// To keep Testnet simple, we used one private key for aptos root account - /// To manually generate a keypair, use generate-key: - /// `cargo run -p generate-keypair -- -o ` - #[serde(default = "ApiConnectionConfig::default_mint_key_file_path")] - #[clap(long, default_value = DEFAULT_KEY_FILE_PATH, value_parser)] - key_file_path: PathBuf, - - /// Hex string of an Ed25519PrivateKey for minting / transferring coins. - #[serde(skip_serializing_if = "Option::is_none")] - #[clap(long, value_parser = ConfigKey::::from_encoded_string)] - key: Option>, - /// Chain ID of the network this client is connecting to. For example, for mainnet: /// "MAINNET" or 1, testnet: "TESTNET" or 2. If there is no predefined string /// alias (e.g. "MAINNET"), just use the number. Note: Chain ID of 0 is not allowed. @@ -88,49 +77,15 @@ impl ApiConnectionConfig { node_url: Url, api_key: Option, additional_headers: Option>, - key_file_path: PathBuf, - key: Option>, chain_id: ChainId, ) -> Self { Self { node_url, api_key, additional_headers, - key_file_path, - key, chain_id, } } - - fn default_mint_key_file_path() -> PathBuf { - PathBuf::from_str(DEFAULT_KEY_FILE_PATH).unwrap() - } - - pub fn get_key(&self) -> Result { - if let Some(ref key) = self.key { - return Ok(key.private_key()); - } - let key_bytes = std::fs::read(self.key_file_path.as_path()).with_context(|| { - format!( - "Failed to read key file: {}", - self.key_file_path.to_string_lossy() - ) - })?; - // decode as bcs first, fall back to a file of hex - let result = aptos_sdk::bcs::from_bytes(&key_bytes); //.with_context(|| "bad bcs"); - if let Ok(x) = result { - return Ok(x); - } - let keystr = String::from_utf8(key_bytes).map_err(|e| anyhow!(e))?; - Ok(ConfigKey::from_encoded_string(keystr.as_str()) - .with_context(|| { - format!( - "{}: key file failed as both bcs and hex", - self.key_file_path.to_string_lossy() - ) - })? - .private_key()) - } } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -467,3 +422,56 @@ impl GasUnitPriceManager { .gas_estimate) } } + +#[derive(Clone, Debug, Deserialize, Serialize, Parser)] +pub struct AssetConfig { + /// Path to the private key for creating test account and minting coins in + /// the MintFunder case, or for transferring coins in the TransferFunder case. + /// To keep Testnet simple, we used one private key for aptos root account + /// To manually generate a keypair, use generate-key: + /// `cargo run -p generate-keypair -- -o ` + #[serde(default = "AssetConfig::default_key_file_path")] + #[clap(long, default_value = DEFAULT_KEY_FILE_PATH, value_parser)] + pub key_file_path: PathBuf, + + /// Hex string of an Ed25519PrivateKey for minting / transferring coins. + #[serde(skip_serializing_if = "Option::is_none")] + #[clap(long, value_parser = ConfigKey::::from_encoded_string)] + pub key: Option>, +} + +impl AssetConfig { + pub fn new(key: Option>, key_file_path: PathBuf) -> Self { + Self { key, key_file_path } + } + + fn default_key_file_path() -> PathBuf { + PathBuf::from_str(DEFAULT_KEY_FILE_PATH).unwrap() + } + + pub fn get_key(&self) -> Result { + if let Some(ref key) = self.key { + return Ok(key.private_key()); + } + let key_bytes = std::fs::read(self.key_file_path.as_path()).with_context(|| { + format!( + "Failed to read key file: {}", + self.key_file_path.to_string_lossy() + ) + })?; + // decode as bcs first, fall back to a file of hex + let result = aptos_sdk::bcs::from_bytes(&key_bytes); //.with_context(|| "bad bcs"); + if let Ok(x) = result { + return Ok(x); + } + let keystr = String::from_utf8(key_bytes).map_err(|e| anyhow!(e))?; + Ok(ConfigKey::from_encoded_string(keystr.as_str()) + .with_context(|| { + format!( + "{}: key file failed as both bcs and hex", + self.key_file_path.to_string_lossy() + ) + })? + .private_key()) + } +} diff --git a/crates/aptos-faucet/core/src/funder/fake.rs b/crates/aptos-faucet/core/src/funder/fake.rs index 57e6e4a780480..8db57e1731997 100644 --- a/crates/aptos-faucet/core/src/funder/fake.rs +++ b/crates/aptos-faucet/core/src/funder/fake.rs @@ -18,6 +18,7 @@ impl FunderTrait for FakeFunder { &self, _amount: Option, _receiver_address: AccountAddress, + _asset: Option, _check_only: bool, _did_bypass_checkers: bool, ) -> Result, AptosTapError> { diff --git a/crates/aptos-faucet/core/src/funder/mint.rs b/crates/aptos-faucet/core/src/funder/mint.rs index 3321d2819f73f..926d0b12bad6e 100644 --- a/crates/aptos-faucet/core/src/funder/mint.rs +++ b/crates/aptos-faucet/core/src/funder/mint.rs @@ -7,7 +7,7 @@ use crate::endpoints::{AptosTapError, AptosTapErrorCode}; use anyhow::{Context, Result}; use aptos_logger::info; use aptos_sdk::{ - crypto::ed25519::Ed25519PublicKey, + crypto::ed25519::{Ed25519PrivateKey, Ed25519PublicKey}, rest_client::{AptosBaseUrl, Client}, transaction_builder::{aptos_stdlib, TransactionFactory}, types::{ @@ -23,27 +23,34 @@ use async_trait::async_trait; use reqwest::Url; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::sync::Arc; use tokio::sync::RwLock; +use rand::rngs::OsRng; static MINTER_SCRIPT: &[u8] = include_bytes!( "../../../../../aptos-move/move-examples/scripts/minter/build/Minter/bytecode_scripts/main.mv" ); use super::common::{ - submit_transaction, update_sequence_numbers, ApiConnectionConfig, GasUnitPriceManager, - TransactionSubmissionConfig, + submit_transaction, update_sequence_numbers, ApiConnectionConfig, AssetConfig, + GasUnitPriceManager, TransactionSubmissionConfig, DEFAULT_ASSET_NAME, }; -/// explain these contain additional args for the mint funder. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct MintFunderConfig { - #[serde(flatten)] - pub api_connection_config: ApiConnectionConfig, +/// Helper function to clone an Ed25519PrivateKey by serializing and deserializing it. +/// This is necessary because Ed25519PrivateKey doesn't implement Clone. +fn clone_private_key(key: &Ed25519PrivateKey) -> Ed25519PrivateKey { + let serialized: &[u8] = &(key.to_bytes()); + Ed25519PrivateKey::try_from(serialized) + .expect("Failed to deserialize private key - this should never happen") +} +/// Asset configuration specific to minting, extends the base AssetConfig with mint-specific fields. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct MintAssetConfig { #[serde(flatten)] - pub transaction_submission_config: TransactionSubmissionConfig, + pub base: AssetConfig, - /// Address of the account to send transactions from. On testnet, for + /// Address of the account to send transactions from. On localnet, for /// example, this is a550c18. If not given, we use the account address /// corresponding to the given private key. pub mint_account_address: Option, @@ -53,32 +60,99 @@ pub struct MintFunderConfig { pub do_not_delegate: bool, } +impl MintAssetConfig { + pub fn new( + base: AssetConfig, + mint_account_address: Option, + do_not_delegate: bool, + ) -> Self { + Self { + base, + mint_account_address, + do_not_delegate, + } + } + + /// Delegate to the base AssetConfig's get_key method. + pub fn get_key(&self) -> Result { + self.base.get_key() + } +} + +/// explain these contain additional args for the mint funder. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct MintFunderConfig { + #[serde(flatten)] + pub api_connection_config: ApiConnectionConfig, + + #[serde(flatten)] + pub transaction_submission_config: TransactionSubmissionConfig, + + pub assets: HashMap, + + /// Default asset to use when no asset is specified in requests. + /// If not provided, defaults to "apt". + #[serde(default)] + pub default_asset: Option, + + pub amount_to_fund: u64, +} + impl MintFunderConfig { pub async fn build_funder(self) -> Result { - let key = self.api_connection_config.get_key()?; + // Validate we have at least one asset + if self.assets.is_empty() { + return Err(anyhow::anyhow!("No assets configured")); + } + + // Resolve default asset: use configured value or fall back to constant + let default_asset = self + .default_asset + .unwrap_or_else(|| DEFAULT_ASSET_NAME.to_string()); - let faucet_account = LocalAccount::new( - self.mint_account_address.unwrap_or_else(|| { - AuthenticationKey::ed25519(&Ed25519PublicKey::from(&key)).account_address() - }), + // Validate that the default asset exists in the assets map + let default_asset_config = self.assets.get(&default_asset).ok_or_else(|| { + anyhow::anyhow!( + "Default asset '{}' is not configured in assets map", + default_asset + ) + })?; + + let key = default_asset_config.get_key()?; + let initial_account = LocalAccount::new( + default_asset_config + .mint_account_address + .unwrap_or_else(|| { + AuthenticationKey::ed25519(&Ed25519PublicKey::from(&key)).account_address() + }), key, 0, ); - let mut minter = MintFunder::new( + let minter = MintFunder::new( self.api_connection_config.node_url.clone(), self.api_connection_config.api_key.clone(), self.api_connection_config.additional_headers.clone(), self.api_connection_config.chain_id, self.transaction_submission_config, - faucet_account, + initial_account, + self.assets.clone(), + default_asset.clone(), + self.amount_to_fund, ); - if !self.do_not_delegate { - minter - .use_delegated_account() + // If default asset needs delegation, do it once at startup + if !default_asset_config.do_not_delegate { + let delegated_account = minter + .use_delegated_account_for_asset(&default_asset) .await .context("Failed to make MintFunder use delegated account")?; + // Set it as the current faucet account + // Note: delegated_account is already a LocalAccount (not Arc) from use_delegated_account_for_asset + { + let mut faucet_account = minter.faucet_account.write().await; + *faucet_account = delegated_account; + } } Ok(minter) @@ -102,6 +176,15 @@ pub struct MintFunder { /// When recovering from being overloaded, this struct ensures we handle /// requests in the order they came in. outstanding_requests: RwLock>, + + // Multi-asset support: store asset configs + assets: HashMap, + default_asset: String, + amount_to_fund: u64, + + // Cache of delegated accounts per asset to avoid recreating them + // Using Arc to allow sharing the non-cloneable LocalAccount + delegated_accounts: RwLock>>, } impl MintFunder { @@ -112,6 +195,9 @@ impl MintFunder { chain_id: ChainId, txn_config: TransactionSubmissionConfig, faucet_account: LocalAccount, + assets: HashMap, + default_asset: String, + amount_to_fund: u64, ) -> Self { let gas_unit_price_manager = GasUnitPriceManager::new(node_url.clone(), txn_config.get_gas_unit_price_ttl_secs()); @@ -127,6 +213,10 @@ impl MintFunder { transaction_factory, gas_unit_price_manager, outstanding_requests: RwLock::new(vec![]), + assets, + default_asset, + amount_to_fund, + delegated_accounts: RwLock::new(HashMap::new()), } } @@ -150,13 +240,66 @@ impl MintFunder { .with_gas_unit_price(self.get_gas_unit_price().await?)) } - /// todo explain / rename - pub async fn use_delegated_account(&mut self) -> Result<()> { + /// Legacy method for backward compatibility - delegates for the current asset + pub async fn use_delegated_account(&self) -> Result<()> { + // This is only used at startup for the default asset + // We'll handle it differently in build_funder + let delegated_account = self.use_delegated_account_for_asset(&self.default_asset).await?; + let mut faucet_account = self.faucet_account.write().await; + *faucet_account = delegated_account; + Ok(()) + } + + /// Performs the delegated account creation and delegation for a specific asset. + /// Returns the delegated account and caches it. + /// + /// This method uses a double-check locking pattern to prevent race conditions: + /// 1. First check the cache with a read lock (fast path for already-delegated assets) + /// 2. If not found, acquire a write lock and check again (prevents duplicate creation) + /// 3. If still not found, create and delegate a new account, then cache it + /// + /// The write lock is held during account creation to ensure only one thread creates + /// a delegated account per asset, even if multiple requests arrive concurrently. + pub async fn use_delegated_account_for_asset(&self, asset_name: &str) -> Result { + // Fast path: Check cache with read lock first + // This allows concurrent reads for already-delegated assets + { + let delegated_accounts = self.delegated_accounts.read().await; + if let Some(cached_account) = delegated_accounts.get(asset_name) { + // Reconstruct LocalAccount from Arc (since LocalAccount doesn't implement Clone) + // Clone the private key by serializing/deserializing + let private_key = clone_private_key(cached_account.private_key()); + return Ok(LocalAccount::new( + cached_account.address(), + private_key, + cached_account.sequence_number(), + )); + } + } + + // Slow path: Acquire write lock before creating account + // This ensures only one thread creates the delegated account for this asset + let mut delegated_accounts = self.delegated_accounts.write().await; + + // Double-check: Another thread may have created it while we waited for the write lock + if let Some(cached_account) = delegated_accounts.get(asset_name) { + // Reconstruct LocalAccount from Arc + // Clone the private key by serializing/deserializing + let private_key = clone_private_key(cached_account.private_key()); + return Ok(LocalAccount::new( + cached_account.address(), + private_key, + cached_account.sequence_number(), + )); + } + + // We're the first to create a delegated account for this asset + // The write lock is held during all async operations to prevent duplicates // Build a client. let client = self.get_api_client(); // Create a new random account, then delegate to it - let delegated_account = LocalAccount::generate(&mut rand::rngs::OsRng); + let delegated_account = LocalAccount::generate(&mut OsRng::default()); // Create the account, wait for the response. self.process( @@ -177,20 +320,31 @@ impl MintFunder { // transaction with a gas unit price that will be accepted. let transaction_factory = self.get_transaction_factory().await?; - // Delegate minting to the account - { - let faucet_account = self.faucet_account.write().await; - client - .submit_and_wait(&faucet_account.sign_with_transaction_builder( - transaction_factory.payload(aptos_stdlib::aptos_coin_delegate_mint_capability( - delegated_account.address(), - )), - )) - .await - .context("Failed to delegate minting to the new account")?; - } + // Get the current faucet account (which should be the mint account for this asset) + // This was set by the caller in the fund() method before calling this function + let mint_account = { + let faucet_account = self.faucet_account.read().await; + // Reconstruct from the account to avoid cloning issues + // Clone the private key by serializing/deserializing + let private_key = clone_private_key(faucet_account.private_key()); + LocalAccount::new( + faucet_account.address(), + private_key, + faucet_account.sequence_number(), + ) + }; - // Claim the capability! + // Delegate minting capability from the mint account to the new delegated account + client + .submit_and_wait(&mint_account.sign_with_transaction_builder( + transaction_factory.payload(aptos_stdlib::aptos_coin_delegate_mint_capability( + delegated_account.address(), + )), + )) + .await + .context("Failed to delegate minting to the new account")?; + + // Claim the mint capability on the delegated account client .submit_and_wait(&delegated_account.sign_with_transaction_builder( transaction_factory.payload(aptos_stdlib::aptos_coin_claim_mint_capability()), @@ -199,13 +353,23 @@ impl MintFunder { .context("Failed to claim the minting capability")?; info!( - "Successfully configured MintFunder to use delegated account: {}", + "Successfully configured MintFunder to use delegated account for asset '{}': {}", + asset_name, delegated_account.address() ); - self.faucet_account = RwLock::new(delegated_account); - - Ok(()) + // Cache the delegated account so future requests for this asset can reuse it + let account_arc = Arc::new(delegated_account); + delegated_accounts.insert(asset_name.to_string(), Arc::clone(&account_arc)); + + // Reconstruct and return LocalAccount (not Arc) + // Clone the private key by serializing/deserializing + let private_key = clone_private_key(account_arc.private_key()); + Ok(LocalAccount::new( + account_arc.address(), + private_key, + account_arc.sequence_number(), + )) } /// Within a single request we should just call this once and use this client @@ -227,6 +391,7 @@ impl MintFunder { builder.build() } + /// Core processing logic that handles sequence numbers and transaction submission. pub async fn process( &self, client: &Client, @@ -290,9 +455,63 @@ impl FunderTrait for MintFunder { &self, amount: Option, receiver_address: AccountAddress, + asset: Option, check_only: bool, did_bypass_checkers: bool, ) -> Result, AptosTapError> { + // Resolve asset (use configured default if not specified) + let asset_name = asset.as_deref().unwrap_or(&self.default_asset); + + // Get asset config + let asset_config = self.assets.get(asset_name).ok_or_else(|| { + AptosTapError::new( + format!("Asset '{}' is not configured", asset_name), + AptosTapErrorCode::InvalidRequest, + ) + })?; + + // Switch to the mint account for this asset + // Each asset has its own mint account (with its own private key) + // We need to switch faucet_account to the correct mint account before processing + let key = asset_config.get_key().map_err(|e| { + AptosTapError::new_with_error_code(e, AptosTapErrorCode::InvalidRequest) + })?; + let account_address = asset_config.mint_account_address.unwrap_or_else(|| { + AuthenticationKey::ed25519(&Ed25519PublicKey::from(&key)).account_address() + }); + let mint_account = LocalAccount::new(account_address, key, 0); + + // Set the mint account as the current faucet account + // This is a per-request operation - faucet_account is shared state that gets + // switched for each request based on the requested asset + { + let mut faucet_account = self.faucet_account.write().await; + *faucet_account = mint_account; + } + + // Get or create the delegated account for this asset (if delegation is needed) + // The delegated account is the one that actually performs the minting. + // It's created once per asset and cached for reuse. + // + // Note: use_delegated_account_for_asset reads from faucet_account, so we must + // set the mint account first (which we did above). + if !asset_config.do_not_delegate { + let delegated_account = self.use_delegated_account_for_asset(asset_name).await + .map_err(|e| { + AptosTapError::new_with_error_code(e, AptosTapErrorCode::InternalError) + })?; + // Switch to the delegated account - this is the account that will sign the mint transaction + // Note: delegated_account is already a LocalAccount (not Arc) from use_delegated_account_for_asset + { + let mut faucet_account = self.faucet_account.write().await; + *faucet_account = delegated_account; + } + } + + // Process the minting request using the correct account + // At this point, faucet_account points to either: + // - The delegated account (if do_not_delegate is false) + // - The mint account directly (if do_not_delegate is true) let client = self.get_api_client(); let amount = self.get_amount(amount, did_bypass_checkers); self.process( @@ -312,8 +531,8 @@ impl FunderTrait for MintFunder { ) { (Some(amount), Some(maximum_amount)) => std::cmp::min(amount, maximum_amount), (Some(amount), None) => amount, - (None, Some(maximum_amount)) => maximum_amount, - (None, None) => 0, + (None, Some(maximum_amount)) => std::cmp::min(self.amount_to_fund, maximum_amount), + (None, None) => self.amount_to_fund, } } diff --git a/crates/aptos-faucet/core/src/funder/mod.rs b/crates/aptos-faucet/core/src/funder/mod.rs index 936b723759978..ca0dacac9e9dc 100644 --- a/crates/aptos-faucet/core/src/funder/mod.rs +++ b/crates/aptos-faucet/core/src/funder/mod.rs @@ -7,8 +7,8 @@ mod mint; mod transfer; pub use self::{ - common::{ApiConnectionConfig, TransactionSubmissionConfig}, - mint::MintFunderConfig, + common::{ApiConnectionConfig, AssetConfig, TransactionSubmissionConfig, DEFAULT_ASSET_NAME}, + mint::{MintAssetConfig, MintFunderConfig}, }; use self::{fake::FakeFunderConfig, transfer::TransferFunderConfig}; use crate::endpoints::AptosTapError; @@ -37,6 +37,7 @@ pub trait FunderTrait: Sync + Send + 'static { &self, amount: Option, receiver_address: AccountAddress, + asset: Option, check_only: bool, // True if a Bypasser let this request bypass the Checkers. did_bypass_checkers: bool, diff --git a/crates/aptos-faucet/core/src/funder/transfer.rs b/crates/aptos-faucet/core/src/funder/transfer.rs index d15f00ff09694..64171454d9eb1 100644 --- a/crates/aptos-faucet/core/src/funder/transfer.rs +++ b/crates/aptos-faucet/core/src/funder/transfer.rs @@ -3,7 +3,8 @@ use super::{ common::{ - submit_transaction, ApiConnectionConfig, GasUnitPriceManager, TransactionSubmissionConfig, + submit_transaction, ApiConnectionConfig, AssetConfig, GasUnitPriceManager, + TransactionSubmissionConfig, DEFAULT_ASSET_NAME, }, FunderHealthMessage, FunderTrait, }; @@ -46,12 +47,18 @@ pub struct TransferFunderConfig { /// The amount of coins to fund the receiver account. pub amount_to_fund: AmountToFund, + + /// The assets to transfer. + pub assets: HashMap, } impl TransferFunderConfig { pub async fn build_funder(&self) -> Result { // Read in private key. - let key = self.api_connection_config.get_key()?; + let apt_asset_config = self.assets.get(DEFAULT_ASSET_NAME).ok_or_else(|| { + anyhow::anyhow!("No '{}' asset configuration found", DEFAULT_ASSET_NAME) + })?; + let key = apt_asset_config.get_key()?; // Build account address from private key. let account_address = account_address_from_private_key(&key); @@ -253,6 +260,7 @@ impl FunderTrait for TransferFunder { &self, amount: Option, receiver_address: AccountAddress, + _asset: Option, check_only: bool, did_bypass_checkers: bool, ) -> Result, AptosTapError> { diff --git a/crates/aptos-faucet/core/src/server/run.rs b/crates/aptos-faucet/core/src/server/run.rs index cc205a78002a6..d9efa45db1c91 100644 --- a/crates/aptos-faucet/core/src/server/run.rs +++ b/crates/aptos-faucet/core/src/server/run.rs @@ -9,7 +9,10 @@ use crate::{ build_openapi_service, convert_error, mint, BasicApi, CaptchaApi, FundApi, FundApiComponents, }, - funder::{ApiConnectionConfig, FunderConfig, MintFunderConfig, TransactionSubmissionConfig}, + funder::{ + ApiConnectionConfig, AssetConfig, FunderConfig, MintAssetConfig, MintFunderConfig, + TransactionSubmissionConfig, DEFAULT_ASSET_NAME, + }, middleware::middleware_log, }; use anyhow::{anyhow, Context, Result}; @@ -18,14 +21,18 @@ use aptos_faucet_metrics_server::{run_metrics_server, MetricsServerConfig}; use aptos_logger::info; use aptos_sdk::{ crypto::ed25519::Ed25519PrivateKey, - types::{account_config::aptos_test_root_address, chain_id::ChainId}, + types::{ + account_address::AccountAddress, account_config::aptos_test_root_address, chain_id::ChainId, + }, }; use clap::Parser; use futures::{channel::oneshot::Sender as OneShotSender, lock::Mutex}; use poem::{http::Method, listener::TcpAcceptor, middleware::Cors, EndpointExt, Route, Server}; use reqwest::Url; use serde::{Deserialize, Serialize}; -use std::{fs::File, io::BufReader, path::PathBuf, pin::Pin, str::FromStr, sync::Arc}; +use std::{ + collections::HashMap, fs::File, io::BufReader, path::PathBuf, pin::Pin, str::FromStr, sync::Arc, +}; use tokio::{net::TcpListener, sync::Semaphore, task::JoinSet}; #[derive(Clone, Debug, Deserialize, Serialize)] @@ -251,7 +258,7 @@ impl RunConfig { do_not_delegate: bool, chain_id: Option, ) -> Self { - let (key_file_path, key) = match funder_key { + let (key_file_path, _key) = match funder_key { FunderKeyEnum::KeyFile(key_file_path) => (key_file_path, None), FunderKeyEnum::Key(key) => (PathBuf::from_str("/dummy").unwrap(), Some(key)), }; @@ -273,8 +280,6 @@ impl RunConfig { api_url, None, None, - key_file_path, - key, chain_id.unwrap_or_else(ChainId::test), ), transaction_submission_config: TransactionSubmissionConfig::new( @@ -287,8 +292,16 @@ impl RunConfig { 35, // wait_for_outstanding_txns_secs false, // wait_for_transactions ), - mint_account_address: Some(aptos_test_root_address()), - do_not_delegate, + assets: HashMap::from([( + DEFAULT_ASSET_NAME.to_string(), + MintAssetConfig::new( + AssetConfig::new(None, key_file_path), + Some(aptos_test_root_address()), + do_not_delegate, + ), + )]), + default_asset: None, // Will default to DEFAULT_ASSET_NAME ("apt") + amount_to_fund: 100_000_000_000, }), handler_config: HandlerConfig { use_helpful_errors: true, @@ -350,16 +363,28 @@ pub struct RunSimple { #[clap(long, default_value_t = 8081)] pub listen_port: u16, + /// Path to the private key file for the APT asset + #[clap(long, default_value = "/tmp/mint.key")] + pub key_file_path: PathBuf, + + /// Address of the mint account (optional) #[clap(long)] - do_not_delegate: bool, + pub mint_account_address: Option, + + /// Whether to skip delegation + #[clap(long)] + pub do_not_delegate: bool, } impl RunSimple { pub async fn run_simple(&self) -> Result<()> { - let key = self - .api_connection_config + // Create an AssetConfig from the CLI arguments to get the key + let asset_config = AssetConfig::new(None, self.key_file_path.clone()); + + let key = asset_config .get_key() .context("Failed to load private key")?; + let run_config = RunConfig::build_for_cli( self.api_connection_config.node_url.clone(), self.listen_address.clone(),