Contractless/src/bin/create_loan_tx.rs

248 lines
9.1 KiB
Rust
Raw Normal View History

2026-05-24 17:56:57 +00:00
use blockchain::blocks::loans::UnsignedLoanContractTransaction;
use blockchain::common::cli_prompts::{prompt_hidden_nonempty, prompt_visible};
use blockchain::common::types::LENDER_FEE;
use blockchain::json;
use blockchain::records::wallet_registry::resolve_local_input_short_address;
use blockchain::wallets::structures::Wallet;
use blockchain::File;
use blockchain::{create_dir_all, AsyncWriteExt};
use blockchain::{Local, LocalResult, NaiveDate, NaiveTime, TimeZone};
fn pad_to_width(input: &str, width: usize) -> String {
// Asset names are fixed-width fields in the loan transaction bytes.
let mut result = String::with_capacity(width);
2026-05-26 06:24:57 +00:00
let _ = std::fmt::write(&mut result, format_args!("{input:<width$}"));
2026-05-24 17:56:57 +00:00
result
}
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 normalize_short_address_input(address: &str) -> Result<String, String> {
// Accept local vanity/short input and resolve it into the real short address.
resolve_local_input_short_address(address.trim())
}
fn parse_start_date(input: &str) -> Result<u32, String> {
// Loan start dates are entered as calendar dates and stored as local midnight timestamps.
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 = match Local.from_local_datetime(&datetime) {
LocalResult::Single(dt) => dt.timestamp(),
LocalResult::Ambiguous(dt, _) => dt.timestamp(),
LocalResult::None => {
return Err("Loan start date could not be resolved in local time.".to_string());
}
};
if timestamp < 0 || timestamp > u32::MAX as i64 {
return Err("Loan start date is out of range.".to_string());
}
Ok(timestamp as u32)
}
#[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 loan_coin_name = loan_coin_name.trim().to_lowercase();
let loan_coin = pad_to_width(&loan_coin_name, 15);
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 collateral_name = collateral_name.trim().to_lowercase();
let collateral = pad_to_width(&collateral_name, 15);
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;
}
};
// 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 normalize_short_address_input(borrower.trim()) {
Ok(address) => address,
Err(_) => {
println!("borrower wallet invalid");
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;
// Load the lender wallet that creates the first loan-contract signature.
let decryption_key = prompt_hidden_nonempty(
"What is your wallet decryption key? ",
"Wallet key cannot be empty. Please try again.",
)
.await;
let wallet = match Wallet::try_obtain_wallet(decryption_key, None).await {
Ok(wallet) => wallet,
Err(err) => {
eprintln!("Wallet decryption failed: {err}");
return;
}
};
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!("transaction: {output_str}");
}