From 61bc832ff4c83f75d7c5842d15d17d636721fc24 Mon Sep 17 00:00:00 2001 From: viraladmin <00purple@gmail.com> Date: Sat, 27 Jun 2026 10:28:13 -0600 Subject: [PATCH] rpc changes for asset reporting --- src/bin/create_loan_tx.rs | 24 +- src/bin/create_transfer_tx.rs | 8 +- src/blocks/loans.rs | 29 +- src/common/asset_names.rs | 103 +++++++ src/common/loan_schedule.rs | 148 ++++++++++ src/common/mod.rs | 3 +- .../undo_transactions/restore_mempool.rs | 14 +- .../undo_transactions/undo_collateral.rs | 6 +- .../undo_transactions/undo_loan_creation.rs | 22 +- .../undo_transactions/undo_marketing.rs | 7 + src/records/memory/mempool/mod.rs | 16 +- src/records/memory/mempool/schema.rs | 3 +- src/records/memory/mempool/selection.rs | 6 +- src/records/record_chain/collateral_tx.rs | 5 +- src/records/record_chain/lender_tx.rs | 16 +- .../record_chain/marketing_campaign_index.rs | 88 ++++++ src/records/record_chain/marketing_tx.rs | 9 + src/records/record_chain/mod.rs | 1 + src/rpc/command_maps.rs | 2 + src/rpc/commands/contract.rs | 66 ++--- .../commands/marketing_campaign_history.rs | 279 ++++++++++++++++++ src/rpc/commands/mod.rs | 1 + src/rpc/commands/nft_lookup.rs | 16 +- src/rpc/commands/token_lookup.rs | 154 ++++++++++ src/rpc/commands/tx_submit.rs | 71 +++-- src/rpc/server/rpc_command_loop.rs | 49 +++ .../connections/sending_request.rs | 113 ++++++- .../async_funcs/checks/block_balance.rs | 106 ++++++- src/verifications/async_funcs/transactions.rs | 17 +- src/verifications/async_funcs/verify_block.rs | 4 +- .../async_funcs/verify_borrower.rs | 27 ++ .../async_funcs/verify_collateral.rs | 81 +++-- .../async_funcs/verify_lender.rs | 28 +- .../async_funcs/verify_marketing.rs | 2 +- src/verifications/verification_service.rs | 5 + src/verifications/verification_types.rs | 2 + src/wallets/create_keys.rs | 5 +- src/wallets/load_wallets.rs | 188 ++++++------ 38 files changed, 1436 insertions(+), 288 deletions(-) create mode 100644 src/common/loan_schedule.rs create mode 100644 src/records/record_chain/marketing_campaign_index.rs create mode 100644 src/rpc/commands/marketing_campaign_history.rs diff --git a/src/bin/create_loan_tx.rs b/src/bin/create_loan_tx.rs index bf7de5c..8347e87 100644 --- a/src/bin/create_loan_tx.rs +++ b/src/bin/create_loan_tx.rs @@ -1,5 +1,7 @@ use blockchain::blocks::loans::UnsignedLoanContractTransaction; -use blockchain::common::asset_names::padded_normalized_asset_input; +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; @@ -9,7 +11,7 @@ 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::{Local, LocalResult, NaiveDate, NaiveTime, TimeZone}; +use blockchain::{NaiveDate, NaiveTime, TimeZone, Utc}; fn normalize_asset_or_base(input: &str) -> Option { let base_coin = block_extension_and_paths().1; @@ -26,17 +28,11 @@ fn display_fee(value: u64) -> f64 { } fn parse_start_date(input: &str) -> Result { - // Loan start dates are entered as calendar dates and stored as local midnight timestamps. + // 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 = 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()); - } - }; + 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()); } @@ -129,8 +125,12 @@ async fn main() { let collateral_name = prompt_visible("What collateral coin, token, or NFT will you be accepting for this loan? ") .await; - let Some(collateral) = padded_normalized_asset_input(collateral_name.trim()) else { - println!("Collateral asset must normalize to 3-15 alphanumeric characters."); + 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; }; diff --git a/src/bin/create_transfer_tx.rs b/src/bin/create_transfer_tx.rs index efb6cd4..fdeda93 100644 --- a/src/bin/create_transfer_tx.rs +++ b/src/bin/create_transfer_tx.rs @@ -46,10 +46,14 @@ async fn main() { prompt_visible("Please enter the coin or token you wish to transfer: ").await }; let Some(coin) = normalize_asset_or_base(&coin_name) else { - println!("Transfer asset must be the base coin or normalize to 3-15 alphanumeric characters."); + println!( + "Transfer asset must be the base coin or normalize to 3-15 alphanumeric characters." + ); return; }; - let is_base_coin = coin.trim().eq_ignore_ascii_case(block_extension_and_paths().1.trim()); + let is_base_coin = coin + .trim() + .eq_ignore_ascii_case(block_extension_and_paths().1.trim()); let value_string = if args.len() > 2 { args[2].clone() diff --git a/src/blocks/loans.rs b/src/blocks/loans.rs index 96ba5ec..ea8e718 100644 --- a/src/blocks/loans.rs +++ b/src/blocks/loans.rs @@ -1,6 +1,7 @@ +use crate::common::asset_names::{loan_collateral_asset_name_or_base, LOAN_COLLATERAL_LENGTH}; use crate::common::binary_conversions::binary_to_string; use crate::common::skein::skein_256_hash_data; -use crate::records::memory::mempool::db_client; +use crate::records::memory::mempool::{db_client, BASECOIN}; use crate::to_string; use crate::wallets::structures::Wallet; use crate::Cursor; @@ -8,14 +9,14 @@ use crate::Serialize; use crate::{decode, encode}; use crate::{AsyncReadExt, AsyncWriteExt}; -#[derive(Debug, Serialize, Clone)] // 122 bytes +#[derive(Debug, Serialize, Clone)] // 128 bytes pub struct UnsignedLoanContractTransaction { pub txtype: u8, // 1 byte transaction type, should be 7 pub timestamp: u32, // 4 bytes contract creation timestamp pub loan_coin: String, // 15 bytes coin or token being loaned pub loan_amount: u64, // 8 bytes amount being loaned pub lender: String, // 22 bytes lender short address - pub collateral: String, // 15 bytes token or NFT used as collateral + pub collateral: String, // 21 bytes token or numbered NFT used as collateral pub collateral_amount: u64, // 8 bytes collateral token amount or 1 for NFT collateral pub borrower: String, // 22 bytes borrower short address pub payment_period: String, // 1 byte d, w, or m for days, weeks, or months @@ -26,9 +27,9 @@ pub struct UnsignedLoanContractTransaction { pub txfee: u64, // 8 bytes transaction fee paid by the lender } -#[derive(Debug, Serialize, Clone)] // 1486 bytes +#[derive(Debug, Serialize, Clone)] // 1492 bytes pub struct LoanContractTransaction { - pub unsigned_loan_contract: UnsignedLoanContractTransaction, // 122 bytes + pub unsigned_loan_contract: UnsignedLoanContractTransaction, // 128 bytes pub hash: String, // 32 bytes The hash of the transaction pub signature1: String, // 666 bytes The signature of lender for the hash pub signature2: String, // 666 bytes The signature of borrower for the hash @@ -40,7 +41,7 @@ impl LoanContractTransaction { + 15 + 8 + Wallet::SHORT_ADDRESS_BYTES_LENGTH - + 15 + + LOAN_COLLATERAL_LENGTH + 8 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 1 @@ -161,6 +162,14 @@ impl LoanContractTransaction { pub async fn to_bytes(&self) -> tokio::io::Result> { // Serialize into the fixed loan-contract transaction byte layout. + if self.unsigned_loan_contract.loan_coin.len() != 15 + || self.unsigned_loan_contract.collateral.len() != LOAN_COLLATERAL_LENGTH + || self.unsigned_loan_contract.payment_period.len() != 1 + { + return Err(tokio::io::Error::other( + "Invalid fixed-width loan transaction field", + )); + } let mut buffer = Vec::with_capacity(Self::BYTE_LENGTH); let mut cursor = Cursor::new(&mut buffer); @@ -238,7 +247,7 @@ impl LoanContractTransaction { .ok_or_else(|| tokio::io::Error::other("Invalid lender short address bytes"))?; // Decode collateral asset and amount. - let mut collateral_bytes = vec![0; 15]; + let mut collateral_bytes = vec![0; LOAN_COLLATERAL_LENGTH]; cursor.read_exact(&mut collateral_bytes).await?; let collateral = binary_to_string(collateral_bytes); @@ -318,7 +327,9 @@ impl LoanContractTransaction { let loan_coin = &self.unsigned_loan_contract.loan_coin; let lender = &self.unsigned_loan_contract.lender; - let collateral = &self.unsigned_loan_contract.collateral; + let collateral = + loan_collateral_asset_name_or_base(&self.unsigned_loan_contract.collateral, &BASECOIN) + .ok_or_else(|| std::io::Error::other("Invalid loan collateral asset"))?; let borrower = &self.unsigned_loan_contract.borrower; let txid = &self.hash; let hash = self.unsigned_loan_contract.hash().await; @@ -357,7 +368,7 @@ impl LoanContractTransaction { loan_coin, &loan_amount, lender, - collateral, + &collateral, &collateral_amount, borrower, txid, diff --git a/src/common/asset_names.rs b/src/common/asset_names.rs index d37af08..aab8d5d 100644 --- a/src/common/asset_names.rs +++ b/src/common/asset_names.rs @@ -1,5 +1,7 @@ use crate::common::types::COIN_LENGTH; +pub const LOAN_COLLATERAL_LENGTH: usize = 21; + pub fn normalize_asset_identifier(input: &str) -> String { input .chars() @@ -36,3 +38,104 @@ pub fn is_canonical_padded_asset_or_base(input: &str, base_coin: &str) -> bool { input.trim().eq_ignore_ascii_case(base_coin.trim()) || is_canonical_padded_asset_identifier(input) } + +pub fn loan_collateral_asset_name(input: &str) -> Option { + let trimmed = input.trim(); + if let Some((base, series)) = trimmed.rsplit_once('_') { + if base.len() < 3 + || base.len() > COIN_LENGTH + || series.is_empty() + || series.len() > 5 + || !base + .chars() + .all(|character| character.is_ascii_alphanumeric()) + || !series.chars().all(|character| character.is_ascii_digit()) + || series.parse::().ok()? == 0 + { + return None; + } + + let normalized_base = normalize_asset_identifier(base); + return Some(format!("{normalized_base}_{series}")); + } + + padded_normalized_asset_identifier(trimmed) +} + +pub fn padded_loan_collateral_input(input: &str) -> Option { + let asset_name = loan_collateral_asset_name(input)?; + if asset_name.trim_end().len() > LOAN_COLLATERAL_LENGTH { + return None; + } + Some(format!( + "{: bool { + input.len() == LOAN_COLLATERAL_LENGTH + && padded_loan_collateral_input(input).is_some_and(|canonical| canonical == input) +} + +pub fn loan_collateral_asset_name_or_base(input: &str, base_coin: &str) -> Option { + if input.trim().eq_ignore_ascii_case(base_coin.trim()) { + return Some(base_coin.to_string()); + } + loan_collateral_asset_name(input) +} + +pub fn padded_loan_collateral_input_or_base(input: &str, base_coin: &str) -> Option { + if input.trim().eq_ignore_ascii_case(base_coin.trim()) { + return Some(format!("{: bool { + input.len() == LOAN_COLLATERAL_LENGTH + && padded_loan_collateral_input_or_base(input, base_coin) + .is_some_and(|canonical| canonical == input) +} + +#[cfg(test)] +mod tests { + use super::{ + loan_collateral_asset_name, loan_collateral_asset_name_or_base, + padded_loan_collateral_input, padded_loan_collateral_input_or_base, LOAN_COLLATERAL_LENGTH, + }; + + #[test] + fn normal_collateral_uses_a_21_byte_wire_field_and_15_byte_asset_key() { + let wire = padded_loan_collateral_input("testcoin").unwrap(); + assert_eq!(wire.len(), LOAN_COLLATERAL_LENGTH); + assert_eq!( + loan_collateral_asset_name(&wire).unwrap(), + "testcoin " + ); + } + + #[test] + fn numbered_nft_collateral_keeps_its_series_suffix() { + let wire = padded_loan_collateral_input("testnft_12345").unwrap(); + assert_eq!(wire.len(), LOAN_COLLATERAL_LENGTH); + assert_eq!(loan_collateral_asset_name(&wire).unwrap(), "testnft_12345"); + } + + #[test] + fn numbered_nft_suffix_is_limited_to_five_digits() { + assert!(padded_loan_collateral_input("testnft_123456").is_none()); + assert!(padded_loan_collateral_input("testnft_0").is_none()); + } + + #[test] + fn base_coin_collateral_preserves_the_network_symbol() { + let base = "CLTC "; + let wire = padded_loan_collateral_input_or_base("cltc", base).unwrap(); + assert_eq!(wire, "CLTC "); + assert_eq!( + loan_collateral_asset_name_or_base(&wire, base).unwrap(), + base + ); + } +} diff --git a/src/common/loan_schedule.rs b/src/common/loan_schedule.rs new file mode 100644 index 0000000..4e6b52c --- /dev/null +++ b/src/common/loan_schedule.rs @@ -0,0 +1,148 @@ +use chrono::{Datelike, Duration, NaiveDate, TimeZone, Utc}; + +fn last_day_of_month(year: i32, month: u32) -> Result { + let (next_year, next_month) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + let next_month_start = NaiveDate::from_ymd_opt(next_year, next_month, 1) + .ok_or_else(|| "Loan payment date is out of range.".to_string())?; + Ok((next_month_start - Duration::days(1)).day()) +} + +fn monthly_due_date(start: NaiveDate, payment_index: u32) -> Result { + let start_month = start.year() as i64 * 12 + start.month0() as i64; + let target_month = start_month + .checked_add(payment_index as i64) + .ok_or_else(|| "Loan payment month overflowed.".to_string())?; + let year = i32::try_from(target_month.div_euclid(12)) + .map_err(|_| "Loan payment year is out of range.".to_string())?; + let month = target_month.rem_euclid(12) as u32 + 1; + let day = start.day().min(last_day_of_month(year, month)?); + NaiveDate::from_ymd_opt(year, month, day) + .ok_or_else(|| "Loan payment date is out of range.".to_string()) +} + +fn due_date(start: NaiveDate, period: &str, payment_index: u32) -> Result { + match period.trim() { + "d" => start + .checked_add_signed(Duration::days(payment_index as i64)) + .ok_or_else(|| "Daily loan payment date overflowed.".to_string()), + "w" => start + .checked_add_signed(Duration::days(payment_index as i64 * 7)) + .ok_or_else(|| "Weekly loan payment date overflowed.".to_string()), + "m" => monthly_due_date(start, payment_index), + _ => Err("Loan payment period must be d, w, or m.".to_string()), + } +} + +pub fn overdue_payment_count( + start_timestamp: u32, + period: &str, + payment_number: u8, + as_of_timestamp: u32, +) -> Result { + let start = Utc + .timestamp_opt(start_timestamp as i64, 0) + .single() + .ok_or_else(|| "Invalid loan start timestamp.".to_string())? + .date_naive(); + + let mut overdue = 0usize; + for payment_index in 1..=payment_number as u32 { + let due = due_date(start, period, payment_index)?; + let overdue_at = due + .checked_add_signed(Duration::days(1)) + .and_then(|date| date.and_hms_opt(0, 0, 0)) + .ok_or_else(|| "Loan overdue timestamp overflowed.".to_string())? + .and_utc() + .timestamp(); + + if as_of_timestamp as i64 >= overdue_at { + overdue += 1; + } else { + break; + } + } + + Ok(overdue) +} + +#[cfg(test)] +mod tests { + use super::overdue_payment_count; + use chrono::{TimeZone, Utc}; + + fn timestamp(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> u32 { + Utc.with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .unwrap() + .timestamp() as u32 + } + + #[test] + fn daily_payment_is_not_overdue_until_the_following_midnight() { + let start = timestamp(2026, 3, 15, 12, 0, 0); + assert_eq!( + overdue_payment_count(start, "d", 10, timestamp(2026, 3, 16, 23, 59, 59)).unwrap(), + 0 + ); + assert_eq!( + overdue_payment_count(start, "d", 10, timestamp(2026, 3, 17, 0, 0, 0)).unwrap(), + 1 + ); + } + + #[test] + fn weekly_payment_uses_the_full_due_date() { + let start = timestamp(2026, 3, 15, 0, 0, 0); + assert_eq!( + overdue_payment_count(start, "w", 10, timestamp(2026, 3, 22, 23, 59, 59)).unwrap(), + 0 + ); + assert_eq!( + overdue_payment_count(start, "w", 10, timestamp(2026, 3, 23, 0, 0, 0)).unwrap(), + 1 + ); + } + + #[test] + fn monthly_schedule_is_anchored_to_the_original_calendar_day() { + let start = timestamp(2026, 1, 31, 0, 0, 0); + assert_eq!( + overdue_payment_count(start, "m", 10, timestamp(2026, 2, 28, 23, 59, 59)).unwrap(), + 0 + ); + assert_eq!( + overdue_payment_count(start, "m", 10, timestamp(2026, 3, 1, 0, 0, 0)).unwrap(), + 1 + ); + assert_eq!( + overdue_payment_count(start, "m", 10, timestamp(2026, 4, 1, 0, 0, 0)).unwrap(), + 2 + ); + } + + #[test] + fn monthly_schedule_handles_leap_year_february() { + let start = timestamp(2024, 1, 31, 0, 0, 0); + assert_eq!( + overdue_payment_count(start, "m", 10, timestamp(2024, 2, 29, 23, 59, 59)).unwrap(), + 0 + ); + assert_eq!( + overdue_payment_count(start, "m", 10, timestamp(2024, 3, 1, 0, 0, 0)).unwrap(), + 1 + ); + } + + #[test] + fn future_start_dates_have_no_overdue_payments() { + let start = timestamp(2026, 6, 1, 0, 0, 0); + assert_eq!( + overdue_payment_count(start, "d", 10, timestamp(2026, 5, 1, 0, 0, 0)).unwrap(), + 0 + ); + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index 65b22b6..e20973c 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,7 +1,8 @@ -pub mod binary_conversions; pub mod asset_names; +pub mod binary_conversions; pub mod check_genesis; pub mod cli_prompts; +pub mod loan_schedule; pub mod network_paths_and_settings; pub mod network_startup; pub mod nft_assets; diff --git a/src/orphans/undo_transactions/restore_mempool.rs b/src/orphans/undo_transactions/restore_mempool.rs index f9156bd..f813539 100644 --- a/src/orphans/undo_transactions/restore_mempool.rs +++ b/src/orphans/undo_transactions/restore_mempool.rs @@ -9,6 +9,7 @@ use crate::blocks::swap::SwapTransaction; use crate::blocks::token::CreateTokenTransaction; use crate::blocks::transfer::TransferTransaction; use crate::blocks::vanity::VanityAddressTransaction; +use crate::common::asset_names::loan_collateral_asset_name_or_base; use crate::common::nft_assets::nft_asset_name; use crate::common::types::Transaction; use crate::decode; @@ -148,6 +149,9 @@ pub async fn restore_swap(transaction: &SwapTransaction, db: &Db) { pub async fn restore_loan_creation(transaction: &LoanContractTransaction, db: &Db) { let loan = &transaction.unsigned_loan_contract; + let Some(collateral) = loan_collateral_asset_name_or_base(&loan.collateral, &BASECOIN) else { + return; + }; let lender_spendable = balance_checkup( db, loan.loan_amount, @@ -156,14 +160,8 @@ pub async fn restore_loan_creation(transaction: &LoanContractTransaction, db: &D &loan.lender, ) .await; - let borrower_spendable = balance_checkup( - db, - loan.collateral_amount, - 0, - loan.collateral.clone(), - &loan.borrower, - ) - .await; + let borrower_spendable = + balance_checkup(db, loan.collateral_amount, 0, collateral, &loan.borrower).await; let signatures = vec![ transaction.signature1.clone(), transaction.signature2.clone(), diff --git a/src/orphans/undo_transactions/undo_collateral.rs b/src/orphans/undo_transactions/undo_collateral.rs index 17c500f..c91c021 100644 --- a/src/orphans/undo_transactions/undo_collateral.rs +++ b/src/orphans/undo_transactions/undo_collateral.rs @@ -1,8 +1,10 @@ use crate::blocks::collateral::CollateralClaimTransaction; use crate::blocks::loans::LoanContractTransaction; +use crate::common::asset_names::loan_collateral_asset_name_or_base; use crate::common::network_paths_and_settings::block_extension_and_paths; use crate::decode; use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::memory::mempool::BASECOIN; use crate::records::record_chain::nft_provenance::remove_nft_history_entry; use crate::records::record_chain::wallet_tx_index::remove_wallet_transaction_index; use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; @@ -54,7 +56,9 @@ pub async fn undo_collateral_transaction( } }; - let collateral = loan_tx.unsigned_loan_contract.collateral; + let collateral = + loan_collateral_asset_name_or_base(&loan_tx.unsigned_loan_contract.collateral, &BASECOIN) + .ok_or_else(|| "Invalid loan collateral asset.".to_string())?; let collateral_amount = loan_tx.unsigned_loan_contract.collateral_amount; let collateral_holding = format!( "collateral_{}", diff --git a/src/orphans/undo_transactions/undo_loan_creation.rs b/src/orphans/undo_transactions/undo_loan_creation.rs index 842c51d..e0056db 100644 --- a/src/orphans/undo_transactions/undo_loan_creation.rs +++ b/src/orphans/undo_transactions/undo_loan_creation.rs @@ -1,7 +1,9 @@ use crate::blocks::loans::LoanContractTransaction; +use crate::common::asset_names::loan_collateral_asset_name_or_base; use crate::common::network_paths_and_settings::block_extension_and_paths; use crate::decode; use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::memory::mempool::BASECOIN; use crate::records::record_chain::nft_provenance::remove_nft_history_entry; use crate::records::record_chain::wallet_tx_index::remove_wallet_transaction_index; use crate::sled::Db; @@ -26,12 +28,22 @@ pub async fn undo_loan_creation_transaction( _balance_path, _log_path, ) = block_extension_and_paths(); - let (txfee, loan_coin, loan_amount, lender, collateral, collateral_amount, borrower, hash) = ( + let collateral = loan_collateral_asset_name_or_base( + &transaction.unsigned_loan_contract.collateral, + &BASECOIN, + ) + .unwrap_or_else(|| { + transaction + .unsigned_loan_contract + .collateral + .trim() + .to_string() + }); + let (txfee, loan_coin, loan_amount, lender, collateral_amount, borrower, hash) = ( &transaction.unsigned_loan_contract.txfee, &transaction.unsigned_loan_contract.loan_coin.clone(), &transaction.unsigned_loan_contract.loan_amount, &transaction.unsigned_loan_contract.lender.clone(), - &transaction.unsigned_loan_contract.collateral.clone(), &transaction.unsigned_loan_contract.collateral_amount, &transaction.unsigned_loan_contract.borrower.clone(), &transaction.hash.clone(), @@ -55,14 +67,14 @@ pub async fn undo_loan_creation_transaction( db, &collateral_wallet, *collateral_amount, - collateral, + &collateral, operand_subtraction, ); let _ = balance_sheet_operation_with_db( db, borrower, *collateral_amount, - collateral, + &collateral, operand_addition, ); @@ -86,7 +98,7 @@ pub async fn undo_loan_creation_transaction( .contains_key(collateral.as_bytes()) .unwrap_or(false) { - let _ = remove_nft_history_entry(db, collateral, &hash_binary); + let _ = remove_nft_history_entry(db, &collateral, &hash_binary); } if nft_tree.contains_key(loan_coin.as_bytes()).unwrap_or(false) { let _ = remove_nft_history_entry(db, loan_coin, &hash_binary); diff --git a/src/orphans/undo_transactions/undo_marketing.rs b/src/orphans/undo_transactions/undo_marketing.rs index 279847f..a892e8c 100644 --- a/src/orphans/undo_transactions/undo_marketing.rs +++ b/src/orphans/undo_transactions/undo_marketing.rs @@ -2,6 +2,7 @@ use crate::blocks::marketing::MarketingTransaction; use crate::common::network_paths_and_settings::block_extension_and_paths; use crate::decode; use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; +use crate::records::record_chain::marketing_campaign_index::remove_marketing_campaign_transaction_index; use crate::records::record_chain::wallet_tx_index::remove_wallet_transaction_index; use crate::sled::Db; @@ -42,6 +43,12 @@ pub async fn undo_marketing_transaction( let hash = decode(&transaction.unsigned_marketing.hash().await).unwrap(); let _ = remove_wallet_transaction_index(db, &[advertiser, mining_receiver], &hash); + let _ = remove_marketing_campaign_transaction_index( + db, + advertiser, + transaction.unsigned_marketing.campaign, + &hash, + ); // Remove the marketing transaction lookup from the txid tree. let tree = db.open_tree("txid").unwrap(); diff --git a/src/records/memory/mempool/mod.rs b/src/records/memory/mempool/mod.rs index f39c4f6..412f197 100644 --- a/src/records/memory/mempool/mod.rs +++ b/src/records/memory/mempool/mod.rs @@ -1,4 +1,5 @@ use crate::blocks::loans::LoanContractTransaction; +use crate::common::asset_names::loan_collateral_asset_name_or_base; use crate::common::binary_conversions::binary_to_string; use crate::common::network_paths_and_settings::block_extension_and_paths; use crate::common::nft_assets::{nft_asset_name, nft_asset_parts}; @@ -294,10 +295,17 @@ async fn resolve_collateral_details(db: &Db, contract_hash: &str) -> Result<(Vec return Ok((Vec::new(), 0)); } match LoanContractTransaction::from_bytes(7, &bytes[1..]).await { - Ok(loan) => Ok(( - loan.unsigned_loan_contract.collateral.as_bytes().to_vec(), - loan.unsigned_loan_contract.collateral_amount as i64, - )), + Ok(loan) => { + let collateral = loan_collateral_asset_name_or_base( + &loan.unsigned_loan_contract.collateral, + &BASECOIN, + ) + .unwrap_or_else(|| loan.unsigned_loan_contract.collateral.trim().to_string()); + Ok(( + collateral.into_bytes(), + loan.unsigned_loan_contract.collateral_amount as i64, + )) + } Err(_) => Ok((Vec::new(), 0)), } } diff --git a/src/records/memory/mempool/schema.rs b/src/records/memory/mempool/schema.rs index eb20d16..0f22b1a 100644 --- a/src/records/memory/mempool/schema.rs +++ b/src/records/memory/mempool/schema.rs @@ -238,7 +238,7 @@ pub async fn setup_mempool() -> Result<()> { loan_coin VARCHAR(15), loan_amount BIGINT NOT NULL, lender TEXT NOT NULL, - collateral VARCHAR(15), + collateral VARCHAR(21), collateral_amount BIGINT NOT NULL, borrower TEXT NOT NULL, txid VARCHAR(64) NOT NULL, @@ -307,6 +307,7 @@ pub async fn setup_mempool() -> Result<()> { ALTER TABLE swap ALTER COLUMN signature2 TYPE TEXT; ALTER TABLE loan_contract ALTER COLUMN lender TYPE TEXT; ALTER TABLE loan_contract ALTER COLUMN borrower TYPE TEXT; + ALTER TABLE loan_contract ALTER COLUMN collateral TYPE VARCHAR(21); ALTER TABLE loan_contract ALTER COLUMN signature1 TYPE TEXT; ALTER TABLE loan_contract ALTER COLUMN signature2 TYPE TEXT; ALTER TABLE loan_payment ALTER COLUMN address TYPE TEXT; diff --git a/src/records/memory/mempool/selection.rs b/src/records/memory/mempool/selection.rs index ad350e7..b56f07d 100644 --- a/src/records/memory/mempool/selection.rs +++ b/src/records/memory/mempool/selection.rs @@ -765,6 +765,8 @@ pub async fn apply_selected_transaction_math( } => { // Loan creation moves the loan asset to the borrower and locks // collateral under a contract-specific holding key. + let collateral = loan_collateral_asset_name_or_base(collateral, &BASECOIN) + .ok_or_else(|| anyhow!("Invalid loan collateral asset"))?; add_balance_change(db, &mut balance_changes, lender, &BASECOIN, -*fee); add_balance_change_bytes( &mut balance_changes, @@ -778,7 +780,7 @@ pub async fn apply_selected_transaction_math( db, &mut balance_changes, borrower, - collateral, + &collateral, -*collateral_amount, ); @@ -787,7 +789,7 @@ pub async fn apply_selected_transaction_math( db, &mut balance_changes, &collateral_holding, - collateral, + &collateral, *collateral_amount, ); diff --git a/src/records/record_chain/collateral_tx.rs b/src/records/record_chain/collateral_tx.rs index 6403f02..40ba679 100644 --- a/src/records/record_chain/collateral_tx.rs +++ b/src/records/record_chain/collateral_tx.rs @@ -1,5 +1,6 @@ use crate::blocks::collateral::CollateralClaimTransaction; use crate::blocks::loans::LoanContractTransaction; +use crate::common::asset_names::loan_collateral_asset_name_or_base; use crate::decode; use crate::records::memory::mempool::BASECOIN; use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; @@ -58,7 +59,9 @@ pub async fn process_collateral( } }; - let collateral = loan_tx.unsigned_loan_contract.collateral; + let collateral = + loan_collateral_asset_name_or_base(&loan_tx.unsigned_loan_contract.collateral, &BASECOIN) + .ok_or_else(|| "Invalid loan collateral asset.".to_string())?; let collateral_amount = loan_tx.unsigned_loan_contract.collateral_amount; let collateral_holding = format!( "collateral_{}", diff --git a/src/records/record_chain/lender_tx.rs b/src/records/record_chain/lender_tx.rs index 625bd26..31974db 100644 --- a/src/records/record_chain/lender_tx.rs +++ b/src/records/record_chain/lender_tx.rs @@ -1,4 +1,5 @@ use crate::blocks::loans::LoanContractTransaction; +use crate::common::asset_names::loan_collateral_asset_name_or_base; use crate::decode; use crate::records::memory::mempool::BASECOIN; use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; @@ -25,6 +26,11 @@ pub async fn process_lender( // the loan disbursement and collateral-holding balance changes. let txhash = transaction.unsigned_loan_contract.hash().await; let txhash_bytes = decode(&txhash).map_err(|e| format!("Error decoding hex: {e}"))?; + let collateral = loan_collateral_asset_name_or_base( + &transaction.unsigned_loan_contract.collateral, + &BASECOIN, + ) + .ok_or_else(|| "Invalid loan collateral asset.".to_string())?; let transaction_bytes = match transaction.to_bytes().await { Ok(bytes) => bytes, Err(e) => return Err(e.to_string()), @@ -60,14 +66,14 @@ pub async fn process_lender( pending_effects.add_balance( &transaction.unsigned_loan_contract.borrower, transaction.unsigned_loan_contract.collateral_amount, - &transaction.unsigned_loan_contract.collateral, + &collateral, BalanceOperand::Subtraction, ); let collateral_holding = format!("collateral_{txhash}"); pending_effects.add_balance( &collateral_holding, transaction.unsigned_loan_contract.collateral_amount, - &transaction.unsigned_loan_contract.collateral, + &collateral, BalanceOperand::Addition, ); @@ -91,11 +97,7 @@ pub async fn process_lender( pending_effects.append_tree_if_key_exists( "nfts", "nft_history", - transaction - .unsigned_loan_contract - .collateral - .as_bytes() - .to_vec(), + collateral.as_bytes().to_vec(), txhash_bytes.clone(), ); pending_effects.append_tree_if_key_exists( diff --git a/src/records/record_chain/marketing_campaign_index.rs b/src/records/record_chain/marketing_campaign_index.rs new file mode 100644 index 0000000..077a938 --- /dev/null +++ b/src/records/record_chain/marketing_campaign_index.rs @@ -0,0 +1,88 @@ +use crate::records::record_chain::pending_effects::PendingEffects; +use crate::sled::Db; +use crate::wallets::structures::Wallet; + +pub const MARKETING_CAMPAIGN_INDEX_TREE: &str = "marketing_campaign_index"; + +fn marketing_campaign_index_key( + advertiser: &str, + campaign: u64, + block_height: u32, + entry_index: u32, +) -> Result, String> { + let advertiser_bytes = Wallet::short_address_to_bytes(advertiser) + .ok_or_else(|| format!("Invalid marketing advertiser address: {advertiser}"))?; + let mut key = Vec::with_capacity(Wallet::SHORT_ADDRESS_BYTES_LENGTH + 16); + key.extend_from_slice(&advertiser_bytes); + key.extend_from_slice(&campaign.to_be_bytes()); + key.extend_from_slice(&block_height.to_be_bytes()); + key.extend_from_slice(&entry_index.to_be_bytes()); + Ok(key) +} + +pub fn index_marketing_campaign_transaction( + pending_effects: &mut PendingEffects, + advertiser: &str, + campaign: u64, + block_height: u32, + entry_index: u32, + txid: &[u8], +) -> Result<(), String> { + let key = marketing_campaign_index_key(advertiser, campaign, block_height, entry_index)?; + pending_effects.set_tree(MARKETING_CAMPAIGN_INDEX_TREE, key, txid.to_vec()); + Ok(()) +} + +fn txid_block_height(db: &Db, txid: &[u8]) -> Result, String> { + let tree = db + .open_tree("txid") + .map_err(|err| format!("Failed to open txid tree: {err}"))?; + let Some(value) = tree + .get(txid) + .map_err(|err| format!("Failed to read txid tree: {err}"))? + else { + return Ok(None); + }; + let value = String::from_utf8_lossy(&value); + let Some((block_height, _entry_index)) = value.split_once(':') else { + return Err("Invalid txid location value.".to_string()); + }; + block_height + .parse::() + .map(Some) + .map_err(|err| format!("Invalid txid block height: {err}")) +} + +pub fn remove_marketing_campaign_transaction_index( + db: &Db, + advertiser: &str, + campaign: u64, + txid: &[u8], +) -> Result<(), String> { + let Some(block_height) = txid_block_height(db, txid)? else { + return Ok(()); + }; + let advertiser_bytes = Wallet::short_address_to_bytes(advertiser) + .ok_or_else(|| format!("Invalid marketing advertiser address: {advertiser}"))?; + let mut prefix = Vec::with_capacity(Wallet::SHORT_ADDRESS_BYTES_LENGTH + 12); + prefix.extend_from_slice(&advertiser_bytes); + prefix.extend_from_slice(&campaign.to_be_bytes()); + prefix.extend_from_slice(&block_height.to_be_bytes()); + + let tree = db + .open_tree(MARKETING_CAMPAIGN_INDEX_TREE) + .map_err(|err| format!("Failed to open marketing campaign index: {err}"))?; + let keys = tree + .scan_prefix(prefix) + .filter_map(|entry| match entry { + Ok((key, value)) if value.as_ref() == txid => Some(key.to_vec()), + _ => None, + }) + .collect::>(); + + for key in keys { + tree.remove(key) + .map_err(|err| format!("Failed to remove marketing campaign index: {err}"))?; + } + Ok(()) +} diff --git a/src/records/record_chain/marketing_tx.rs b/src/records/record_chain/marketing_tx.rs index e45fc58..e9dcc0b 100644 --- a/src/records/record_chain/marketing_tx.rs +++ b/src/records/record_chain/marketing_tx.rs @@ -1,6 +1,7 @@ use crate::blocks::marketing::MarketingTransaction; use crate::decode; use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::marketing_campaign_index::index_marketing_campaign_transaction; use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; use crate::records::record_chain::wallet_tx_index::index_wallet_transaction; use crate::sled::Db; @@ -59,5 +60,13 @@ pub async fn process_marketing( **index as u32, &txhash_bytes, )?; + index_marketing_campaign_transaction( + pending_effects, + &transaction.unsigned_marketing.advertiser, + transaction.unsigned_marketing.campaign, + block_header_number, + **index as u32, + &txhash_bytes, + )?; Ok(binary_data) } diff --git a/src/records/record_chain/mod.rs b/src/records/record_chain/mod.rs index c65fbe7..78828bd 100644 --- a/src/records/record_chain/mod.rs +++ b/src/records/record_chain/mod.rs @@ -7,6 +7,7 @@ pub mod genesis_tx; pub mod header_number; pub mod issue_token_tx; pub mod lender_tx; +pub mod marketing_campaign_index; pub mod marketing_tx; pub mod nft_provenance; pub mod nft_tx; diff --git a/src/rpc/command_maps.rs b/src/rpc/command_maps.rs index 9a77e95..359770b 100644 --- a/src/rpc/command_maps.rs +++ b/src/rpc/command_maps.rs @@ -57,6 +57,8 @@ pub const RPC_WALLET_REGISTRATION_STATUS: u8 = 43; pub const RPC_ADDRESS_HISTORY: u8 = 44; pub const RPC_VANITY_OWNER_LOOKUP: u8 = 45; pub const RPC_LATEST_ADDRESS_TRANSACTIONS: u8 = 46; +pub const RPC_MARKETING_CAMPAIGN_HISTORY: u8 = 47; +pub const RPC_TOKEN_CATALOG: u8 = 48; pub const RPC_REPLY: u8 = 255; pub const MAX_RPC_REPLY_BYTES: usize = 64 * 1024 * 1024; diff --git a/src/rpc/commands/contract.rs b/src/rpc/commands/contract.rs index 97469c0..abd0862 100644 --- a/src/rpc/commands/contract.rs +++ b/src/rpc/commands/contract.rs @@ -1,12 +1,14 @@ use crate::blocks::collateral::CollateralClaimTransaction; use crate::blocks::loan_payment::ContractPaymentTransaction; use crate::blocks::loans::LoanContractTransaction; +use crate::common::loan_schedule::overdue_payment_count; use crate::encode; +use crate::records::memory::mempool::get_pending_payments_for_contract; use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; use crate::rpc::responses::RpcResponse; use crate::sled::Db; use crate::wallets::structures::Wallet; -use crate::{DateTime, Datelike, Local, TimeZone, Utc}; +use crate::{Local, TimeZone, Utc}; fn format_amount(value: u64) -> f64 { // Contract RPC output presents coin amounts as user-facing decimal values. @@ -21,40 +23,6 @@ fn format_local_date(timestamp: u32) -> String { } } -fn time_passed(start_time: u32, period: &str) -> usize { - // Contract summaries reuse the same elapsed-period logic as the - // collateral checks to compute delinquency and payment schedule state. - let start_datetime = Utc - .timestamp_opt(start_time as i64, 0) - .single() - .expect("Invalid start timestamp"); - let current_timestamp = Utc::now().timestamp() as u32; - let current_datetime = Utc - .timestamp_opt(current_timestamp as i64, 0) - .single() - .expect("Invalid current timestamp"); - let duration = current_datetime - start_datetime; - - match period { - "d" => duration.num_days() as usize, - "w" => (duration.num_days() / 7) as usize, - "m" => calculate_months_between(start_datetime, current_datetime), - _ => 0, - } -} - -fn calculate_months_between(start: DateTime, end: DateTime) -> usize { - // Monthly payment periods are calendar based, not a fixed number of - // seconds, so use year/month fields directly. - let start_date = start.date_naive(); - let end_date = end.date_naive(); - - let years_diff = end_date.year() - start_date.year(); - let months_diff = end_date.month() as i32 - start_date.month() as i32; - - (years_diff * 12 + months_diff) as usize -} - fn payment_type_name(period: &str) -> &'static str { // Expand the stored compact payment-period code into display text. match period.trim() { @@ -168,11 +136,18 @@ async fn build_contract_summary(hash: Vec, db: &Db) -> Result, db: &Db) -> Result, + block_height: u32, + time: u32, + campaign: u64, + ad_type: String, + keyword: String, + displayed: String, + impression: u8, + click: u8, + impression_value: u16, + click_value: u16, +} + +#[derive(Default)] +struct MarketingCampaignTotals { + total_records: u64, + total_impressions: u64, + total_clicks: u64, + total_impression_value: u64, + total_click_value: u64, +} + +impl MarketingCampaignTotals { + fn add(&mut self, row: &MarketingCampaignRow) { + self.total_records = self.total_records.saturating_add(1); + self.total_impressions = self + .total_impressions + .saturating_add(u64::from(row.impression)); + self.total_clicks = self.total_clicks.saturating_add(u64::from(row.click)); + self.total_impression_value = self.total_impression_value.saturating_add( + u64::from(row.impression).saturating_mul(u64::from(row.impression_value)), + ); + self.total_click_value = self + .total_click_value + .saturating_add(u64::from(row.click).saturating_mul(u64::from(row.click_value))); + } +} + +fn prefix_successor(prefix: &[u8]) -> Option> { + let mut next = prefix.to_vec(); + for index in (0..next.len()).rev() { + if next[index] != u8::MAX { + next[index] += 1; + next.truncate(index + 1); + return Some(next); + } + } + None +} + +fn campaign_bounds(advertiser_bytes: &[u8], campaign: u64) -> (Bound>, Bound>) { + let mut prefix = Vec::with_capacity(Wallet::SHORT_ADDRESS_BYTES_LENGTH + 8); + prefix.extend_from_slice(advertiser_bytes); + prefix.extend_from_slice(&campaign.to_be_bytes()); + let start = Bound::Included(prefix.clone()); + let end = prefix_successor(&prefix) + .map(Bound::Excluded) + .unwrap_or(Bound::Unbounded); + (start, end) +} + +fn row_from_transaction( + status: u8, + txid: Vec, + block_height: u32, + tx: MarketingTransaction, +) -> MarketingCampaignRow { + let marketing = tx.unsigned_marketing; + MarketingCampaignRow { + status, + txid, + block_height, + time: marketing.time, + campaign: marketing.campaign, + ad_type: marketing.ad_type, + keyword: marketing.keyword, + displayed: marketing.displayed, + impression: marketing.impression, + click: marketing.click, + impression_value: marketing.impression_value, + click_value: marketing.click_value, + } +} + +async fn pending_rows( + advertiser: &str, + campaign: u64, +) -> Result, String> { + let client_handle = db_client() + .await + .map_err(|err| format!("Failed to connect to mempool database: {err}"))?; + let client = client_handle.as_ref(); + let rows = client + .query( + r#" + SELECT hash, original + FROM marketing + WHERE advertiser = $1 AND processed = false + ORDER BY time DESC, id DESC + "#, + &[&advertiser], + ) + .await + .map_err(|err| format!("Failed to query pending marketing records: {err}"))?; + + let mut output = Vec::new(); + for row in rows { + let hash: String = row.get(0); + let original: Vec = row.get(1); + if original.first() != Some(&5) { + continue; + } + let Ok(tx) = MarketingTransaction::from_bytes(5, &original[1..]).await else { + continue; + }; + if tx.unsigned_marketing.campaign != campaign { + continue; + } + let txid = match crate::decode(&hash) { + Ok(bytes) if bytes.len() == TXID_LENGTH => bytes, + _ => continue, + }; + output.push(row_from_transaction(STATUS_PENDING, txid, 0, tx)); + } + + Ok(output) +} + +async fn mined_rows( + db: &Db, + advertiser_bytes: &[u8], + campaign: u64, + seen: &mut HashSet>, +) -> Result, String> { + let tree = db + .open_tree(MARKETING_CAMPAIGN_INDEX_TREE) + .map_err(|err| format!("Failed to open marketing campaign index: {err}"))?; + let (start, end) = campaign_bounds(advertiser_bytes, campaign); + let mut output = Vec::new(); + + for entry in tree.range((start, end)).rev() { + let (_key, value) = + entry.map_err(|err| format!("Failed to read marketing campaign index: {err}"))?; + if value.len() != TXID_LENGTH { + continue; + } + let txid = value.to_vec(); + if !seen.insert(txid.clone()) { + continue; + } + let (block_height, transaction_bytes) = transaction_bytes_with_block(db, &txid).await?; + if transaction_bytes.first() != Some(&5) { + continue; + } + let tx = MarketingTransaction::from_bytes(5, &transaction_bytes[1..]) + .await + .map_err(|err| format!("Failed to decode marketing transaction: {err}"))?; + if tx.unsigned_marketing.campaign != campaign { + continue; + } + output.push(row_from_transaction(STATUS_MINED, txid, block_height, tx)); + } + + Ok(output) +} + +fn encode_fixed_text(output: &mut Vec, value: &str, width: usize) { + let bytes = value.as_bytes(); + if bytes.len() >= width { + output.extend_from_slice(&bytes[..width]); + } else { + output.extend_from_slice(bytes); + output.extend(std::iter::repeat(b' ').take(width - bytes.len())); + } +} + +fn encode_response(totals: MarketingCampaignTotals, rows: &[MarketingCampaignRow]) -> RpcResponse { + let mut response = Vec::with_capacity(8 * 5 + 4 + rows.len().saturating_mul(202)); + response.extend_from_slice(&totals.total_records.to_le_bytes()); + response.extend_from_slice(&totals.total_impressions.to_le_bytes()); + response.extend_from_slice(&totals.total_clicks.to_le_bytes()); + response.extend_from_slice(&totals.total_impression_value.to_le_bytes()); + response.extend_from_slice(&totals.total_click_value.to_le_bytes()); + response.extend_from_slice(&(rows.len() as u32).to_le_bytes()); + + for row in rows { + response.push(row.status); + response.extend_from_slice(&row.txid); + response.extend_from_slice(&row.block_height.to_le_bytes()); + response.extend_from_slice(&row.time.to_le_bytes()); + response.extend_from_slice(&row.campaign.to_le_bytes()); + encode_fixed_text(&mut response, &row.ad_type, 6); + encode_fixed_text(&mut response, &row.keyword, 40); + encode_fixed_text(&mut response, &row.displayed, 100); + response.push(row.impression); + response.push(row.click); + response.extend_from_slice(&row.impression_value.to_le_bytes()); + response.extend_from_slice(&row.click_value.to_le_bytes()); + } + + RpcResponse::Binary(response) +} + +pub async fn lookup( + advertiser_bytes: Vec, + campaign: u64, + skip: u32, + limit: u32, + db: &Db, +) -> RpcResponse { + if advertiser_bytes.len() != Wallet::SHORT_ADDRESS_BYTES_LENGTH { + return RpcResponse::Binary(b"error: Invalid advertiser address bytes".to_vec()); + } + let Some(advertiser) = Wallet::bytes_to_short_address(&advertiser_bytes) else { + return RpcResponse::Binary(b"error: Invalid advertiser address bytes".to_vec()); + }; + + let limit = (limit as usize).min(MAX_MARKETING_CAMPAIGN_ROWS); + let skip = skip as usize; + let mut seen = HashSet::new(); + let mut all_rows = Vec::new(); + + match pending_rows(&advertiser, campaign).await { + Ok(rows) => { + for row in rows { + seen.insert(row.txid.clone()); + all_rows.push(row); + } + } + Err(err) => return RpcResponse::Binary(format!("error: {err}").into_bytes()), + } + + match mined_rows(db, &advertiser_bytes, campaign, &mut seen).await { + Ok(rows) => all_rows.extend(rows), + Err(err) => return RpcResponse::Binary(format!("error: {err}").into_bytes()), + } + + all_rows.sort_by(|left, right| { + right + .time + .cmp(&left.time) + .then_with(|| right.block_height.cmp(&left.block_height)) + }); + + let mut totals = MarketingCampaignTotals::default(); + for row in &all_rows { + totals.add(row); + } + + let paged_rows = if limit == 0 { + Vec::new() + } else { + all_rows + .into_iter() + .skip(skip) + .take(limit) + .collect::>() + }; + + encode_response(totals, &paged_rows) +} diff --git a/src/rpc/commands/mod.rs b/src/rpc/commands/mod.rs index 46adca5..d59e4d0 100644 --- a/src/rpc/commands/mod.rs +++ b/src/rpc/commands/mod.rs @@ -16,6 +16,7 @@ pub mod contract; pub mod difficulty; pub mod largest_tx_fee; pub mod latest_block; +pub mod marketing_campaign_history; pub mod memory_by_signature; pub mod network_info; pub mod nft_list; diff --git a/src/rpc/commands/nft_lookup.rs b/src/rpc/commands/nft_lookup.rs index 0aa2214..d304829 100644 --- a/src/rpc/commands/nft_lookup.rs +++ b/src/rpc/commands/nft_lookup.rs @@ -4,12 +4,14 @@ use crate::blocks::loans::LoanContractTransaction; use crate::blocks::nft::CreateNftTransaction; use crate::blocks::swap::SwapTransaction; use crate::blocks::transfer::TransferTransaction; +use crate::common::asset_names::loan_collateral_asset_name_or_base; use crate::common::binary_conversions::binary_to_string; use crate::common::nft_assets::{nft_asset_name, nft_asset_parts}; use crate::decode; use crate::fs; use crate::records::balance_sheet::pathing::{balance_asset_segments, balance_root_path}; use crate::records::balance_sheet::tokens_to_lower::strip_spaces_and_lowercase; +use crate::records::memory::mempool::BASECOIN; use crate::rpc::commands::transaction_by_txid::{ request_transaction_by_txid, request_transaction_by_txid_with_block, }; @@ -245,7 +247,11 @@ async fn build_history_entry(db: &Db, asset_name: &str, txid_bytes: &[u8]) -> Op let tx = LoanContractTransaction::from_bytes(txtype, body) .await .ok()?; - if tx.unsigned_loan_contract.collateral == asset_name { + let collateral = loan_collateral_asset_name_or_base( + &tx.unsigned_loan_contract.collateral, + &BASECOIN, + )?; + if collateral == asset_name { from = wallet_field_bytes(&tx.unsigned_loan_contract.borrower); let (asset, series) = nft_asset_parts(&tx.unsigned_loan_contract.loan_coin); received_asset = padded_asset_bytes(asset.trim()); @@ -255,7 +261,7 @@ async fn build_history_entry(db: &Db, asset_name: &str, txid_bytes: &[u8]) -> Op } else if tx.unsigned_loan_contract.loan_coin == asset_name { from = wallet_field_bytes(&tx.unsigned_loan_contract.lender); to = wallet_field_bytes(&tx.unsigned_loan_contract.borrower); - let (asset, series) = nft_asset_parts(&tx.unsigned_loan_contract.collateral); + let (asset, series) = nft_asset_parts(&collateral); received_asset = padded_asset_bytes(asset.trim()); received_series = series; received_value = tx.unsigned_loan_contract.collateral_amount; @@ -295,7 +301,11 @@ async fn build_history_entry(db: &Db, asset_name: &str, txid_bytes: &[u8]) -> Op let contract = LoanContractTransaction::from_bytes(7, &contract_bytes) .await .ok()?; - if contract.unsigned_loan_contract.collateral != asset_name { + let collateral = loan_collateral_asset_name_or_base( + &contract.unsigned_loan_contract.collateral, + &BASECOIN, + )?; + if collateral != asset_name { return None; } to = wallet_field_bytes(&tx.unsigned_collateral_claim.address); diff --git a/src/rpc/commands/token_lookup.rs b/src/rpc/commands/token_lookup.rs index e7b745f..116cf5f 100644 --- a/src/rpc/commands/token_lookup.rs +++ b/src/rpc/commands/token_lookup.rs @@ -295,3 +295,157 @@ pub async fn lookup_token_details(db: &Db, token_name: String) -> RpcResponse { RpcResponse::Binary(response) } + +pub async fn token_catalog(db: &Db) -> RpcResponse { + let tokens_tree = match db.open_tree("tokens") { + Ok(tree) => tree, + Err(err) => { + return RpcResponse::Binary( + format!("error: Failed to open tokens tree: {err}").into_bytes(), + ) + } + }; + let origins_tree = match db.open_tree("token_origins") { + Ok(tree) => tree, + Err(err) => { + return RpcResponse::Binary( + format!("error: Failed to open token origins tree: {err}").into_bytes(), + ) + } + }; + let limits_tree = match db.open_tree("token_limits") { + Ok(tree) => tree, + Err(err) => { + return RpcResponse::Binary( + format!("error: Failed to open token limits tree: {err}").into_bytes(), + ) + } + }; + + let mut rows = Vec::new(); + + for entry in origins_tree.iter() { + let (ticker_key, origin_value) = match entry { + Ok(pair) => pair, + Err(_) => continue, + }; + let ticker = binary_to_string(ticker_key.to_vec()); + let normalized_ticker = strip_spaces_and_lowercase(&ticker); + if normalized_ticker.is_empty() { + continue; + } + + let origin_hash = binary_to_string(origin_value.to_vec()); + let origin_hash_bytes = match decode(&origin_hash) { + Ok(bytes) if bytes.len() == 32 => bytes, + _ => continue, + }; + + let RpcResponse::Binary(tx_bytes) = + request_transaction_by_txid(db, origin_hash_bytes.clone()).await; + if tx_bytes.is_empty() || tx_bytes[0] != 3 { + continue; + } + + let contract = match CreateTokenTransaction::from_bytes(tx_bytes[0], &tx_bytes[1..]).await { + Ok(tx) => tx, + Err(_) => continue, + }; + + let creator_bytes = + match Wallet::short_address_to_bytes(&contract.unsigned_create_token.creator) { + Some(bytes) => bytes, + None => continue, + }; + + let current_supply = tokens_tree + .get(ticker_key.as_ref()) + .ok() + .flatten() + .and_then(|value| parse_token_supply(value.as_ref())) + .unwrap_or(0); + let hard_limit = limits_tree + .get(ticker_key.as_ref()) + .ok() + .flatten() + .and_then(|bytes| bytes.first().copied()) + .unwrap_or(contract.unsigned_create_token.hard_limit); + + let (_, burned_hashes) = collect_token_history(db, &ticker, &origin_hash_bytes).await; + let mut total_burned = 0_u64; + for hash in burned_hashes { + let RpcResponse::Binary(burn_bytes) = + request_transaction_by_txid(db, hash.to_vec()).await; + if burn_bytes.is_empty() || burn_bytes[0] != 10 { + continue; + } + let Ok(burn) = + crate::blocks::burn::BurnTransaction::from_bytes(burn_bytes[0], &burn_bytes[1..]) + .await + else { + continue; + }; + if burn.unsigned_burn.nft_series == 0 + && strip_spaces_and_lowercase(&burn.unsigned_burn.coin) == normalized_ticker + { + total_burned = total_burned.saturating_add(burn.unsigned_burn.value); + } + } + + let mut padded_ticker = ticker.as_bytes().to_vec(); + if padded_ticker.len() < 15 { + padded_ticker.resize(15, b' '); + } + if padded_ticker.len() != 15 + || creator_bytes.len() != Wallet::SHORT_ADDRESS_BYTES_LENGTH + || origin_hash_bytes.len() != 32 + { + continue; + } + + rows.push(( + padded_ticker, + origin_hash_bytes, + creator_bytes, + contract.unsigned_create_token.time, + contract.unsigned_create_token.number, + current_supply, + total_burned, + count_wallets_holding(&normalized_ticker) as u32, + hard_limit, + )); + } + + rows.sort_by(|left, right| { + binary_to_string(left.0.clone()) + .to_ascii_lowercase() + .cmp(&binary_to_string(right.0.clone()).to_ascii_lowercase()) + }); + + let mut response = Vec::with_capacity(4 + rows.len() * 102); + response.extend_from_slice(&(rows.len() as u32).to_le_bytes()); + for ( + ticker, + origin_hash, + creator, + created_timestamp, + initial_supply, + current_supply, + total_burned, + holder_count, + hard_limit, + ) in rows + { + response.extend_from_slice(&ticker); + response.extend_from_slice(&origin_hash); + response.extend_from_slice(&creator); + response.extend_from_slice(&created_timestamp.to_le_bytes()); + response.extend_from_slice(&initial_supply.to_le_bytes()); + response.extend_from_slice(¤t_supply.to_le_bytes()); + response.extend_from_slice(&total_burned.to_le_bytes()); + response.extend_from_slice(&holder_count.to_le_bytes()); + response.push(hard_limit); + } + + RpcResponse::Binary(response) +} diff --git a/src/rpc/commands/tx_submit.rs b/src/rpc/commands/tx_submit.rs index 790aacf..723e952 100644 --- a/src/rpc/commands/tx_submit.rs +++ b/src/rpc/commands/tx_submit.rs @@ -22,6 +22,13 @@ use crate::sled::Db; use crate::torrent::torrenting_system::get_nodes::get_nodes_from_memory; use crate::Arc; use crate::Mutex; +use crate::OnceLock; + +static LOAN_PAYMENT_SUBMIT_LOCK: OnceLock> = OnceLock::new(); + +fn loan_payment_submit_lock() -> &'static Mutex<()> { + LOAN_PAYMENT_SUBMIT_LOCK.get_or_init(|| Mutex::new(())) +} async fn broadcast_tx(tx_bytes: Vec, map: Arc>) { // Broadcast newly accepted mempool transactions only to miner peers, @@ -375,45 +382,53 @@ pub async fn save_and_submit( RpcResponse::Binary(msg) } }, - BORROWER_TYPE => match ContractPaymentTransaction::from_bytes(txtype, &tx).await { - Ok(payment) => match payment.verify(db).await { - Ok(existing) => { - if !existing.is_empty() { - let msg = "successful_broadcast: false already_in_mempool" - .to_string() - .as_bytes() - .to_vec(); - return RpcResponse::Binary(msg); - } - match payment.to_bytes().await { - Ok(payment_bytes) => match payment.add_to_memory().await { - Ok(_) => { - broadcast_tx(payment_bytes, map.clone()).await; - let msg = - "successful_broadcast: true".to_string().as_bytes().to_vec(); - RpcResponse::Binary(msg) - } + BORROWER_TYPE => { + // Serialize the pending-total check and insert so concurrent + // payments cannot both reserve the same remaining balance. + let _loan_payment_guard = loan_payment_submit_lock().lock().await; + match ContractPaymentTransaction::from_bytes(txtype, &tx).await { + Ok(payment) => match payment.verify(db).await { + Ok(existing) => { + if !existing.is_empty() { + let msg = "successful_broadcast: false already_in_mempool" + .to_string() + .as_bytes() + .to_vec(); + return RpcResponse::Binary(msg); + } + match payment.to_bytes().await { + Ok(payment_bytes) => match payment.add_to_memory().await { + Ok(_) => { + broadcast_tx(payment_bytes, map.clone()).await; + let msg = "successful_broadcast: true" + .to_string() + .as_bytes() + .to_vec(); + RpcResponse::Binary(msg) + } + Err(err) => { + let msg = + format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, Err(err) => { let msg = format!("error: {err}").to_string().as_bytes().to_vec(); RpcResponse::Binary(msg) } - }, - Err(err) => { - let msg = format!("error: {err}").to_string().as_bytes().to_vec(); - RpcResponse::Binary(msg) } } - } + Err(err) => { + let msg = format!("error: {err}").to_string().as_bytes().to_vec(); + RpcResponse::Binary(msg) + } + }, Err(err) => { let msg = format!("error: {err}").to_string().as_bytes().to_vec(); RpcResponse::Binary(msg) } - }, - Err(err) => { - let msg = format!("error: {err}").to_string().as_bytes().to_vec(); - RpcResponse::Binary(msg) } - }, + } COLLATERAL_TYPE => match CollateralClaimTransaction::from_bytes(txtype, &tx).await { Ok(collateral) => match collateral.verify(db).await { Ok(existing) => { diff --git a/src/rpc/server/rpc_command_loop.rs b/src/rpc/server/rpc_command_loop.rs index f5d9aa5..7c8f756 100644 --- a/src/rpc/server/rpc_command_loop.rs +++ b/src/rpc/server/rpc_command_loop.rs @@ -907,6 +907,55 @@ pub async fn start_loop( .send(&stream_locked, Some(&connections_key), uid) .await; } + 47 => { + // request marketing campaign summary and paged campaign records + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let advertiser = read_bytes_from_stream::read_short_address_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let campaign = read_bytes_from_stream::read_u64_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let skip = read_bytes_from_stream::read_u32_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let limit = read_bytes_from_stream::read_u32_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::marketing_campaign_history::lookup( + advertiser, campaign, skip, limit, &db, + ) + .await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 48 => { + // request the full token catalog for wallet asset views + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::token_lookup::token_catalog(&db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } 255 => { commands::route_reply::route_reply( &connections_key, diff --git a/src/standalone_tools/connections/sending_request.rs b/src/standalone_tools/connections/sending_request.rs index 964ca4b..6d1d4b6 100644 --- a/src/standalone_tools/connections/sending_request.rs +++ b/src/standalone_tools/connections/sending_request.rs @@ -5,12 +5,12 @@ use crate::records::memory::response_channels::Byte3; use crate::rpc::command_maps::{ MAX_RPC_REPLY_BYTES, RPC_ADDRESS_HISTORY, RPC_ADDRESS_TOTAL_BALANCE, RPC_BLOCK_BY_HASH, RPC_BLOCK_BY_HEIGHT, RPC_BLOCK_HEIGHT, RPC_BLOCK_IP, RPC_CONTRACT_BY_ADDRESS, RPC_DIFFICULTY, - RPC_LARGEST_TX_FEE, RPC_LATEST_ADDRESS_TRANSACTIONS, - RPC_LOAN_CONTRACT, RPC_MEMPOOL_TX_BY_ADDRESS, RPC_MEMPOOL_TX_BY_SIGNATURE, + RPC_LARGEST_TX_FEE, RPC_LATEST_ADDRESS_TRANSACTIONS, RPC_LOAN_CONTRACT, + RPC_MARKETING_CAMPAIGN_HISTORY, RPC_MEMPOOL_TX_BY_ADDRESS, RPC_MEMPOOL_TX_BY_SIGNATURE, RPC_MEMPOOL_TX_COUNT, RPC_NETWORK_INFO, RPC_NFT_DETAILS, RPC_NFT_LIST, RPC_REGISTER_WALLET, - RPC_TIME, RPC_TOKEN_DETAILS, RPC_TOKEN_LIST, RPC_TORRENT_BY_HEIGHT, RPC_TOTAL_CONFIRMED_TX, - RPC_TRANSACTION_BY_TXID, RPC_UNBLOCK_IP, RPC_VANITY_LOOKUP, RPC_VANITY_OWNER_LOOKUP, - RPC_WALLET_REGISTRATION_STATUS, + RPC_TIME, RPC_TOKEN_CATALOG, RPC_TOKEN_DETAILS, RPC_TOKEN_LIST, RPC_TORRENT_BY_HEIGHT, + RPC_TOTAL_CONFIRMED_TX, RPC_TRANSACTION_BY_TXID, RPC_UNBLOCK_IP, RPC_VALIDATE_MESSAGE, + RPC_VANITY_LOOKUP, RPC_VANITY_OWNER_LOOKUP, RPC_WALLET_REGISTRATION_STATUS, }; use crate::standalone_tools::transaction_creator::create_transaction_request; use crate::timeout; @@ -251,6 +251,42 @@ async fn build_request_bytes( bin_msg.extend_from_slice(&hashmap_key); bin_msg.extend_from_slice(&address_bytes) } + // Validate a signature over a UTF-8 message for a registered wallet. + 25 => { + let command_number: u8 = RPC_VALIDATE_MESSAGE; + let mut parts = command_input.splitn(3, '|'); + let message = parts.next().unwrap_or_default(); + let address = parts.next().unwrap_or_default(); + let signature = parts.next().unwrap_or_default(); + let message_bytes = message.as_bytes(); + let message_size = u16::try_from(message_bytes.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "Signed message is too long") + })?; + let address_bytes = Wallet::normalize_to_short_address(address) + .and_then(|short| Wallet::short_address_to_bytes(&short)) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "Invalid signer address") + })?; + let signature_bytes = decode(signature).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid signature: {err}"), + ) + })?; + if signature_bytes.len() != Wallet::SIGNATURE_LENGTH { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Signature has the wrong byte length", + )); + } + + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&message_size.to_le_bytes()); + bin_msg.extend_from_slice(message_bytes); + bin_msg.extend_from_slice(&address_bytes); + bin_msg.extend_from_slice(&signature_bytes); + } // Server-owner block IP request. 26 => { let command_number: u8 = RPC_BLOCK_IP; @@ -541,6 +577,46 @@ async fn build_request_bytes( bin_msg.extend_from_slice(&address_bytes); bin_msg.extend_from_slice(&limit.to_le_bytes()); } + // Lookup marketing campaign summary and paged campaign records. + 47 => { + let command_number: u8 = RPC_MARKETING_CAMPAIGN_HISTORY; + let parts = command_input.split('|').collect::>(); + if parts.len() != 4 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Marketing campaign history input must be advertiser|campaign|skip|limit", + )); + } + let address_bytes = Wallet::normalize_to_short_address(parts[0]) + .and_then(|s| Wallet::short_address_to_bytes(&s)) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "Invalid advertiser address") + })?; + let campaign = parts[1] + .parse::() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Invalid campaign id"))?; + let skip = parts[2].parse::().map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "Invalid campaign history skip") + })?; + let limit = parts[3].parse::().map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid campaign history limit", + ) + })?; + + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&address_bytes); + bin_msg.extend_from_slice(&campaign.to_le_bytes()); + bin_msg.extend_from_slice(&skip.to_le_bytes()); + bin_msg.extend_from_slice(&limit.to_le_bytes()); + } + 48 => { + let command_number: u8 = RPC_TOKEN_CATALOG; + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + } _ => { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -551,3 +627,30 @@ async fn build_request_bytes( Ok(bin_msg) } + +#[cfg(test)] +mod tests { + use super::build_request_bytes; + use crate::rpc::command_maps::RPC_VALIDATE_MESSAGE; + use crate::wallets::structures::Wallet; + + #[tokio::test] + async fn validate_message_request_uses_expected_wire_layout() { + let address = format!("{}.cltc", "00".repeat(20)); + let signature = "00".repeat(Wallet::SIGNATURE_LENGTH); + let uid = [1, 2, 3]; + let bytes = build_request_bytes( + format!("message|{address}|{signature}"), + RPC_VALIDATE_MESSAGE as usize, + uid, + ) + .await + .unwrap(); + + assert_eq!(bytes[0], RPC_VALIDATE_MESSAGE); + assert_eq!(&bytes[1..4], &uid); + assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 7); + assert_eq!(&bytes[6..13], b"message"); + assert_eq!(bytes.len(), 1 + 3 + 2 + 7 + 22 + Wallet::SIGNATURE_LENGTH); + } +} diff --git a/src/verifications/async_funcs/checks/block_balance.rs b/src/verifications/async_funcs/checks/block_balance.rs index 497a2ec..abf3f7f 100644 --- a/src/verifications/async_funcs/checks/block_balance.rs +++ b/src/verifications/async_funcs/checks/block_balance.rs @@ -1,4 +1,5 @@ use crate::blocks::loans::LoanContractTransaction; +use crate::common::asset_names::loan_collateral_asset_name_or_base; use crate::common::nft_assets::nft_asset_name; use crate::common::types::Transaction; use crate::decode; @@ -9,6 +10,7 @@ use crate::records::memory::mempool::{ use crate::rpc::commands::transaction_by_txid::request_transaction_by_txid; use crate::rpc::responses::RpcResponse; use crate::sled::Db; +use crate::verifications::async_funcs::total_payments::get_total_payments; use std::collections::{HashMap, HashSet}; #[derive(Clone)] @@ -30,12 +32,21 @@ pub(crate) struct TransactionBalanceReservation { signatures: Vec, debits: HashMap<(String, String), u64>, available: HashMap<(String, String), u64>, + loan_payment: Option, +} + +struct LoanPaymentReservation { + contract_hash: String, + payment_amount: u64, + confirmed_paid: u64, + contract_total: u64, } pub struct BlockBalanceTracker { transaction_hashes: HashSet, transaction_signatures: HashSet, reserved: HashMap<(String, String), u64>, + loan_payments_reserved: HashMap, } impl BlockBalanceTracker { @@ -44,6 +55,7 @@ impl BlockBalanceTracker { transaction_hashes: HashSet::new(), transaction_signatures: HashSet::new(), reserved: HashMap::new(), + loan_payments_reserved: HashMap::new(), } } @@ -61,6 +73,30 @@ impl BlockBalanceTracker { } } + let loan_payment_update = if let Some(payment) = reservation.loan_payment { + let already_reserved = self + .loan_payments_reserved + .get(&payment.contract_hash) + .copied() + .unwrap_or(0); + let block_total = already_reserved + .checked_add(payment.payment_amount) + .ok_or_else(|| "Block loan payment reservation overflowed.".to_string())?; + let total_after_block = payment + .confirmed_paid + .checked_add(block_total) + .ok_or_else(|| "Block loan repayment total overflowed.".to_string())?; + if total_after_block > payment.contract_total { + return Err(format!( + "Loan payments inside block exceed remaining contract balance: contract={}", + payment.contract_hash + )); + } + Some((payment.contract_hash, block_total)) + } else { + None + }; + let mut updates = Vec::new(); for (key, transaction_debit) in reservation.debits { let already_reserved = self.reserved.get(&key).copied().unwrap_or(0); @@ -83,6 +119,10 @@ impl BlockBalanceTracker { for (key, required) in updates { self.reserved.insert(key, required); } + if let Some((contract_hash, block_total)) = loan_payment_update { + self.loan_payments_reserved + .insert(contract_hash, block_total); + } Ok(()) } @@ -315,6 +355,8 @@ async fn transaction_balance_view( Transaction::Lender(tx) => { let loan = &tx.unsigned_loan_contract; let mut debits = Vec::new(); + let collateral = loan_collateral_asset_name_or_base(&loan.collateral, &BASECOIN) + .ok_or_else(|| "Invalid loan collateral asset.".to_string())?; add_debit( &mut debits, @@ -326,7 +368,7 @@ async fn transaction_balance_view( add_debit( &mut debits, &loan.borrower, - loan.collateral.clone(), + collateral, loan.collateral_amount, ); @@ -422,6 +464,23 @@ pub(crate) async fn prepare_transaction_balance_reservation( } let already_reserved_in_mempool = transaction_is_pending(&view).await?; + let loan_payment = if let Transaction::Borrower(tx) = transaction { + let payment = &tx.unsigned_contract_payment; + let contract = loan_contract_for_hash(db, &payment.contract_hash).await?; + let contract_total = contract + .unsigned_loan_contract + .payment_amount + .checked_mul(contract.unsigned_loan_contract.payment_number as u64) + .ok_or_else(|| "Loan repayment total overflowed.".to_string())?; + Some(LoanPaymentReservation { + contract_hash: payment.contract_hash.clone(), + payment_amount: payment.payback_amount, + confirmed_paid: get_total_payments(db, &payment.contract_hash).await, + contract_total, + }) + } else { + None + }; let mut debits = HashMap::new(); let mut available = HashMap::new(); @@ -452,5 +511,50 @@ pub(crate) async fn prepare_transaction_balance_reservation( signatures: view.signatures, debits, available, + loan_payment, }) } + +#[cfg(test)] +mod tests { + use super::{BlockBalanceTracker, LoanPaymentReservation, TransactionBalanceReservation}; + use std::collections::HashMap; + + fn payment_reservation( + hash: &str, + contract_hash: &str, + payment_amount: u64, + confirmed_paid: u64, + contract_total: u64, + ) -> TransactionBalanceReservation { + TransactionBalanceReservation { + hash: hash.to_string(), + signatures: vec![format!("signature-{hash}")], + debits: HashMap::new(), + available: HashMap::new(), + loan_payment: Some(LoanPaymentReservation { + contract_hash: contract_hash.to_string(), + payment_amount, + confirmed_paid, + contract_total, + }), + } + } + + #[test] + fn cumulative_block_payments_cannot_exceed_contract_total() { + let mut tracker = BlockBalanceTracker::new(); + + tracker + .reserve(payment_reservation("one", "contract", 50, 20, 100)) + .unwrap(); + tracker + .reserve(payment_reservation("two", "contract", 30, 20, 100)) + .unwrap(); + + let error = tracker + .reserve(payment_reservation("three", "contract", 1, 20, 100)) + .unwrap_err(); + assert!(error.contains("exceed remaining contract balance")); + } +} diff --git a/src/verifications/async_funcs/transactions.rs b/src/verifications/async_funcs/transactions.rs index 8b64da6..118338a 100644 --- a/src/verifications/async_funcs/transactions.rs +++ b/src/verifications/async_funcs/transactions.rs @@ -15,6 +15,7 @@ pub async fn verify_transactions( db: &Db, stop_flag: Arc, balance_tracker: Arc>, + block_timestamp: u32, ) -> Result, String> { let mut results: Vec = Vec::new(); @@ -26,7 +27,15 @@ pub async fn verify_transactions( return Err("Stop signal received.".to_string()); } - match verify_transaction(miner, transaction.clone(), db, balance_tracker.clone()).await { + match verify_transaction( + miner, + transaction.clone(), + db, + balance_tracker.clone(), + block_timestamp, + ) + .await + { Ok(result) => { results.push(result); } @@ -48,6 +57,7 @@ pub async fn verify_transactions( transaction.clone(), db, balance_tracker.clone(), + block_timestamp, ) .await { @@ -71,6 +81,7 @@ async fn verify_transaction( transaction: Transaction, db: &Db, balance_tracker: Arc>, + block_timestamp: u32, ) -> Result { if let Transaction::Genesis(genesis_tx) = &transaction { match genesis_tx.verify().await { @@ -202,7 +213,7 @@ async fn verify_transaction( } } if let Transaction::Borrower(loan_payment_tx) = &transaction { - match loan_payment_tx.verify(db).await { + match loan_payment_tx.verify_for_block(db).await { Ok(value) => { reserve_verified_transaction(db, &transaction, balance_tracker).await?; return Ok(value); @@ -215,7 +226,7 @@ async fn verify_transaction( } } if let Transaction::Collateral(collateral_claim_tx) = &transaction { - match collateral_claim_tx.verify(db).await { + match collateral_claim_tx.verify_at(db, block_timestamp).await { Ok(value) => { reserve_verified_transaction(db, &transaction, balance_tracker).await?; return Ok(value); diff --git a/src/verifications/async_funcs/verify_block.rs b/src/verifications/async_funcs/verify_block.rs index 35826bd..74fa2ad 100644 --- a/src/verifications/async_funcs/verify_block.rs +++ b/src/verifications/async_funcs/verify_block.rs @@ -102,6 +102,7 @@ impl Block { self.transactions.clone(), db.clone(), verification_service, + timestamp, ) .await?; @@ -183,11 +184,12 @@ impl Block { transactions: Vec, db: crate::sled::Db, verification_service: Arc, + block_timestamp: u32, ) -> Result, String> { // transaction verification is centralized in the shared // verification service so block assembly stays lightweight verification_service - .verify_block_transactions(miner, transactions, db) + .verify_block_transactions(miner, transactions, db, block_timestamp) .await } diff --git a/src/verifications/async_funcs/verify_borrower.rs b/src/verifications/async_funcs/verify_borrower.rs index 10da547..492e38c 100644 --- a/src/verifications/async_funcs/verify_borrower.rs +++ b/src/verifications/async_funcs/verify_borrower.rs @@ -1,6 +1,7 @@ use crate::blocks::loan_payment::ContractPaymentTransaction; use crate::blocks::loans::LoanContractTransaction; use crate::common::types::BORROWER_FEE; +use crate::records::memory::mempool::get_pending_payments_for_contract; use crate::records::wallet_registry::{ require_canonical_registered_short_address, resolve_pubkey_from_short_address, }; @@ -16,6 +17,18 @@ use crate::{decode, encode}; impl ContractPaymentTransaction { pub async fn verify(&self, db: &Db) -> Result { + self.verify_internal(db, true).await + } + + pub async fn verify_for_block(&self, db: &Db) -> Result { + self.verify_internal(db, false).await + } + + async fn verify_internal( + &self, + db: &Db, + check_pending_payments: bool, + ) -> Result { let hash = self.unsigned_contract_payment.hash().await; let signature = &self.signature; @@ -103,6 +116,20 @@ impl ContractPaymentTransaction { if payback_amount > remaining_balance { return Err("Payment exceeds the remaining contract balance".to_string()); } + if check_pending_payments { + let pending_payments = get_pending_payments_for_contract(contract_hash) + .await + .map_err(|err| format!("Failed to check pending loan payments: {err}"))?; + let pending_with_new = pending_payments + .checked_add(payback_amount) + .ok_or_else(|| "Pending loan payment total overflowed.".to_string())?; + if pending_with_new > remaining_balance { + return Err( + "Payment exceeds the remaining contract balance after pending payments." + .to_string(), + ); + } + } // Borrower payments must cover the fixed base-coin fee and an // asset-denominated tip of at least 1% of the payment amount. diff --git a/src/verifications/async_funcs/verify_collateral.rs b/src/verifications/async_funcs/verify_collateral.rs index 42f6984..30a2299 100644 --- a/src/verifications/async_funcs/verify_collateral.rs +++ b/src/verifications/async_funcs/verify_collateral.rs @@ -1,5 +1,6 @@ use crate::blocks::collateral::CollateralClaimTransaction; use crate::blocks::loans::LoanContractTransaction; +use crate::common::loan_schedule::overdue_payment_count; use crate::common::types::COLLATERAL_FEE; use crate::records::memory::mempool::BASECOIN; use crate::records::wallet_registry::{ @@ -13,17 +14,38 @@ use crate::verifications::async_funcs::checks::mempool_check::memcheck; use crate::verifications::async_funcs::checks::verify_db::db_hex_verification; use crate::verifications::async_funcs::total_payments::get_total_payments; use crate::wallets::structures::Wallet; +use crate::Utc; use crate::{decode, encode}; -use crate::{DateTime, Datelike, TimeZone, Utc}; impl CollateralClaimTransaction { pub async fn verify(&self, db: &Db) -> Result { + self.verify_internal(db, Utc::now().timestamp() as u32, true) + .await + } + + pub async fn verify_at(&self, db: &Db, as_of_timestamp: u32) -> Result { + self.verify_internal(db, as_of_timestamp, false).await + } + + async fn verify_internal( + &self, + db: &Db, + as_of_timestamp: u32, + allow_mempool_short_circuit: bool, + ) -> Result { let hash = self.unsigned_collateral_claim.hash().await; let signature = &self.signature; - // Transactions already present in the mempool can short-circuit - // the deeper verification path and reuse their stored signature. - if memcheck(signature, &hash).await { + if self.unsigned_collateral_claim.time > as_of_timestamp { + return Err( + "Collateral claim timestamp cannot be later than its containing block.".to_string(), + ); + } + + let already_in_mempool = memcheck(signature, &hash).await; + // Submission can reuse an already verified mempool entry. Block + // validation still checks the schedule against the block timestamp. + if allow_mempool_short_circuit && already_in_mempool { return Ok(signature.to_string()); } @@ -118,12 +140,14 @@ impl CollateralClaimTransaction { } // Both possible claimant roles must be able to cover the base-coin fee. - if address == contract.unsigned_loan_contract.lender + if !already_in_mempool + && address == contract.unsigned_loan_contract.lender && !balance_checkup(db, 0, txfee, BASECOIN.clone(), &address).await { return Err("Insuficient funds for this Transfer Transaction!".to_string()); } - if address == contract.unsigned_loan_contract.borrower + if !already_in_mempool + && address == contract.unsigned_loan_contract.borrower && !balance_checkup(db, 0, txfee, BASECOIN.clone(), &address).await { return Err("Insuficient funds for this Transfer Transaction!".to_string()); @@ -148,13 +172,13 @@ impl CollateralClaimTransaction { } } - // Lender claims depend on how many scheduled periods have elapsed, - // excluding the initial funding period. - let periods_elapsed = Self::time_passed( + // A payment becomes overdue only after its entire UTC due date has passed. + let payments_due = overdue_payment_count( contract.unsigned_loan_contract.timestamp, &contract.unsigned_loan_contract.payment_period, - ); - let payments_due = periods_elapsed.saturating_sub(1); + contract.unsigned_loan_contract.payment_number, + as_of_timestamp, + )?; // The lender must wait until the grace period has expired. if payments_due <= contract.unsigned_loan_contract.grace_period as usize { @@ -176,39 +200,4 @@ impl CollateralClaimTransaction { let sign = ""; Ok(sign.to_string()) } - - fn time_passed(start_time: u32, period: &str) -> usize { - // Convert the start timestamp into elapsed payment periods using - // the contract's declared schedule unit. - let start_datetime = Utc - .timestamp_opt(start_time as i64, 0) - .single() - .expect("Invalid start timestamp"); - let current_timestamp = Utc::now().timestamp() as u32; - let current_datetime = Utc - .timestamp_opt(current_timestamp as i64, 0) - .single() - .expect("Invalid current timestamp"); - - let duration = current_datetime - start_datetime; - - match period { - "d" => duration.num_days() as usize, - "w" => (duration.num_days() / 7) as usize, - "m" => Self::calculate_months_between(start_datetime, current_datetime), - _ => 0, - } - } - - fn calculate_months_between(start: DateTime, end: DateTime) -> usize { - // Monthly contracts count whole calendar-month boundaries between - // the contract start date and the current date. - let start_date = start.date_naive(); - let end_date = end.date_naive(); - - let years_diff = end_date.year() - start_date.year(); - let months_diff = end_date.month() as i32 - start_date.month() as i32; - - (years_diff * 12 + months_diff) as usize - } } diff --git a/src/verifications/async_funcs/verify_lender.rs b/src/verifications/async_funcs/verify_lender.rs index 6e0e1b7..8d7bb70 100644 --- a/src/verifications/async_funcs/verify_lender.rs +++ b/src/verifications/async_funcs/verify_lender.rs @@ -1,6 +1,7 @@ use crate::blocks::loans::LoanContractTransaction; use crate::common::asset_names::{ - is_canonical_padded_asset_identifier, is_canonical_padded_asset_or_base, + is_canonical_loan_collateral_or_base, is_canonical_padded_asset_or_base, + loan_collateral_asset_name_or_base, LOAN_COLLATERAL_LENGTH, }; use crate::common::types::{COIN_LENGTH, LENDER_FEE}; use crate::encode; @@ -76,8 +77,12 @@ impl LoanContractTransaction { let loan_amount = self.unsigned_loan_contract.loan_amount; let txfee = self.unsigned_loan_contract.txfee; let loan_coin_length = &loan_coin.to_string().len(); - let collateral = self.unsigned_loan_contract.collateral.clone(); - let collateral_length = collateral.to_string().len(); + let collateral_field = self.unsigned_loan_contract.collateral.clone(); + let collateral = loan_collateral_asset_name_or_base(&collateral_field, &BASECOIN) + .ok_or_else(|| { + "Collateral must be the base coin, a token, or an NFT ending in _xxxxx.".to_string() + })?; + let collateral_length = collateral_field.len(); let collateral_amount = self.unsigned_loan_contract.collateral_amount; // Creating the loan contract has a fixed minimum lender fee. @@ -88,14 +93,12 @@ impl LoanContractTransaction { } // Asset names use the fixed padded coin-length format. - if loan_coin_length != &COIN_LENGTH || collateral_length != COIN_LENGTH { - return Err( - "Coin/Token lengths must be 15 characters each. Consider padding with empty spaces", - ) - .map_err(|s| s.to_string())?; + if loan_coin_length != &COIN_LENGTH || collateral_length != LOAN_COLLATERAL_LENGTH { + return Err("Loan assets must use a 15-byte loan coin and a 21-byte collateral field.") + .map_err(|s| s.to_string())?; } if !is_canonical_padded_asset_or_base(&loan_coin, &BASECOIN) - || !is_canonical_padded_asset_identifier(&collateral) + || !is_canonical_loan_collateral_or_base(&collateral_field, &BASECOIN) { return Err( "Loan assets must use the base coin or canonical alphanumeric asset ids." @@ -112,13 +115,14 @@ impl LoanContractTransaction { return Err("Coin/Token does not exist.".to_string()); } - // Collateral can be a token or an NFT asset, but it must already + // Collateral can be the base coin, a token, or an NFT asset, but it must already // exist before the contract can be created. let nft_tree = "nfts"; - if !(db_bytes_verification(db, tree, &collateral).await + if !(collateral.trim().eq_ignore_ascii_case(BASECOIN.trim()) + || db_bytes_verification(db, tree, &collateral).await || db_bytes_verification(db, nft_tree, &collateral).await) { - return Err("Coin/Token/Nft does not exist.".to_string()); + return Err("Coin/Token/NFT does not exist.".to_string()); } // The lender must be able to fund the loan and fee, while the diff --git a/src/verifications/async_funcs/verify_marketing.rs b/src/verifications/async_funcs/verify_marketing.rs index 6c9bd1d..90818e7 100644 --- a/src/verifications/async_funcs/verify_marketing.rs +++ b/src/verifications/async_funcs/verify_marketing.rs @@ -52,7 +52,7 @@ impl MarketingTransaction { // Ad payloads are fixed-format records with a constrained ad type // and fixed-length keyword and displayed-link fields. - let ad = &self.unsigned_marketing.ad_type; + let ad = self.unsigned_marketing.ad_type.trim(); if ad != "social" && ad != "banner" && ad != "text" { return Err("Wrong ad type. An ad must be type social, banner or text. If text you must add 2 spaces at the end.").map_err(|s| s.to_string())?; } diff --git a/src/verifications/verification_service.rs b/src/verifications/verification_service.rs index 66922df..2667ebc 100644 --- a/src/verifications/verification_service.rs +++ b/src/verifications/verification_service.rs @@ -77,6 +77,7 @@ impl VerificationService { miner: String, transactions: Vec, db: crate::sled::Db, + block_timestamp: u32, ) -> Result, String> { let (reply_tx, reply_rx) = oneshot::channel(); let request = VerificationRequest { @@ -84,6 +85,7 @@ impl VerificationService { miner, transactions, db, + block_timestamp, }, reply_tx, }; @@ -126,6 +128,7 @@ impl VerificationService { &chunk_job.db, stop_flag, balance_tracker, + chunk_job.block_timestamp, ) .await { @@ -167,6 +170,7 @@ impl VerificationService { miner, transactions, db, + block_timestamp, } = job; for chunk in transactions.chunks(chunk_size) { @@ -174,6 +178,7 @@ impl VerificationService { miner: miner.clone(), transactions: chunk.to_vec(), db: db.clone(), + block_timestamp, }); } diff --git a/src/verifications/verification_types.rs b/src/verifications/verification_types.rs index fd3e6ab..536b934 100644 --- a/src/verifications/verification_types.rs +++ b/src/verifications/verification_types.rs @@ -6,6 +6,7 @@ pub struct VerificationJob { pub miner: String, pub transactions: Vec, pub db: Db, + pub block_timestamp: u32, } // VerificationChunkJob is the per-worker slice created from a larger verification job. @@ -13,6 +14,7 @@ pub struct VerificationChunkJob { pub miner: String, pub transactions: Vec, pub db: Db, + pub block_timestamp: u32, } // VerificationChunkResult carries one worker's results plus a flag for chunk-level failure. diff --git a/src/wallets/create_keys.rs b/src/wallets/create_keys.rs index 2e0af26..26ca2a1 100644 --- a/src/wallets/create_keys.rs +++ b/src/wallets/create_keys.rs @@ -46,8 +46,9 @@ impl Wallet { let public_key_bytes = keypair.public_key().to_vec(); if !Self::has_valid_public_key_bytes(&public_key_bytes) { - return Err("FN-DSA public key does not satisfy this network's wallet key rule." - .to_string()); + return Err( + "FN-DSA public key does not satisfy this network's wallet key rule.".to_string(), + ); } Ok(public_key_bytes) diff --git a/src/wallets/load_wallets.rs b/src/wallets/load_wallets.rs index 90851c9..d2b49b4 100644 --- a/src/wallets/load_wallets.rs +++ b/src/wallets/load_wallets.rs @@ -1,49 +1,52 @@ -use crate::common::network_paths_and_settings::block_extension_and_paths; -use crate::decode_image_and_extract_text; -use crate::decrypts; -use crate::from_slice; -use crate::log::error; -use crate::metadata; -use crate::read; -use crate::wallets::structures::{SavedWallet, Wallet}; -use crate::Path; - -impl Wallet { - pub async fn get_wallet_path() -> String { - // Load the active network paths and keep only the wallet file path. - let ( - _network_name, - _padded_base_coin, - _suffix, - _torrent_path, - wallet_path, - _block_path, - _db_path, - _balance_path, - _log_path, - ) = block_extension_and_paths(); - wallet_path - } - - pub async fn private_key_from_wallet( - wallet_path: &Path, - wallet_key: String, - ) -> Result { - // Read the wallet JSON file from disk. - if let Ok(wallet_content) = read(wallet_path).await { - // Deserialize the saved wallet before extracting the encrypted image payload. - let mut wallet: SavedWallet = from_slice(&wallet_content) - .map_err(|e| format!("Deserialization of wallet failed: {e}"))?; - - // Extract the encrypted private key text from the wallet image. - if let Some(encrypted_text) = decode_image_and_extract_text(&wallet.private_key) { +use crate::common::network_paths_and_settings::block_extension_and_paths; +use crate::decode_image_and_extract_text; +use crate::decrypts; +use crate::from_slice; +use crate::log::error; +use crate::metadata; +use crate::read; +use crate::wallets::structures::{SavedWallet, Wallet}; +use crate::Path; + +impl Wallet { + pub async fn get_wallet_path() -> String { + // Load the active network paths and keep only the wallet file path. + let ( + _network_name, + _padded_base_coin, + _suffix, + _torrent_path, + wallet_path, + _block_path, + _db_path, + _balance_path, + _log_path, + ) = block_extension_and_paths(); + wallet_path + } + + pub async fn private_key_from_wallet( + wallet_path: &Path, + wallet_key: String, + ) -> Result { + // Read the wallet JSON file from disk. + if let Ok(wallet_content) = read(wallet_path).await { + // Deserialize the saved wallet before extracting the encrypted image payload. + let mut wallet: SavedWallet = from_slice(&wallet_content) + .map_err(|e| format!("Deserialization of wallet failed: {e}"))?; + + // Extract the encrypted private key text from the wallet image. + if let Some(encrypted_text) = decode_image_and_extract_text(&wallet.private_key) { // Decrypt the private key with the user-provided wallet key. if let Some(decrypted_private_key) = decrypts(&encrypted_text, Some(&wallet_key)) { - let regenerated_public_key = Self::regenerate_public_key(&decrypted_private_key)?; + let regenerated_public_key = + Self::regenerate_public_key(&decrypted_private_key)?; let regenerated_public_key_hex = crate::encode(regenerated_public_key.clone()); if regenerated_public_key_hex != wallet.public_key { - return Err("Wallet public key does not match the decrypted private key." - .to_string()); + return Err( + "Wallet public key does not match the decrypted private key." + .to_string(), + ); } let regenerated_short_address_bytes = @@ -53,10 +56,9 @@ impl Wallet { .to_string() })?; let regenerated_short_address = - Self::bytes_to_short_address(®enerated_short_address_bytes) - .ok_or_else(|| { - "Wallet public key produced an invalid short address.".to_string() - })?; + Self::bytes_to_short_address(®enerated_short_address_bytes).ok_or_else( + || "Wallet public key produced an invalid short address.".to_string(), + )?; if regenerated_short_address != wallet.short_address { return Err( "Wallet short address does not match the decrypted private key." @@ -66,50 +68,50 @@ impl Wallet { // Replace the saved encrypted image payload with the decrypted private key in memory. wallet.private_key = decrypted_private_key; - - // Attach the encryption key so the full wallet can be reused by callers. - let full_wallet = Wallet { - saved: wallet, - encryption_key: wallet_key.clone(), - }; - - // Return the loaded wallet when image extraction and decryption both succeed. - Ok(full_wallet) - } else { - error!("Decryption of private key failed."); - Err("Decryption of private key failed.".into()) - } - } else { - error!("Decryption of image failed."); - Err("Decryption of image failed.".into()) - } - } else { - error!("Wallet path did not exist"); - Err("Wallet path did not exist".into()) - } - } - - async fn load_wallet(wallet_path: &Path, wallet_key: String) -> Result { - // Load an existing wallet when the file exists. - if metadata(wallet_path).await.is_ok() { - Self::private_key_from_wallet(wallet_path, wallet_key).await - } else { - // Create, save, and load a new wallet when no wallet file exists. - Self::generate_saved_struct(wallet_path, wallet_key).await - } - } - - pub async fn try_obtain_wallet( - wallet_key: String, - path: Option<&str>, - ) -> Result { - // Use a caller-provided path when supplied, otherwise use the active network wallet path. - let wallet_path = match path { - Some(p) => p.to_string(), - None => Wallet::get_wallet_path().await, - }; - - // Load or create the wallet and return any failure to the caller. - Self::load_wallet(Path::new(&wallet_path), wallet_key).await - } -} + + // Attach the encryption key so the full wallet can be reused by callers. + let full_wallet = Wallet { + saved: wallet, + encryption_key: wallet_key.clone(), + }; + + // Return the loaded wallet when image extraction and decryption both succeed. + Ok(full_wallet) + } else { + error!("Decryption of private key failed."); + Err("Decryption of private key failed.".into()) + } + } else { + error!("Decryption of image failed."); + Err("Decryption of image failed.".into()) + } + } else { + error!("Wallet path did not exist"); + Err("Wallet path did not exist".into()) + } + } + + async fn load_wallet(wallet_path: &Path, wallet_key: String) -> Result { + // Load an existing wallet when the file exists. + if metadata(wallet_path).await.is_ok() { + Self::private_key_from_wallet(wallet_path, wallet_key).await + } else { + // Create, save, and load a new wallet when no wallet file exists. + Self::generate_saved_struct(wallet_path, wallet_key).await + } + } + + pub async fn try_obtain_wallet( + wallet_key: String, + path: Option<&str>, + ) -> Result { + // Use a caller-provided path when supplied, otherwise use the active network wallet path. + let wallet_path = match path { + Some(p) => p.to_string(), + None => Wallet::get_wallet_path().await, + }; + + // Load or create the wallet and return any failure to the caller. + Self::load_wallet(Path::new(&wallet_path), wallet_key).await + } +}