251 lines
9.1 KiB
Rust
251 lines
9.1 KiB
Rust
|
|
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);
|
||
|
|
let _ = std::fmt::write(
|
||
|
|
&mut result,
|
||
|
|
format_args!("{input:<width$}"),
|
||
|
|
);
|
||
|
|
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}");
|
||
|
|
}
|