use std::boxed::Box;
use std::collections::BTreeMap;
use std::env::temp_dir;
use std::fs::OpenOptions;
use std::io::Write;
use std::ops::{Deref, DerefMut};
use std::path::PathBuf;
use std::string::{String, ToString};
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::vec::Vec;
use anyhow::{Context, Result};
use miden_protocol::account::auth::AuthSecretKey;
use miden_protocol::account::{Account, AccountId};
use miden_protocol::asset::{AssetAmount, FungibleAsset, TokenSymbol};
use miden_protocol::note::NoteType;
use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE;
use miden_protocol::transaction::TransactionId;
use miden_standards::account::auth::{Approver, AuthSingleSig};
use miden_standards::account::faucets::TokenName;
use rand::Rng;
use tracing::{debug, info};
use uuid::Uuid;
use crate::account::component::{
BasicWallet,
BurnPolicy,
FungibleFaucet,
MintPolicy,
TokenPolicyManager,
};
use crate::account::{AccountBuilder, AccountBuilderSchemaCommitmentExt, AccountType};
use crate::auth::{AuthSchemeId, ECDSA_K256_KECCAK_SCHEME_ID};
pub use crate::keystore::{FilesystemKeyStore, Keystore};
use crate::note::{Note, NoteConsumability, P2idNote};
use crate::rpc::RpcError;
use crate::store::{InputNoteRecord, NoteFilter, TransactionFilter};
use crate::sync::SyncSummary;
use crate::test_utils::fee::FeeFunder;
use crate::transaction::{
NoteArgs,
TransactionRequest,
TransactionRequestBuilder,
TransactionRequestError,
TransactionResult,
TransactionStatus,
};
use crate::{Client, ClientError};
pub struct TestClient {
client: Client<FilesystemKeyStore>,
fee_funder: Option<Arc<dyn FeeFunder>>,
pending_funding: BTreeMap<AccountId, Note>,
}
impl TestClient {
pub fn new(client: Client<FilesystemKeyStore>) -> Self {
Self {
client,
fee_funder: None,
pending_funding: BTreeMap::new(),
}
}
pub fn keystore(&self) -> &FilesystemKeyStore {
self.client
.authenticator()
.expect("test clients are always built with a keystore authenticator")
.as_ref()
}
pub(crate) fn stash_funding(&mut self, funded: impl IntoIterator<Item = (AccountId, Note)>) {
self.pending_funding.extend(funded);
}
pub fn take_funding(&mut self, account_id: AccountId) -> Option<Note> {
self.pending_funding.remove(&account_id)
}
pub async fn submit_new_transaction(
&mut self,
account_id: AccountId,
transaction_request: TransactionRequest,
) -> Result<TransactionId, ClientError> {
self.sync_state().await?;
let transaction_request = self.fund_request(account_id, transaction_request);
Box::pin(self.client.submit_new_transaction(account_id, transaction_request)).await
}
pub async fn execute_transaction(
&mut self,
account_id: AccountId,
transaction_request: TransactionRequest,
) -> Result<TransactionResult, ClientError> {
self.sync_state().await?;
let transaction_request = self.fund_request(account_id, transaction_request);
Box::pin(self.client.execute_transaction(account_id, transaction_request)).await
}
#[must_use]
pub fn fund_request(
&mut self,
account_id: AccountId,
mut transaction_request: TransactionRequest,
) -> TransactionRequest {
if let Some(note) = self.take_funding(account_id) {
transaction_request.add_unauthenticated_input_note(note);
}
transaction_request
}
#[must_use]
pub fn with_fee_funder(mut self, fee_funder: Option<Arc<dyn FeeFunder>>) -> Self {
self.fee_funder = fee_funder;
self
}
pub fn fee_funder(&self) -> Option<&Arc<dyn FeeFunder>> {
self.fee_funder.as_ref()
}
}
impl From<Client<FilesystemKeyStore>> for TestClient {
fn from(client: Client<FilesystemKeyStore>) -> Self {
Self::new(client)
}
}
impl Deref for TestClient {
type Target = Client<FilesystemKeyStore>;
fn deref(&self) -> &Self::Target {
&self.client
}
}
impl DerefMut for TestClient {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.client
}
}
enum StandardComponents {
Wallet,
Faucet,
}
enum AccountKind {
Standard {
components: StandardComponents,
account_type: AccountType,
auth_scheme: AuthSchemeId,
},
Prebuilt {
account: Box<Account>,
key: AuthSecretKey,
},
}
pub struct AccountSetup {
kind: AccountKind,
funded: bool,
invitation_code: Option<String>,
}
impl AccountSetup {
fn standard(components: StandardComponents, account_type: AccountType) -> Self {
Self {
kind: AccountKind::Standard {
components,
account_type,
auth_scheme: ECDSA_K256_KECCAK_SCHEME_ID,
},
funded: true,
invitation_code: None,
}
}
pub fn wallet(account_type: AccountType) -> Self {
Self::standard(StandardComponents::Wallet, account_type)
}
pub fn faucet(account_type: AccountType) -> Self {
Self::standard(StandardComponents::Faucet, account_type)
}
pub fn prebuilt(account: Account, key: AuthSecretKey) -> Self {
Self {
kind: AccountKind::Prebuilt { account: Box::new(account), key },
funded: true,
invitation_code: None,
}
}
#[must_use]
pub fn auth_scheme(mut self, auth_scheme: AuthSchemeId) -> Self {
if let AccountKind::Standard { auth_scheme: scheme, .. } = &mut self.kind {
*scheme = auth_scheme;
}
self
}
#[must_use]
pub fn unfunded(mut self) -> Self {
self.funded = false;
self
}
#[must_use]
pub fn invitation_code(mut self, invitation_code: &str) -> Self {
self.invitation_code = Some(invitation_code.to_string());
self
}
}
pub fn auth_component(auth_scheme: AuthSchemeId) -> Result<(AuthSingleSig, AuthSecretKey)> {
let key_pair = match auth_scheme {
AuthSchemeId::Falcon512Poseidon2 => AuthSecretKey::new_falcon512_poseidon2(),
AuthSchemeId::EcdsaK256Keccak => AuthSecretKey::new_ecdsa_k256_keccak(),
other => anyhow::bail!("unsupported auth scheme: {}", other.as_u8()),
};
let component =
AuthSingleSig::new(Approver::new(key_pair.public_key().to_commitment(), auth_scheme));
Ok((component, key_pair))
}
pub fn fungible_faucet_component() -> Result<(FungibleFaucet, TokenPolicyManager)> {
let symbol = TokenSymbol::new("TEST").expect("TEST is a valid token symbol");
let name = TokenName::new(&symbol.to_string()).expect("token symbol is a valid token name");
let max_supply = 9_999_999_u64;
let faucet = FungibleFaucet::builder()
.name(name)
.symbol(symbol)
.decimals(10)
.max_supply(AssetAmount::new(max_supply).expect("max supply is a valid amount"))
.build()
.context("failed to build the fungible faucet component")?;
let policy_manager = TokenPolicyManager::builder()
.active_mint_policy(MintPolicy::allow_all())
.active_burn_policy(BurnPolicy::allow_all())
.build();
Ok((faucet, policy_manager))
}
impl TestClient {
pub async fn insert_account(
&mut self,
setup: AccountSetup,
) -> Result<(Account, AuthSecretKey)> {
let (account, key_pair) = match setup.kind {
AccountKind::Prebuilt { account, key } => (*account, key),
AccountKind::Standard { components, account_type, auth_scheme } => {
let (auth, key_pair) = auth_component(auth_scheme)?;
let mut init_seed = [0u8; 32];
self.rng().fill_bytes(&mut init_seed);
let mut builder =
AccountBuilder::new(init_seed).account_type(account_type).with_component(auth);
match components {
StandardComponents::Wallet => {
builder = builder.with_component(BasicWallet);
},
StandardComponents::Faucet => {
let (faucet, policy_manager) = fungible_faucet_component()?;
builder = builder
.with_component(faucet)
.with_component(BasicWallet)
.with_components(policy_manager);
},
}
let account = builder
.build_with_schema_commitment()
.context("failed to build the test account")?;
(account, key_pair)
},
};
self.keystore()
.add_key(&key_pair, account.id())
.await
.context("failed to add the account key to the keystore")?;
self.add_account(&account, false).await?;
if let Some(invitation_code) = setup.invitation_code.as_deref() {
self.register_account(account.id(), invitation_code).await?;
}
info!(
account_id = %account.id(),
account_type = ?account.id().account_type(),
"Inserted account"
);
if setup.funded {
self.fund_if_needed(&[account.id()]).await?;
}
Ok((account, key_pair))
}
pub async fn insert_wallet(&mut self, account_type: AccountType) -> Result<Account> {
let (account, _) = self.insert_account(AccountSetup::wallet(account_type)).await?;
Ok(account)
}
pub async fn insert_faucet(&mut self, account_type: AccountType) -> Result<Account> {
let (account, _) = self.insert_account(AccountSetup::faucet(account_type)).await?;
Ok(account)
}
pub async fn setup_wallet_and_faucet(
&mut self,
account_type: AccountType,
) -> Result<(Account, Account)> {
let (faucet_account, _) = self
.insert_account(AccountSetup::faucet(account_type).unfunded())
.await
.context("failed to insert new fungible faucet account")?;
let (basic_account, _) = self
.insert_account(AccountSetup::wallet(account_type).unfunded())
.await
.context("failed to insert new wallet account")?;
self.fund_if_needed(&[faucet_account.id(), basic_account.id()])
.await
.context("failed to fund and deploy the created accounts")?;
Ok((basic_account, faucet_account))
}
pub async fn setup_two_wallets_and_faucet(
&mut self,
account_type: AccountType,
) -> Result<(Account, Account, Account)> {
let account_headers = self
.get_account_headers()
.await
.with_context(|| "failed to get account headers")?;
anyhow::ensure!(
account_headers.is_empty(),
"Expected empty account headers for clean state"
);
let transactions = self
.get_transactions(TransactionFilter::All)
.await
.with_context(|| "failed to get transactions")?;
anyhow::ensure!(transactions.is_empty(), "Expected empty transactions for clean state");
let input_notes = self
.get_input_notes(NoteFilter::All)
.await
.with_context(|| "failed to get input notes")?;
anyhow::ensure!(input_notes.is_empty(), "Expected empty input notes for clean state");
let (faucet_account, _) = self
.insert_account(AccountSetup::faucet(account_type).unfunded())
.await
.context("failed to insert new fungible faucet account")?;
let (first_basic_account, _) = self
.insert_account(AccountSetup::wallet(account_type).unfunded())
.await
.context("failed to insert first basic wallet account")?;
let (second_basic_account, _) = self
.insert_account(AccountSetup::wallet(account_type).unfunded())
.await
.context("failed to insert second basic wallet account")?;
self.fund_if_needed(&[
faucet_account.id(),
first_basic_account.id(),
second_basic_account.id(),
])
.await
.context("failed to fund and deploy the created accounts")?;
info!(
faucet_id = %faucet_account.id(),
wallet_1_id = %first_basic_account.id(),
wallet_2_id = %second_basic_account.id(),
"Setup complete, syncing state"
);
self.sync_state().await.with_context(|| "failed to sync client state")?;
Ok((first_basic_account, second_basic_account, faucet_account))
}
}
impl TestClient {
pub async fn execute_failing_tx(
&mut self,
account_id: AccountId,
tx_request: TransactionRequest,
expected_error: ClientError,
) {
info!(account_id = %account_id, "Executing transaction (expecting failure)");
assert_eq!(
self.submit_new_transaction(account_id, tx_request)
.await
.unwrap_err()
.to_string(),
expected_error.to_string()
);
}
pub async fn execute_tx_and_sync(
&mut self,
account_id: AccountId,
tx_request: TransactionRequest,
) -> Result<()> {
let transaction_id = self.submit_new_transaction(account_id, tx_request).await?;
info!(tx_id = %transaction_id, account_id = %account_id, "Transaction submitted, waiting for commit");
self.wait_for_tx(transaction_id).await?;
Ok(())
}
pub async fn wait_for_tx(&mut self, transaction_id: TransactionId) -> Result<()> {
let now = Instant::now();
debug!(tx_id = %transaction_id, "Waiting for transaction to be committed");
loop {
self.sync_state()
.await
.with_context(|| "failed to sync client state while waiting for transaction")?;
let tracked_transaction = self
.get_transactions(TransactionFilter::Ids(vec![transaction_id]))
.await
.with_context(|| format!("failed to get transaction with ID: {transaction_id}"))?
.pop()
.with_context(|| format!("transaction with ID {transaction_id} not found"))?;
match tracked_transaction.status {
TransactionStatus::Committed { block_number, .. } => {
info!(tx_id = %transaction_id, %block_number, "Transaction committed");
break;
},
TransactionStatus::Pending => {
tokio::time::sleep(Duration::from_millis(500)).await;
},
TransactionStatus::Discarded(cause) => {
anyhow::bail!("transaction was discarded with cause: {cause:?}");
},
}
if std::env::var("LOG_WAIT_TIMES") == Ok("true".to_string()) {
let elapsed = now.elapsed();
let wait_times_dir = std::path::PathBuf::from("wait_times");
std::fs::create_dir_all(&wait_times_dir)
.with_context(|| "failed to create wait_times directory")?;
let elapsed_time_file =
wait_times_dir.join(format!("wait_time_{}", Uuid::new_v4()));
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(elapsed_time_file)
.with_context(|| "failed to create elapsed time file")?;
writeln!(file, "{:?}", elapsed.as_millis())
.with_context(|| "failed to write elapsed time to file")?;
}
}
Ok(())
}
pub async fn wait_for_blocks(&mut self, amount_of_blocks: u32) -> Result<SyncSummary> {
let current_block = self.get_sync_height().await?;
let final_block = current_block + amount_of_blocks;
debug!(current_block = %current_block, target_block = %final_block, "Waiting for blocks");
loop {
let summary = self.sync_state().await?;
debug!(sync_height = %summary.block_num, target_block = %final_block, "Synced");
if summary.block_num >= final_block {
return Ok(summary);
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
pub async fn wait_for_blocks_no_sync(&mut self, amount_of_blocks: u32) -> Result<()> {
let current_block = self.get_sync_height().await?;
let final_block = current_block + amount_of_blocks;
debug!(current_block = %current_block, target_block = %final_block, "Waiting for blocks (no sync)");
loop {
let (latest_block, _) =
self.test_rpc_api().get_block_header_by_number(None, false).await?;
debug!(
chain_tip = %latest_block.block_num(),
target_block = %final_block,
"Waiting for blocks (no sync)"
);
if latest_block.block_num() >= final_block {
return Ok(());
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
pub async fn wait_for_consumable_notes(
&mut self,
account_id: AccountId,
max_blocks: u32,
) -> Result<Vec<(InputNoteRecord, Vec<NoteConsumability>)>> {
let start_block = self.get_sync_height().await?;
let deadline_block = start_block + max_blocks;
debug!(
%account_id,
%start_block,
%deadline_block,
"Waiting for consumable notes"
);
loop {
self.sync_state().await?;
let notes = self.get_consumable_notes(Some(account_id)).await?;
if !notes.is_empty() {
let current_block = self.get_sync_height().await?;
debug!(
%account_id,
count = notes.len(),
%current_block,
"Found consumable notes"
);
return Ok(notes);
}
let current_block = self.get_sync_height().await?;
assert!(
current_block < deadline_block,
"account {account_id} has no consumable notes after waiting {max_blocks} blocks \
(from block {start_block} to {current_block})"
);
debug!(
%account_id,
%current_block,
%deadline_block,
"No consumable notes yet, waiting..."
);
std::thread::sleep(Duration::from_secs(3));
}
}
pub async fn wait_for_node(&mut self) {
const NODE_TIME_BETWEEN_ATTEMPTS: u64 = 2;
const NUMBER_OF_NODE_ATTEMPTS: u64 = 60;
info!(
"Waiting for node to be up (checking every {NODE_TIME_BETWEEN_ATTEMPTS}s, max {NUMBER_OF_NODE_ATTEMPTS} tries)"
);
for _try_number in 0..NUMBER_OF_NODE_ATTEMPTS {
match self.sync_state().await {
Err(ClientError::RpcError(
RpcError::ConnectionError(_) | RpcError::RequestError { .. },
)) => {
tokio::time::sleep(Duration::from_secs(NODE_TIME_BETWEEN_ATTEMPTS)).await;
},
Err(other_error) => {
panic!("Unexpected error: {other_error}");
},
_ => return,
}
}
panic!("Unable to connect to node");
}
pub async fn mint_note(
&mut self,
basic_account_id: AccountId,
faucet_account_id: AccountId,
note_type: NoteType,
) -> Result<(TransactionId, Note)> {
let fungible_asset = FungibleAsset::new(faucet_account_id, MINT_AMOUNT)?;
info!(faucet_id = %faucet_account_id, target_id = %basic_account_id, amount = MINT_AMOUNT, "Minting asset");
let tx_request = TransactionRequestBuilder::new().build_mint_fungible_asset(
fungible_asset,
basic_account_id,
note_type,
self.rng(),
)?;
let tx_id = self
.submit_new_transaction(fungible_asset.faucet_id(), tx_request.clone())
.await?;
let note = tx_request
.expected_output_own_notes()
.pop()
.context("the mint request should produce one output note")?;
info!(tx_id = %tx_id, note_id = %note.id(), "Mint transaction submitted");
Ok((tx_id, note))
}
pub async fn consume_notes(
&mut self,
account_id: AccountId,
input_notes: &[Note],
) -> Result<TransactionId> {
let note_ids: Vec<_> = input_notes.iter().map(|n| n.id().to_string()).collect();
info!(account_id = %account_id, note_ids = %note_ids.join(", "), "Consuming notes");
let tx_request =
TransactionRequestBuilder::new().build_consume_notes(input_notes.to_vec())?;
let tx_id = self.submit_new_transaction(account_id, tx_request).await?;
info!(tx_id = %tx_id, "Consume transaction submitted");
Ok(tx_id)
}
pub async fn execute_tx_and_consume_output_notes(
&mut self,
tx_request: TransactionRequest,
executor: AccountId,
consumer: AccountId,
) -> Result<TransactionId> {
let output_notes = tx_request
.expected_output_own_notes()
.into_iter()
.map(|note| (note, None::<NoteArgs>))
.collect::<Vec<(Note, Option<NoteArgs>)>>();
self.submit_new_transaction(executor, tx_request).await?;
let tx_request = TransactionRequestBuilder::new().input_notes(output_notes).build()?;
Ok(self.submit_new_transaction(consumer, tx_request).await?)
}
pub async fn mint_and_consume(
&mut self,
basic_account_id: AccountId,
faucet_account_id: AccountId,
note_type: NoteType,
) -> Result<TransactionId> {
info!(
faucet_id = %faucet_account_id,
target_id = %basic_account_id,
amount = MINT_AMOUNT,
"Minting and consuming asset"
);
let tx_request = TransactionRequestBuilder::new().build_mint_fungible_asset(
FungibleAsset::new(faucet_account_id, MINT_AMOUNT)?,
basic_account_id,
note_type,
self.rng(),
)?;
let tx_id = self
.execute_tx_and_consume_output_notes(tx_request, faucet_account_id, basic_account_id)
.await?;
info!(tx_id = %tx_id, "Mint-and-consume transaction submitted");
Ok(tx_id)
}
pub fn mint_multiple_fungible_asset(
&mut self,
asset: FungibleAsset,
target_id: &[AccountId],
note_type: NoteType,
) -> Result<TransactionRequest> {
let rng = self.rng();
let notes = target_id
.iter()
.map(|account_id| {
Ok(P2idNote::builder()
.sender(asset.faucet_id())
.target(*account_id)
.asset(asset)
.note_type(note_type)
.generate_serial_number(rng)
.build()
.context("note creation failed")?
.into())
})
.collect::<Result<Vec<Note>>>()?;
Ok(TransactionRequestBuilder::new().own_output_notes(notes).build()?)
}
}
impl TestClient {
pub async fn assert_account_has_single_asset(
&self,
account_id: AccountId,
faucet_id: AccountId,
expected_amount: u64,
) {
let balance = self
.account_reader(account_id)
.get_balance(faucet_id)
.await
.expect("Account should have the asset");
assert_eq!(balance, AssetAmount::new(expected_amount).unwrap());
}
pub async fn assert_note_cannot_be_consumed_twice(
&mut self,
consuming_account_id: AccountId,
note_to_consume: Note,
) {
info!(note_id = %note_to_consume.id(), account_id = %consuming_account_id, "Attempting double-consume (expecting failure)");
let tx_request = TransactionRequestBuilder::new()
.build_consume_notes(vec![note_to_consume.clone()])
.unwrap();
match self.submit_new_transaction(consuming_account_id, tx_request).await {
Err(ClientError::TransactionRequestError(
TransactionRequestError::InputNoteAlreadyConsumed(_),
)) => {},
Ok(_) => panic!("Double-spend error: Note should not be consumable!"),
err => {
panic!("Unexpected error {:?} for note ID: {}", err, note_to_consume.id().to_hex())
},
}
}
}
pub const ACCOUNT_ID_REGULAR: u128 = ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE;
pub const RECALL_HEIGHT_DELTA: u32 = 50;
pub const MINT_AMOUNT: u64 = 1000;
pub const TRANSFER_AMOUNT: u64 = 59;
pub fn create_test_store_path() -> PathBuf {
let mut temp_file = temp_dir();
temp_file.push(format!("{}.sqlite3", Uuid::new_v4()));
temp_file
}