use blockchain::blocks::loans::UnsignedLoanContractTransaction; use blockchain::common::asset_names::{ padded_loan_collateral_input_or_base, padded_normalized_asset_input, }; use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible, prompt_wallet_path}; use blockchain::common::network_paths_and_settings::block_extension_and_paths; use blockchain::common::types::LENDER_FEE; use blockchain::json; use blockchain::standalone_tools::vanity_resolver::resolve_wallet_address_input; use blockchain::wallets::structures::Wallet; use blockchain::File; use blockchain::{create_dir_all, AsyncWriteExt}; use blockchain::{NaiveDate, NaiveTime, TimeZone, Utc}; fn normalize_asset_or_base(input: &str) -> Option { let base_coin = block_extension_and_paths().1; if input.trim().eq_ignore_ascii_case(base_coin.trim()) { Some(base_coin) } else { padded_normalized_asset_input(input.trim()) } } fn display_fee(value: u64) -> f64 { // Fees are stored as atomic units and displayed as whole-coin decimals for the CLI. value as f64 / 100_000_000.0 } fn parse_start_date(input: &str) -> Result { // Loan schedules use UTC dates so both parties and every node derive the same due dates. let date = NaiveDate::parse_from_str(input.trim(), "%Y-%m-%d") .map_err(|_| "Please enter the date in YYYY-MM-DD format.".to_string())?; let datetime = date.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()); let timestamp = Utc.from_utc_datetime(&datetime).timestamp(); if timestamp < 0 || timestamp > u32::MAX as i64 { return Err("Loan start date is out of range.".to_string()); } Ok(timestamp as u32) } async fn load_signing_wallet() -> Option { let wallet_path = prompt_wallet_path().await; let decryption_key = prompt_hidden_nonempty( "What is your wallet decryption key? ", "Wallet key cannot be empty. Please try again.", ) .await; match Wallet::try_obtain_wallet(decryption_key, Some(&wallet_path)).await { Ok(wallet) => Some(wallet), Err(err) => { eprintln!("Wallet decryption failed: {err}"); None } } } #[tokio::main] async fn main() { // Loan contracts use transaction type 7 and are first signed by the lender. let txtype = 7; // Coin/token and collateral names are padded to fixed-width transaction fields. let loan_coin_name = prompt_visible("What coin or token will you be lending out? ").await; let Some(loan_coin) = normalize_asset_or_base(&loan_coin_name) else { println!("Loan asset must be the base coin or normalize to 3-15 alphanumeric characters."); return; }; let loan_amount_string = prompt_visible("How many coins or tokens will you be lending? ").await; let loan_amount_f64: f64 = loan_amount_string .trim() .parse() .expect("Please enter a valid amount"); let loan_amount = (loan_amount_f64 * 100_000_000.0).round() as u64; // Payment cadence is stored as a compact one-letter code. let payment_period = prompt_visible("Would you like each payment period to be daily, weekly, or monthly? ") .await; let payment_period = match payment_period.trim().to_lowercase().as_str() { "daily" | "d" => "d".to_string(), "weekly" | "w" => "w".to_string(), "monthly" | "m" => "m".to_string(), _ => { println!("Payment period must be daily, weekly, or monthly."); return; } }; let payment_number_string = prompt_visible("How many payments do you expect until the loan is considered paid? ").await; let payment_number: u8 = payment_number_string .trim() .parse() .expect("Please enter a valid number"); let payment_amount_string = prompt_visible("What should be the amount of each payment? ").await; let payment_amount_f64: f64 = payment_amount_string .trim() .parse() .expect("Please enter a valid amount"); let payment_amount = (payment_amount_f64 * 100_000_000.0).round() as u64; let grace_period_string = prompt_visible("How many payments can be missed before you will claim the collateral? ") .await; let grace_period: u8 = grace_period_string .trim() .parse() .expect("Please enter a valid number"); let max_late_value_string = prompt_visible( "How far behind in value can the borrower be before you will claim the collateral? ", ) .await; let max_late_value_f64: f64 = max_late_value_string .trim() .parse() .expect("Please enter a valid amount"); let max_late_value = (max_late_value_f64 * 100_000_000.0).round() as u64; // Collateral can be a coin, token, or NFT name; NFT collateral uses amount 1. let collateral_name = prompt_visible("What collateral coin, token, or NFT will you be accepting for this loan? ") .await; let base_coin = block_extension_and_paths().1; let Some(collateral) = padded_loan_collateral_input_or_base(collateral_name.trim(), &base_coin) else { println!( "Collateral must be the base coin, a 3-15 character asset, or a numbered NFT ending in _xxxxx." ); return; }; let collateral_amount_string = prompt_visible("How many collateral coins or tokens will you be accepting for this loan (enter 1 for NFT collateral)? ").await; let collateral_amount_f64: f64 = collateral_amount_string .trim() .parse() .expect("Please enter a valid amount"); let collateral_amount = (collateral_amount_f64 * 100_000_000.0).round() as u64; let start_date = prompt_visible("What is the date this loan should begin? (YYYY-MM-DD): ").await; let timestamp = match parse_start_date(&start_date) { Ok(timestamp) => timestamp, Err(err) => { println!("{err}"); return; } }; // Load the lender wallet before resolving vanity inputs; remote vanity lookup needs a signed handshake. let wallet = match load_signing_wallet().await { Some(wallet) => wallet, None => return, }; // Resolve the borrower input before writing it into the loan offer. let borrower = prompt_visible("What is the wallet address of the borrower? ").await; let borrower = match resolve_wallet_address_input(borrower.trim(), &wallet).await { Ok(address) => address, Err(err) => { println!("borrower wallet invalid: {err}"); return; } }; let txfee_prompt = format!( "Enter the txfee you will be paying for this transaction (minimum: {:.8})", display_fee(LENDER_FEE) ); let txfee_string = prompt_visible(&format!("{txfee_prompt}: ")).await; let txfee_f64: f64 = txfee_string .trim() .parse() .expect("Please enter a valid fee"); let txfee = (txfee_f64 * 100_000_000.0).round() as u64; let private_key = &wallet.saved.private_key; let lender = &wallet.saved.short_address; // The lender must sign with a valid current-network short address. if !Wallet::short_address_validation(lender.trim_matches('"')) { println!("lender wallet invalid: {lender}"); return; } // Build the unsigned loan so both lender and borrower sign the exact same hash. let unsigned_loan = UnsignedLoanContractTransaction::new( txtype, timestamp, &loan_coin, loan_amount, lender.trim_matches('"'), &collateral, collateral_amount, &borrower, &payment_period, payment_number, payment_amount, grace_period, max_late_value, txfee, ) .await; // This first signature is the lender's offer; the borrower adds signature2 later. let (hash, signature1) = match unsigned_loan.hash_and_sign(private_key).await { Ok(value) => value, Err(err) => { eprintln!("Failed to sign loan contract: {err}"); return; } }; // Save the partially signed loan JSON for the borrower to review and sign. let output = json!({ "txtype": txtype, "timestamp": timestamp, "loan_coin": loan_coin, "loan_amount": loan_amount, "lender": lender.trim_matches('"'), "collateral": collateral, "collateral_amount": collateral_amount, "borrower": borrower, "payment_period": payment_period, "payment_number": payment_number, "payment_amount": payment_amount, "grace_period": grace_period, "max_late_value": max_late_value, "txfee": txfee, "hash": hash, "signature1": signature1, }); let output_str = serde_json::to_string_pretty(&output).expect("Failed to serialize JSON"); // Transaction creation tools all write into ./transactions using the transaction hash. let dir_path = "./transactions"; if let Err(e) = create_dir_all(&dir_path).await { eprintln!("Failed to create directory: {e}"); return; } let file_path = format!( "{}/{}.json", dir_path, output["hash"].as_str().unwrap_or_default() ); let mut file = File::create(&file_path) .await .expect("Failed to create file"); if let Err(e) = file.write_all(output_str.as_bytes()).await { eprintln!("Failed to write file: {e}"); return; } println!("{output_str}"); }