use std::fmt;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use miden_client::Deserializable;
use miden_client::account::{AccountFile, AccountId};
use miden_client::asset::FungibleAsset;
use miden_client::block::BlockNumber;
use miden_client::keystore::Keystore;
use miden_client::note::{Note, NoteType, P2idNote};
use miden_client::testing::common::{TestClient, wait_for_node, wait_for_tx};
use miden_client::testing::fee::FeeFunder;
use miden_client::transaction::TransactionRequestBuilder;
use rand::RngExt;
use rustix::fs::{FlockOperation, flock};
use rustix::io::Errno;
use tokio::sync::Mutex;
use crate::config::ClientConfig;
pub const FUNDER_ACCOUNTS_ENV: &str = "MIDEN_FUNDER_ACCOUNTS_DIR";
const FUNDING_AMOUNT: u64 = 10_000_000;
pub fn funders_path_from_env() -> Option<PathBuf> {
std::env::var_os(FUNDER_ACCOUNTS_ENV)
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
}
pub fn load(
client_config: &ClientConfig,
funders: Option<&Path>,
) -> Result<Option<Arc<dyn FeeFunder>>> {
let Some(path) = funders else {
return Ok(None);
};
let wallets = load_funders(path)?;
Ok(Some(Arc::new(Funder::new(client_config, wallets))))
}
fn load_funders(path: &Path) -> Result<Vec<AccountFile>> {
let paths = if path.is_dir() {
let mut mac_files: Vec<PathBuf> = std::fs::read_dir(path)
.with_context(|| format!("failed to read funder directory {}", path.display()))?
.map(|entry| Ok(entry?.path()))
.collect::<Result<Vec<_>>>()?
.into_iter()
.filter(|path| path.extension().is_some_and(|ext| ext == "mac"))
.collect();
mac_files.sort();
mac_files
} else {
vec![path.to_path_buf()]
};
if paths.is_empty() {
bail!("no `.mac` funder account files in {}", path.display());
}
paths
.iter()
.map(|path| {
let bytes = std::fs::read(path)
.with_context(|| format!("failed to read funder {}", path.display()))?;
let funder = AccountFile::read_from_bytes(&bytes).map_err(|err| {
anyhow::anyhow!("failed to deserialize {}: {err}", path.display())
})?;
let id = funder.account.id();
if !id.is_public() {
bail!("funder {id} in {} must be public to be shared", path.display());
}
if funder.auth_secret_keys.is_empty() {
bail!("funder {id} in {} carries no secret key to sign with", path.display());
}
Ok(funder)
})
.collect()
}
struct Funder {
client_config: ClientConfig,
wallets: Vec<AccountFile>,
scan_from: usize,
client: Mutex<Option<TestClient>>,
}
impl Funder {
fn new(client_config: &ClientConfig, wallets: Vec<AccountFile>) -> Self {
Self {
client_config: client_config
.clone()
.with_fee_funder(None)
.with_note_transport_endpoint(None),
wallets,
scan_from: rand::rng().random::<u32>() as usize,
client: Mutex::new(None),
}
}
fn claim(&self) -> Result<AccountLock> {
for offset in 0..self.wallets.len() {
let wallet = &self.wallets[(self.scan_from + offset) % self.wallets.len()];
if let Some(lock) = AccountLock::try_acquire(wallet.account.id())? {
return Ok(lock);
}
}
let wallet = &self.wallets[self.scan_from % self.wallets.len()];
AccountLock::acquire(wallet.account.id())
}
async fn build_client(&self) -> Result<TestClient> {
let (mut client, keystore) = self
.client_config
.clone()
.into_unsynced_client()
.await
.context("failed to build the funder client")?;
wait_for_node(&mut client).await;
client.sync_state().await.context("failed to sync the funder client")?;
for wallet in &self.wallets {
let id = wallet.account.id();
for key in &wallet.auth_secret_keys {
keystore.add_key(key, id).await.context("failed to add a funder key")?;
}
}
Ok(client)
}
async fn pay(
&self,
client: &mut TestClient,
wallet_id: AccountId,
targets: &[AccountId],
) -> Result<Vec<(AccountId, Note)>> {
if client.account_reader(wallet_id).nonce().await.is_err() {
client
.import_account_by_id(wallet_id)
.await
.with_context(|| format!("failed to import funder {wallet_id}"))?;
}
let (genesis, _) = client
.get_block_header_by_num(BlockNumber::GENESIS)
.await?
.context("genesis block header is not in the funder client's store")?;
let fee_faucet_id = genesis.fee_parameters().fee_faucet_id();
let asset = FungibleAsset::new(fee_faucet_id, FUNDING_AMOUNT)
.context("failed to build the native fee asset")?;
let funded = targets
.iter()
.map(|target| {
let note: Note = P2idNote::builder()
.sender(wallet_id)
.target(*target)
.asset(asset)
.note_type(NoteType::Private)
.generate_serial_number(client.rng())
.build()
.context("failed to build a funding note")?
.into();
Ok((*target, note))
})
.collect::<Result<Vec<(AccountId, Note)>>>()?;
let request = TransactionRequestBuilder::new()
.own_output_notes(funded.iter().map(|(_, note)| note.clone()))
.build()
.context("failed to build the funding transaction request")?;
let tx_id =
Box::pin(client.submit_new_transaction(wallet_id, request)).await.with_context(
|| format!("funder {wallet_id} failed to pay {} accounts", targets.len()),
)?;
wait_for_tx(client, tx_id)
.await
.with_context(|| format!("the payment from funder {wallet_id} never committed"))?;
Ok(funded)
}
}
#[async_trait::async_trait(?Send)]
impl FeeFunder for Funder {
async fn fund(&self, account_ids: &[AccountId]) -> Result<Vec<(AccountId, Note)>> {
if account_ids.is_empty() {
return Ok(Vec::new());
}
let _lock = self.claim()?;
let mut guard = self.client.lock().await;
if guard.is_none() {
*guard = Some(self.build_client().await?);
}
let funder_client = guard.as_mut().expect("the funder client was just built");
self.pay(funder_client, _lock.account_id(), account_ids).await
}
}
impl fmt::Debug for Funder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Funder")
.field("rpc_endpoint", &self.client_config.rpc_endpoint)
.field("wallets", &self.wallets.iter().map(|w| w.account.id()).collect::<Vec<_>>())
.finish_non_exhaustive()
}
}
pub struct AccountLock {
file: File,
account_id: AccountId,
}
impl AccountLock {
pub fn acquire(account_id: AccountId) -> Result<Self> {
let file = Self::open(account_id)?;
flock(&file, FlockOperation::LockExclusive)
.with_context(|| format!("failed to lock account {account_id}"))?;
Ok(Self { file, account_id })
}
pub fn try_acquire(account_id: AccountId) -> Result<Option<Self>> {
let file = Self::open(account_id)?;
match flock(&file, FlockOperation::NonBlockingLockExclusive) {
Ok(()) => Ok(Some(Self { file, account_id })),
Err(Errno::WOULDBLOCK) => Ok(None),
Err(err) => {
Err(anyhow::Error::new(err).context(format!("failed to lock account {account_id}")))
},
}
}
pub fn account_id(&self) -> AccountId {
self.account_id
}
fn open(account_id: AccountId) -> Result<File> {
let path = std::env::temp_dir().join(format!("miden-account-{}.lock", account_id.to_hex()));
File::options()
.create(true)
.truncate(false)
.write(true)
.open(&path)
.with_context(|| format!("failed to open account lock file {}", path.display()))
}
}
impl Drop for AccountLock {
fn drop(&mut self) {
let _ = flock(&self.file, FlockOperation::Unlock);
}
}