rpc changes for asset reporting

This commit is contained in:
viraladmin 2026-06-27 10:28:13 -06:00
parent fe83bd50dc
commit 61bc832ff4
38 changed files with 1436 additions and 288 deletions

View File

@ -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<String> {
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<u32, String> {
// 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;
};

View File

@ -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()

View File

@ -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<Vec<u8>> {
// 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,

View File

@ -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<String> {
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::<u32>().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<String> {
let asset_name = loan_collateral_asset_name(input)?;
if asset_name.trim_end().len() > LOAN_COLLATERAL_LENGTH {
return None;
}
Some(format!(
"{:<LOAN_COLLATERAL_LENGTH$}",
asset_name.trim_end()
))
}
pub fn is_canonical_loan_collateral(input: &str) -> 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<String> {
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<String> {
if input.trim().eq_ignore_ascii_case(base_coin.trim()) {
return Some(format!("{:<LOAN_COLLATERAL_LENGTH$}", base_coin.trim()));
}
padded_loan_collateral_input(input)
}
pub fn is_canonical_loan_collateral_or_base(input: &str, base_coin: &str) -> 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
);
}
}

148
src/common/loan_schedule.rs Normal file
View File

@ -0,0 +1,148 @@
use chrono::{Datelike, Duration, NaiveDate, TimeZone, Utc};
fn last_day_of_month(year: i32, month: u32) -> Result<u32, String> {
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<NaiveDate, String> {
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<NaiveDate, String> {
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<usize, String> {
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
);
}
}

View File

@ -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;

View File

@ -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(),

View File

@ -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_{}",

View File

@ -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);

View File

@ -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();

View File

@ -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)),
}
}

View File

@ -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;

View File

@ -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,
);

View File

@ -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_{}",

View File

@ -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(

View File

@ -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<Vec<u8>, 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<Option<u32>, 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::<u32>()
.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::<Vec<_>>();
for key in keys {
tree.remove(key)
.map_err(|err| format!("Failed to remove marketing campaign index: {err}"))?;
}
Ok(())
}

View File

@ -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)
}

View File

@ -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;

View File

@ -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;

View File

@ -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<Utc>, end: DateTime<Utc>) -> 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<u8>, db: &Db) -> Result<String, String
.sum();
let total_to_be_paidback = loan.payment_amount * loan.payment_number as u64;
let remaining_balance = total_to_be_paidback.saturating_sub(total_value_paid);
let pending_value = get_pending_payments_for_contract(&contract_hash)
.await
.map_err(|err| format!("error: Failed to read pending loan payments: {err}"))?;
let remaining_after_pending = remaining_balance.saturating_sub(pending_value);
let periods_elapsed = time_passed(loan.timestamp, &loan.payment_period);
let payments_due = periods_elapsed
.saturating_sub(1)
.min(loan.payment_number as usize);
let payments_due = overdue_payment_count(
loan.timestamp,
&loan.payment_period,
loan.payment_number,
Utc::now().timestamp() as u32,
)
.map_err(|err| format!("error: Failed to calculate loan schedule: {err}"))?;
let should_have_paid = payments_due as u64 * loan.payment_amount;
// A contract is inactive once collateral was claimed; otherwise it is
@ -206,24 +181,35 @@ async fn build_contract_summary(hash: Vec<u8>, db: &Db) -> Result<String, String
.unwrap_or_default();
let mut output = format!(
"contract={}\nstatus={}\ncreation_date={}\nlender={}\nborrower={}\ncoin_loaned={}\nloaned_count={:.8}\ncollateral={}\ncollateral_count={:.8}\npayment_type={}\nnumber_of_payments={}\npayment_value={:.8}\nmax_late_payments={}\nmax_late_value={:.8}\ntotal_payments_made={}\ntotal_value_paid={:.8}\nremaining_balance={:.8}\ncollateral_claimed_by={}\n",
"contract={}\nstatus={}\ncreation_date={}\nstart_timestamp={}\nlender={}\nborrower={}\ncoin_loaned={}\nloaned_count={:.8}\nloaned_count_atomic={}\ncollateral={}\ncollateral_count={:.8}\ncollateral_count_atomic={}\npayment_type={}\nnumber_of_payments={}\npayment_value={:.8}\npayment_value_atomic={}\nmax_late_payments={}\nmax_late_value={:.8}\nmax_late_value_atomic={}\ntotal_payments_made={}\ntotal_value_paid={:.8}\ntotal_value_paid_atomic={}\npending_value={:.8}\npending_value_atomic={}\nremaining_balance={:.8}\nremaining_balance_atomic={}\nremaining_after_pending={:.8}\nremaining_after_pending_atomic={}\ncollateral_claimed_by={}\n",
contract_hash,
status,
format_local_date(loan.timestamp),
loan.timestamp,
loan.lender.trim(),
loan.borrower.trim(),
loan.loan_coin.trim(),
format_amount(loan.loan_amount),
loan.loan_amount,
loan.collateral.trim(),
format_amount(loan.collateral_amount),
loan.collateral_amount,
payment_type_name(&loan.payment_period),
loan.payment_number,
format_amount(loan.payment_amount),
loan.payment_amount,
loan.grace_period,
format_amount(loan.max_late_value),
loan.max_late_value,
payments.len(),
format_amount(total_value_paid),
total_value_paid,
format_amount(pending_value),
pending_value,
format_amount(remaining_balance),
remaining_balance,
format_amount(remaining_after_pending),
remaining_after_pending,
collateral_claimed_by,
);
output.push_str(&payments_output);

View File

@ -0,0 +1,279 @@
use crate::blocks::marketing::MarketingTransaction;
use crate::records::memory::mempool::db_client;
use crate::records::record_chain::marketing_campaign_index::MARKETING_CAMPAIGN_INDEX_TREE;
use crate::rpc::commands::transaction_by_txid::transaction_bytes_with_block;
use crate::rpc::responses::RpcResponse;
use crate::sled::Db;
use crate::wallets::structures::Wallet;
use std::collections::HashSet;
use std::ops::Bound;
const TXID_LENGTH: usize = 32;
const MAX_MARKETING_CAMPAIGN_ROWS: usize = 10_000;
const STATUS_PENDING: u8 = 0;
const STATUS_MINED: u8 = 1;
#[derive(Clone)]
struct MarketingCampaignRow {
status: u8,
txid: Vec<u8>,
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<Vec<u8>> {
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<Vec<u8>>, Bound<Vec<u8>>) {
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<u8>,
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<Vec<MarketingCampaignRow>, 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<u8> = 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<Vec<u8>>,
) -> Result<Vec<MarketingCampaignRow>, 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<u8>, 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<u8>,
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::<Vec<_>>()
};
encode_response(totals, &paged_rows)
}

View File

@ -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;

View File

@ -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);

View File

@ -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(&current_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)
}

View File

@ -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<Mutex<()>> = 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<u8>, map: Arc<Mutex<Command>>) {
// 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) => {

View File

@ -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,

View File

@ -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::<Vec<_>>();
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::<u64>()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Invalid campaign id"))?;
let skip = parts[2].parse::<u32>().map_err(|_| {
io::Error::new(io::ErrorKind::InvalidInput, "Invalid campaign history skip")
})?;
let limit = parts[3].parse::<u32>().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);
}
}

View File

@ -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<String>,
debits: HashMap<(String, String), u64>,
available: HashMap<(String, String), u64>,
loan_payment: Option<LoanPaymentReservation>,
}
struct LoanPaymentReservation {
contract_hash: String,
payment_amount: u64,
confirmed_paid: u64,
contract_total: u64,
}
pub struct BlockBalanceTracker {
transaction_hashes: HashSet<String>,
transaction_signatures: HashSet<String>,
reserved: HashMap<(String, String), u64>,
loan_payments_reserved: HashMap<String, u64>,
}
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"));
}
}

View File

@ -15,6 +15,7 @@ pub async fn verify_transactions(
db: &Db,
stop_flag: Arc<AtomicBool>,
balance_tracker: Arc<Mutex<BlockBalanceTracker>>,
block_timestamp: u32,
) -> Result<Vec<String>, String> {
let mut results: Vec<String> = 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<Mutex<BlockBalanceTracker>>,
block_timestamp: u32,
) -> Result<String, String> {
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);

View File

@ -102,6 +102,7 @@ impl Block {
self.transactions.clone(),
db.clone(),
verification_service,
timestamp,
)
.await?;
@ -183,11 +184,12 @@ impl Block {
transactions: Vec<Transaction>,
db: crate::sled::Db,
verification_service: Arc<VerificationService>,
block_timestamp: u32,
) -> Result<Vec<String>, 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
}

View File

@ -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<String, String> {
self.verify_internal(db, true).await
}
pub async fn verify_for_block(&self, db: &Db) -> Result<String, String> {
self.verify_internal(db, false).await
}
async fn verify_internal(
&self,
db: &Db,
check_pending_payments: bool,
) -> Result<String, String> {
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.

View File

@ -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<String, String> {
self.verify_internal(db, Utc::now().timestamp() as u32, true)
.await
}
pub async fn verify_at(&self, db: &Db, as_of_timestamp: u32) -> Result<String, String> {
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<String, String> {
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<Utc>, end: DateTime<Utc>) -> 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
}
}

View File

@ -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

View File

@ -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())?;
}

View File

@ -77,6 +77,7 @@ impl VerificationService {
miner: String,
transactions: Vec<Transaction>,
db: crate::sled::Db,
block_timestamp: u32,
) -> Result<Vec<String>, 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,
});
}

View File

@ -6,6 +6,7 @@ pub struct VerificationJob {
pub miner: String,
pub transactions: Vec<Transaction>,
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<Transaction>,
pub db: Db,
pub block_timestamp: u32,
}
// VerificationChunkResult carries one worker's results plus a flag for chunk-level failure.

View File

@ -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)

View File

@ -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<Wallet, String> {
// 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<Wallet, String> {
// 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(&regenerated_short_address_bytes)
.ok_or_else(|| {
"Wallet public key produced an invalid short address.".to_string()
})?;
Self::bytes_to_short_address(&regenerated_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<Wallet, String> {
// 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<Wallet, String> {
// 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<Wallet, String> {
// 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<Wallet, String> {
// 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
}
}