261 lines
9.4 KiB
Rust
261 lines
9.4 KiB
Rust
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<String> {
|
|
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<u32, String> {
|
|
// 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<Wallet> {
|
|
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}");
|
|
}
|