From 9381914a2ce2a304b50f54d25d6a0b1ba5516689 Mon Sep 17 00:00:00 2001 From: viraladmin <00purple@gmail.com> Date: Fri, 24 Jul 2026 16:12:25 -0600 Subject: [PATCH] added governance rules and fractional NFT updates --- src/bin/create_activation_vote_tx.rs | 124 +++ src/bin/create_nft_tx.rs | 16 + src/bin/create_proposal_key_tx.rs | 82 ++ src/bin/create_proposal_vote_tx.rs | 86 ++ src/bin/lookup_governance_proposal.rs | 52 + src/bin/lookup_nft_list.rs | 15 +- src/blocks/block.rs | 12 + src/blocks/mod.rs | 3 + src/blocks/nft.rs | 33 +- src/common/governance.rs | 535 ++++++++++ src/common/mod.rs | 1 + src/common/nft_assets.rs | 59 ++ src/common/types.rs | 17 + src/orphans/undo_block_transactions.rs | 32 + src/orphans/undo_transactions/mod.rs | 3 + .../undo_transactions/restore_mempool.rs | 58 ++ .../undo_transactions/undo_activation_vote.rs | 71 ++ src/orphans/undo_transactions/undo_burn.rs | 30 +- .../undo_transactions/undo_create_nft.rs | 16 +- .../undo_transactions/undo_proposal_key.rs | 46 + .../undo_transactions/undo_proposal_vote.rs | 60 ++ src/records/memory/mempool/lookups.rs | 78 ++ src/records/memory/mempool/mod.rs | 36 +- src/records/memory/mempool/processing.rs | 62 ++ src/records/memory/mempool/schema.rs | 78 ++ src/records/memory/mempool/selection.rs | 300 +++++- src/records/memory/network_mapping/queries.rs | 113 +++ .../record_chain/activation_vote_tx.rs | 88 ++ src/records/record_chain/mod.rs | 3 + src/records/record_chain/nft_tx.rs | 36 +- .../record_chain/parse_transactions.rs | 41 + src/records/record_chain/pending_effects.rs | 954 +++++++++++++++++- src/records/record_chain/proposal_key_tx.rs | 57 ++ src/records/record_chain/proposal_vote_tx.rs | 81 ++ src/records/record_chain/save.rs | 10 + .../unpack_block/load_by_binary_data.rs | 42 +- .../unpack_block/load_by_block_number.rs | 42 +- src/rpc/client/governance_state_sync.rs | 345 +++++++ src/rpc/client/handshake_processing.rs | 15 + src/rpc/client/mod.rs | 1 + src/rpc/command_maps.rs | 8 + src/rpc/commands/address_history.rs | 5 + src/rpc/commands/governance_proposal.rs | 381 +++++++ src/rpc/commands/governance_state_sync.rs | 31 + src/rpc/commands/mod.rs | 2 + src/rpc/commands/nft_list.rs | 14 +- src/rpc/commands/tx_submit.rs | 17 +- src/rpc/server/handshake.rs | 9 + src/rpc/server/rpc_command_loop.rs | 33 + .../connections/sending_request.rs | 54 +- src/standalone_tools/governance.rs | 86 ++ src/standalone_tools/mod.rs | 1 + src/standalone_tools/transaction_creator.rs | 169 +++- .../async_funcs/checks/block_balance.rs | 42 + src/verifications/async_funcs/mod.rs | 3 + src/verifications/async_funcs/transactions.rs | 56 + .../async_funcs/verify_activation_vote.rs | 142 +++ src/verifications/async_funcs/verify_burn.rs | 17 +- .../async_funcs/verify_create_nft.rs | 21 +- .../async_funcs/verify_lender.rs | 8 + .../async_funcs/verify_proposal_key.rs | 70 ++ .../async_funcs/verify_proposal_vote.rs | 125 +++ src/verifications/async_funcs/verify_swap.rs | 41 +- .../async_funcs/verify_transfer.rs | 22 +- 64 files changed, 4967 insertions(+), 123 deletions(-) create mode 100644 src/bin/create_activation_vote_tx.rs create mode 100644 src/bin/create_proposal_key_tx.rs create mode 100644 src/bin/create_proposal_vote_tx.rs create mode 100644 src/bin/lookup_governance_proposal.rs create mode 100644 src/common/governance.rs create mode 100644 src/orphans/undo_transactions/undo_activation_vote.rs create mode 100644 src/orphans/undo_transactions/undo_proposal_key.rs create mode 100644 src/orphans/undo_transactions/undo_proposal_vote.rs create mode 100644 src/records/record_chain/activation_vote_tx.rs create mode 100644 src/records/record_chain/proposal_key_tx.rs create mode 100644 src/records/record_chain/proposal_vote_tx.rs create mode 100644 src/rpc/client/governance_state_sync.rs create mode 100644 src/rpc/commands/governance_proposal.rs create mode 100644 src/rpc/commands/governance_state_sync.rs create mode 100644 src/standalone_tools/governance.rs create mode 100644 src/verifications/async_funcs/verify_activation_vote.rs create mode 100644 src/verifications/async_funcs/verify_proposal_key.rs create mode 100644 src/verifications/async_funcs/verify_proposal_vote.rs diff --git a/src/bin/create_activation_vote_tx.rs b/src/bin/create_activation_vote_tx.rs new file mode 100644 index 0000000..d606527 --- /dev/null +++ b/src/bin/create_activation_vote_tx.rs @@ -0,0 +1,124 @@ +use contractless::blocks::activation_vote::{ActivationVote, UnsignedActivationVote}; +use contractless::common::cli_prompts::{prompt_path, prompt_visible}; +use contractless::common::governance::{hash_governance_document, normalize_development_location}; +use contractless::common::types::{ACTIVATION_VOTE_FEE, ACTIVATION_VOTE_TYPE}; +use contractless::standalone_tools::governance::{ + load_wallet, parse_fee, parse_vote, prompt_fee, validate_hash, write_transaction, +}; +use contractless::{env, json, Utc}; + +#[tokio::main] +async fn main() { + let args: Vec = env::args().collect(); + if args.len() > 6 { + println!("Usage: ./create_activation_vote_tx [txfee]"); + return; + } + let proposal_input = match args.get(1) { + Some(value) => value.clone(), + None => prompt_visible("Please enter the approved proposal key: ").await, + }; + let proposal_key = match validate_hash(&proposal_input, "Proposal key") { + Ok(hash) => hash, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + let implementation_path = if let Some(path) = args.get(2) { + path.clone() + } else { + match prompt_path("Please enter the path to the implementation document: ") { + Ok(path) => path, + Err(err) => { + eprintln!("Could not read implementation path: {err}"); + return; + } + } + }; + let document = match tokio::fs::read(&implementation_path).await { + Ok(document) => document, + Err(err) => { + eprintln!("Could not read implementation document: {err}"); + return; + } + }; + let location_input = match args.get(3) { + Some(value) => value.clone(), + None => { + prompt_visible("Please enter the repository location of the implementation document: ") + .await + } + }; + let development_location = match normalize_development_location(location_input.trim()) { + Ok(location) => location, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + let vote_input = match args.get(4) { + Some(value) => value.clone(), + None => prompt_visible("Vote to activate this implementation (yes/no): ").await, + }; + let vote = match parse_vote(&vote_input) { + Ok(vote) => vote, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + let txfee = match args.get(5) { + Some(value) => match parse_fee(value, ACTIVATION_VOTE_FEE) { + Ok(fee) => fee, + Err(err) => { + eprintln!("{err}"); + return; + } + }, + None => prompt_fee("activation-vote transaction", ACTIVATION_VOTE_FEE).await, + }; + let wallet = match load_wallet().await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + let timestamp = Utc::now().timestamp() as u32; + let development_hash = hash_governance_document(&document); + let unsigned = UnsignedActivationVote::new( + ACTIVATION_VOTE_TYPE, + timestamp, + &proposal_key, + &development_hash, + &development_location, + &wallet.saved.short_address, + vote, + txfee, + ) + .await; + let transaction = match ActivationVote::new(unsigned, &wallet.saved.private_key).await { + Ok(transaction) => transaction, + Err(err) => { + eprintln!("Could not sign activation vote: {err}"); + return; + } + }; + let hash = transaction.unsigned_activationvote.hash().await; + let output = json!({ + "txtype": ACTIVATION_VOTE_TYPE, + "timestamp": timestamp, + "proposal_key": proposal_key, + "development_hash": development_hash, + "development_location": development_location, + "address": wallet.saved.short_address, + "vote": vote, + "txfee": txfee, + "hash": hash, + "signature": transaction.signature, + }); + if let Err(err) = write_transaction(&hash, &output).await { + eprintln!("{err}"); + } +} diff --git a/src/bin/create_nft_tx.rs b/src/bin/create_nft_tx.rs index f8f8a20..6cec4bf 100644 --- a/src/bin/create_nft_tx.rs +++ b/src/bin/create_nft_tx.rs @@ -32,6 +32,20 @@ async fn main() { 0 }; + // Collections are always indivisible. Standalone NFTs/RWAs can opt + // into fractional ownership at creation and cannot change it later. + let ownership_type = if series == 1 { + 0 + } else { + let ownership_data = + prompt_visible("Ownership type (indivisible/fractional, default indivisible): ").await; + if ownership_data.trim().eq_ignore_ascii_case("fractional") { + 1 + } else { + 0 + } + }; + // get user input ticker let nft_ticker_name = prompt_visible("Please enter the name for your NFT. This can be 3 to 15 characters and must be 100% unique. First come first serve: ").await; let Some(nft_name) = padded_normalized_asset_input(nft_ticker_name.trim()) else { @@ -100,6 +114,7 @@ async fn main() { timestamp, address.trim_matches('"'), series, + ownership_type, &nft_name, &item_ipfs, count, @@ -125,6 +140,7 @@ async fn main() { "timestamp": timestamp, "creator": address.trim_matches('"'), "series": series, + "ownership_type": ownership_type, "nft_name": nft_name, "item_ipfs": item_ipfs, "count": count, diff --git a/src/bin/create_proposal_key_tx.rs b/src/bin/create_proposal_key_tx.rs new file mode 100644 index 0000000..fdeeafd --- /dev/null +++ b/src/bin/create_proposal_key_tx.rs @@ -0,0 +1,82 @@ +use contractless::blocks::proposal_key::{ProposalKey, UnsignedProposalKey}; +use contractless::common::cli_prompts::prompt_path; +use contractless::common::governance::hash_governance_document; +use contractless::common::types::{PROPOSAL_KEY_FEE, PROPOSAL_KEY_TYPE}; +use contractless::standalone_tools::governance::{ + load_wallet, parse_fee, prompt_fee, write_transaction, +}; +use contractless::{env, json, Utc}; + +#[tokio::main] +async fn main() { + let args: Vec = env::args().collect(); + if args.len() > 3 { + println!("Usage: ./create_proposal_key_tx [txfee]"); + return; + } + let proposal_path = if let Some(path) = args.get(1) { + path.clone() + } else { + match prompt_path("Please enter the path to the finalized CLP document: ") { + Ok(path) => path, + Err(err) => { + eprintln!("Could not read proposal path: {err}"); + return; + } + } + }; + let document = match tokio::fs::read(&proposal_path).await { + Ok(document) => document, + Err(err) => { + eprintln!("Could not read proposal document: {err}"); + return; + } + }; + let txfee = match args.get(2) { + Some(value) => match parse_fee(value, PROPOSAL_KEY_FEE) { + Ok(fee) => fee, + Err(err) => { + eprintln!("{err}"); + return; + } + }, + None => prompt_fee("proposal-key transaction", PROPOSAL_KEY_FEE).await, + }; + let wallet = match load_wallet().await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + let timestamp = Utc::now().timestamp() as u32; + let proposal_hash = hash_governance_document(&document); + let unsigned = UnsignedProposalKey::new( + PROPOSAL_KEY_TYPE, + timestamp, + &proposal_hash, + &wallet.saved.short_address, + txfee, + ) + .await; + let transaction = match ProposalKey::new(unsigned, &wallet.saved.private_key).await { + Ok(transaction) => transaction, + Err(err) => { + eprintln!("Could not sign proposal-key transaction: {err}"); + return; + } + }; + let hash = transaction.unsigned_proposalkey.hash().await; + let output = json!({ + "txtype": PROPOSAL_KEY_TYPE, + "timestamp": timestamp, + "proposal_hash": proposal_hash, + "address": wallet.saved.short_address, + "txfee": txfee, + "hash": hash, + "signature": transaction.signature, + }); + if let Err(err) = write_transaction(&hash, &output).await { + eprintln!("{err}"); + } +} diff --git a/src/bin/create_proposal_vote_tx.rs b/src/bin/create_proposal_vote_tx.rs new file mode 100644 index 0000000..687b26f --- /dev/null +++ b/src/bin/create_proposal_vote_tx.rs @@ -0,0 +1,86 @@ +use contractless::blocks::proposal_vote::{ProposalVote, UnsignedProposalVote}; +use contractless::common::cli_prompts::prompt_visible; +use contractless::common::types::{PROPOSAL_VOTE_FEE, PROPOSAL_VOTE_TYPE}; +use contractless::standalone_tools::governance::{ + load_wallet, parse_fee, parse_vote, prompt_fee, validate_hash, write_transaction, +}; +use contractless::{env, json, Utc}; + +#[tokio::main] +async fn main() { + let args: Vec = env::args().collect(); + if args.len() > 4 { + println!("Usage: ./create_proposal_vote_tx [txfee]"); + return; + } + let proposal_input = match args.get(1) { + Some(value) => value.clone(), + None => prompt_visible("Please enter the proposal key (64 hex characters): ").await, + }; + let proposal_key = match validate_hash(&proposal_input, "Proposal key") { + Ok(hash) => hash, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + let vote_input = match args.get(2) { + Some(value) => value.clone(), + None => prompt_visible("Vote on this proposal (yes/no): ").await, + }; + let vote = match parse_vote(&vote_input) { + Ok(vote) => vote, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + let txfee = match args.get(3) { + Some(value) => match parse_fee(value, PROPOSAL_VOTE_FEE) { + Ok(fee) => fee, + Err(err) => { + eprintln!("{err}"); + return; + } + }, + None => prompt_fee("proposal-vote transaction", PROPOSAL_VOTE_FEE).await, + }; + let wallet = match load_wallet().await { + Ok(wallet) => wallet, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + let timestamp = Utc::now().timestamp() as u32; + let unsigned = UnsignedProposalVote::new( + PROPOSAL_VOTE_TYPE, + timestamp, + &proposal_key, + &wallet.saved.short_address, + vote, + txfee, + ) + .await; + let transaction = match ProposalVote::new(unsigned, &wallet.saved.private_key).await { + Ok(transaction) => transaction, + Err(err) => { + eprintln!("Could not sign proposal vote: {err}"); + return; + } + }; + let hash = transaction.unsigned_proposalvote.hash().await; + let output = json!({ + "txtype": PROPOSAL_VOTE_TYPE, + "timestamp": timestamp, + "proposal_key": proposal_key, + "address": wallet.saved.short_address, + "vote": vote, + "txfee": txfee, + "hash": hash, + "signature": transaction.signature, + }); + if let Err(err) = write_transaction(&hash, &output).await { + eprintln!("{err}"); + } +} diff --git a/src/bin/lookup_governance_proposal.rs b/src/bin/lookup_governance_proposal.rs new file mode 100644 index 0000000..8954c78 --- /dev/null +++ b/src/bin/lookup_governance_proposal.rs @@ -0,0 +1,52 @@ +use contractless::common::cli_prompts::{prompt_hidden_nonempty, prompt_wallet_path}; +use contractless::common::network_startup::get_connections; +use contractless::records::memory::response_channels::generate_uid; +use contractless::standalone_tools::connections::handshake; +use contractless::standalone_tools::governance::validate_hash; +use contractless::{env, Value}; + +#[tokio::main] +async fn main() { + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("Usage: ./lookup_governance_proposal "); + return; + } + let proposal_key = match validate_hash(&args[1], "Proposal key") { + Ok(hash) => hash, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + let wallet_path = prompt_wallet_path().await; + let encryption_key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + for connection in get_connections().await { + let Ok(address) = connection.parse() else { + continue; + }; + let result = handshake::connect_and_handshake( + address, + proposal_key.clone(), + 57, + handshake::HandshakeWallet::WalletKey { + encryption_key: encryption_key.clone(), + wallet_path: wallet_path.clone(), + }, + generate_uid(), + ) + .await; + if let Ok(response) = result { + match serde_json::from_slice::(&response) { + Ok(json) => println!("{}", serde_json::to_string_pretty(&json).unwrap()), + Err(_) => println!("{}", String::from_utf8_lossy(&response)), + } + return; + } + } + eprintln!("failed to connect"); +} diff --git a/src/bin/lookup_nft_list.rs b/src/bin/lookup_nft_list.rs index 7289f9e..ee7b3e6 100644 --- a/src/bin/lookup_nft_list.rs +++ b/src/bin/lookup_nft_list.rs @@ -15,14 +15,16 @@ fn decode_nft_list(response: &[u8]) -> Option { return Some("{\n \"nfts\": {}\n}".to_string()); } - // Each NFT-list row is 32 bytes hash, 15 bytes name, and 4 bytes series. - if response.len() % 51 != 0 { + // Each row contains the origin hash, name, series, ownership rule, + // and current circulating supply. + const NFT_LIST_ROW_BYTES: usize = 32 + 15 + 4 + 1 + 8; + if response.len() % NFT_LIST_ROW_BYTES != 0 { return None; } let mut grouped = Map::new(); let mut offset = 0; - while offset + 51 <= response.len() { + while offset + NFT_LIST_ROW_BYTES <= response.len() { // Group entries under their genesis hash so a collection can contain multiple series items. let hash = binary_to_string(response[offset..offset + 32].to_vec()) .trim() @@ -31,6 +33,9 @@ fn decode_nft_list(response: &[u8]) -> Option { .trim() .to_string(); let series = u32::from_le_bytes(response[offset + 47..offset + 51].try_into().ok()?); + let ownership_type = response[offset + 51]; + let remaining_supply = + u64::from_le_bytes(response[offset + 52..offset + 60].try_into().ok()?); if !hash.is_empty() && !nft_name.is_empty() { let entry = grouped @@ -41,11 +46,13 @@ fn decode_nft_list(response: &[u8]) -> Option { items.push(json!({ "nft_name": nft_name, "series": series, + "ownership_type": ownership_type, + "remaining_supply": remaining_supply, })); } } - offset += 51; + offset += NFT_LIST_ROW_BYTES; } let output = json!({ diff --git a/src/blocks/block.rs b/src/blocks/block.rs index 6d4ba6f..300a39c 100644 --- a/src/blocks/block.rs +++ b/src/blocks/block.rs @@ -351,6 +351,18 @@ impl Block { let tx_bytes = storage_key_tx.to_bytes().await?; buffer.extend_from_slice(&tx_bytes); } + Transaction::ProposalKey(proposal_key_tx) => { + let tx_bytes = proposal_key_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::ProposalVote(proposal_vote_tx) => { + let tx_bytes = proposal_vote_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } + Transaction::ActivationVote(activation_vote_tx) => { + let tx_bytes = activation_vote_tx.to_bytes().await?; + buffer.extend_from_slice(&tx_bytes); + } Transaction::StorageBool(storage_tx) => { let tx_bytes = storage_tx.to_bytes().await?; buffer.extend_from_slice(&tx_bytes); diff --git a/src/blocks/mod.rs b/src/blocks/mod.rs index c2c65f0..1129196 100644 --- a/src/blocks/mod.rs +++ b/src/blocks/mod.rs @@ -1,3 +1,4 @@ +pub mod activation_vote; pub mod block; pub mod burn; pub mod collateral; @@ -8,6 +9,8 @@ pub mod loan_payment; pub mod loans; pub mod marketing; pub mod nft; +pub mod proposal_key; +pub mod proposal_vote; pub mod rewards; pub mod storage_key; pub mod storage_values; diff --git a/src/blocks/nft.rs b/src/blocks/nft.rs index 1362c1e..135bf1e 100644 --- a/src/blocks/nft.rs +++ b/src/blocks/nft.rs @@ -8,22 +8,23 @@ use crate::Serialize; use crate::{decode, encode}; use crate::{AsyncReadExt, AsyncWriteExt}; -#[derive(Debug, Serialize, Clone)] // 255 bytes +#[derive(Debug, Serialize, Clone)] // 256 bytes pub struct UnsignedCreateNftTransaction { - pub txtype: u8, // 1 byte transaction type, should be 4 - pub time: u32, // 4 bytes transaction timestamp - pub creator: String, // 22 bytes creator short address - pub series: u8, // 1 byte 0 for single NFT, 1 for series - pub nft_name: String, // 15 bytes NFT or collection name padded with spaces - pub item_ipfs: String, // 100 bytes padded CID string - pub count: u32, // 4 bytes 1 for single NFT, otherwise series item count - pub desc: String, // 100 bytes description padded with spaces - pub txfee: u64, // 8 bytes transaction fee + pub txtype: u8, // 1 byte transaction type, should be 4 + pub time: u32, // 4 bytes transaction timestamp + pub creator: String, // 22 bytes creator short address + pub series: u8, // 1 byte 0 for single NFT, 1 for series + pub ownership_type: u8, // 1 byte 0 for indivisible, 1 for fractional ownership + pub nft_name: String, // 15 bytes NFT or collection name padded with spaces + pub item_ipfs: String, // 100 bytes padded CID string + pub count: u32, // 4 bytes 1 for single NFT, otherwise series item count + pub desc: String, // 100 bytes description padded with spaces + pub txfee: u64, // 8 bytes transaction fee } -#[derive(Debug, Serialize, Clone)] // 921 bytes +#[derive(Debug, Serialize, Clone)] // 922 bytes pub struct CreateNftTransaction { - pub unsigned_create_nft: UnsignedCreateNftTransaction, // 255 bytes + pub unsigned_create_nft: UnsignedCreateNftTransaction, // 256 bytes pub signature: String, // 666 bytes signature of hash } @@ -32,6 +33,7 @@ impl CreateNftTransaction { + 4 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 1 + + 1 + 15 + 100 + 4 @@ -48,6 +50,7 @@ impl UnsignedCreateNftTransaction { time: u32, creator: &str, series: u8, + ownership_type: u8, nft_name: &str, item_ipfs: &str, count: u32, @@ -59,6 +62,7 @@ impl UnsignedCreateNftTransaction { time, creator: creator.to_string(), series, + ownership_type, nft_name: nft_name.to_string(), item_ipfs: item_ipfs.to_string(), count, @@ -132,6 +136,9 @@ impl CreateNftTransaction { cursor .write_all(&self.unsigned_create_nft.series.to_le_bytes()) .await?; + cursor + .write_all(&self.unsigned_create_nft.ownership_type.to_le_bytes()) + .await?; cursor .write_all(self.unsigned_create_nft.nft_name.as_bytes()) .await?; @@ -171,6 +178,7 @@ impl CreateNftTransaction { // Decode series flag, name, CID, count, description, fee, and signature. let series = cursor.read_u8().await?; + let ownership_type = cursor.read_u8().await?; let mut nft_name_bytes = vec![0; 15]; cursor.read_exact(&mut nft_name_bytes).await?; @@ -198,6 +206,7 @@ impl CreateNftTransaction { time, creator, series, + ownership_type, nft_name, item_ipfs, count, diff --git a/src/common/governance.rs b/src/common/governance.rs new file mode 100644 index 0000000..15b51b8 --- /dev/null +++ b/src/common/governance.rs @@ -0,0 +1,535 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +use crate::common::skein::skein_256_hash_bytes; +use crate::decode; +use crate::wallets::structures::Wallet; + +pub const GOVERNANCE_APPROVAL_PERCENT: u64 = 85; +pub const GOVERNANCE_PERCENT_SCALE: u64 = 100; +pub const GOVERNANCE_MIN_NODE_AGE_MILLIS: u64 = 60 * 60 * 1_000; +pub const GOVERNANCE_MIN_BLOCKS_MINED: u8 = 100; +pub const GOVERNANCE_OFFLINE_GRACE_BLOCKS: u32 = 5_760; +pub const GOVERNANCE_ACTIVATION_DELAY_BLOCKS: u32 = 5_760; +pub const GOVERNANCE_DOCUMENT_HASH_BYTES: usize = 32; +pub const GOVERNANCE_DEVELOPMENT_LOCATION_BYTES: usize = 100; + +pub const PROPOSAL_KEYS_TREE: &str = "proposal_keys"; +pub const PROPOSAL_VOTES_TREE: &str = "proposal_votes"; +pub const FINISHED_PROPOSAL_VOTES_TREE: &str = "finished_proposal_votes"; +pub const ACTIVATION_VOTES_TREE: &str = "activation_votes"; +pub const FINISHED_ACTIVATION_VOTES_TREE: &str = "finished_activation_votes"; +pub const CONSENSUS_ACTIVATIONS_TREE: &str = "consensus_activations"; +pub const GOVERNANCE_EXPIRATIONS_TREE: &str = "governance_expirations"; +pub const SUPERSEDED_GOVERNANCE_VOTES_TREE: &str = "superseded_governance_votes"; +pub const PROPOSAL_VOTE_RECORD_KEY_BYTES: usize = + GOVERNANCE_DOCUMENT_HASH_BYTES + Wallet::SHORT_ADDRESS_BYTES_LENGTH; +pub const ACTIVATION_CANDIDATE_KEY_BYTES: usize = GOVERNANCE_DOCUMENT_HASH_BYTES * 2; +pub const ACTIVATION_VOTE_RECORD_KEY_BYTES: usize = + ACTIVATION_CANDIDATE_KEY_BYTES + Wallet::SHORT_ADDRESS_BYTES_LENGTH; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum GovernanceVote { + No = 0, + Yes = 1, +} + +impl TryFrom for GovernanceVote { + type Error = String; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::No), + 1 => Ok(Self::Yes), + _ => Err("Governance vote must be 0 (no) or 1 (yes).".to_string()), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProposalState { + Open, + Approved, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ActivationState { + Open, + Scheduled, + Activated, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GovernanceNodeSnapshot { + pub address: String, + pub ip: String, + pub blocks_mined: u8, + pub added_timestamp: u64, + pub deleted_block: u32, +} + +pub fn countable_governance_addresses( + snapshots: &[GovernanceNodeSnapshot], + existing_voters: &HashSet, + reference_timestamp_millis: u64, + current_height: u32, +) -> HashSet { + // An active wallet supersedes older wallet records on the same IP for + // unfinished votes. Historical records remain available for validating + // old blocks, but one physical node cannot count twice in governance. + let active_wallet_by_ip: HashMap<&str, &str> = snapshots + .iter() + .filter(|node| node.is_active()) + .map(|node| (node.ip.as_str(), node.address.as_str())) + .collect(); + + snapshots + .iter() + .filter(|node| { + active_wallet_by_ip + .get(node.ip.as_str()) + .map(|active_address| *active_address == node.address) + .unwrap_or(true) + }) + .filter(|node| { + if existing_voters.contains(&node.address) { + node.existing_vote_is_countable(current_height) + } else { + node.is_eligible_for_governance_count(reference_timestamp_millis, current_height) + } + }) + .map(|node| node.address.clone()) + .collect() +} + +impl GovernanceNodeSnapshot { + pub fn is_active(&self) -> bool { + self.deleted_block == 0 + } + + pub fn meets_voting_history_requirements(&self, reference_timestamp_millis: u64) -> bool { + self.blocks_mined >= GOVERNANCE_MIN_BLOCKS_MINED + && reference_timestamp_millis.saturating_sub(self.added_timestamp) + >= GOVERNANCE_MIN_NODE_AGE_MILLIS + } + + pub fn can_cast_vote(&self, reference_timestamp_millis: u64) -> bool { + self.is_active() && self.meets_voting_history_requirements(reference_timestamp_millis) + } + + pub fn can_cast_vote_at(&self, reference_timestamp_millis: u64, block_height: u32) -> bool { + let active_at_height = self.deleted_block == 0 || block_height < self.deleted_block; + active_at_height && self.meets_voting_history_requirements(reference_timestamp_millis) + } + + pub fn is_within_offline_grace(&self, current_height: u32) -> bool { + self.is_active() + || current_height.saturating_sub(self.deleted_block) < GOVERNANCE_OFFLINE_GRACE_BLOCKS + } + + pub fn is_eligible_for_governance_count( + &self, + reference_timestamp_millis: u64, + current_height: u32, + ) -> bool { + self.meets_voting_history_requirements(reference_timestamp_millis) + && self.is_within_offline_grace(current_height) + } + + pub fn existing_vote_is_countable(&self, current_height: u32) -> bool { + self.is_within_offline_grace(current_height) + } +} + +pub fn hash_governance_document(document_bytes: &[u8]) -> String { + skein_256_hash_bytes(document_bytes) +} + +pub fn is_valid_governance_hash(hash: &str) -> bool { + hex::decode(hash) + .map(|decoded| decoded.len() == GOVERNANCE_DOCUMENT_HASH_BYTES) + .unwrap_or(false) +} + +pub fn normalize_development_location(location: &str) -> Result { + if location.is_empty() { + return Err("Development document location cannot be empty.".to_string()); + } + if !location.is_ascii() { + return Err( + "Development document location must contain only ASCII characters.".to_string(), + ); + } + if location.len() > GOVERNANCE_DEVELOPMENT_LOCATION_BYTES { + return Err(format!( + "Development document location cannot exceed {} bytes.", + GOVERNANCE_DEVELOPMENT_LOCATION_BYTES + )); + } + + Ok(format!( + "{location: bool { + location.len() == GOVERNANCE_DEVELOPMENT_LOCATION_BYTES + && location.is_ascii() + && !location.trim().is_empty() +} + +pub fn governance_approval_reached(yes_votes: u64, eligible_nodes: u64) -> bool { + eligible_nodes > 0 + && (yes_votes as u128) * (GOVERNANCE_PERCENT_SCALE as u128) + >= (eligible_nodes as u128) * (GOVERNANCE_APPROVAL_PERCENT as u128) +} + +pub fn governance_activation_height(finalized_height: u32) -> Option { + finalized_height.checked_add(GOVERNANCE_ACTIVATION_DELAY_BLOCKS) +} + +/// Returns whether a specific implementation was approved and the block at +/// which it activates. A proposal without a schedule returns `(false, 0)`. +/// +/// Callers compare the returned activation height with the height of the +/// specific block being validated, decoded, replayed, or undone. +pub fn implementation_activation_status( + db: &crate::sled::Db, + proposal_key: &str, + expected_development_hash: &str, +) -> Result<(bool, u32), String> { + let proposal_key = + decode(proposal_key).map_err(|err| format!("Could not decode proposal key: {err}"))?; + if proposal_key.len() != GOVERNANCE_DOCUMENT_HASH_BYTES { + return Err("Proposal key must decode to exactly 32 bytes.".to_string()); + } + + let expected_development_hash = decode(expected_development_hash) + .map_err(|err| format!("Could not decode expected development hash: {err}"))?; + if expected_development_hash.len() != GOVERNANCE_DOCUMENT_HASH_BYTES { + return Err("Expected development hash must decode to exactly 32 bytes.".to_string()); + } + + let activations = db + .open_tree(CONSENSUS_ACTIVATIONS_TREE) + .map_err(|err| format!("Could not open consensus activations: {err}"))?; + let Some(schedule) = activations + .get(proposal_key) + .map_err(|err| format!("Could not read consensus activation: {err}"))? + else { + return Ok((false, 0)); + }; + + let expected_length = GOVERNANCE_DOCUMENT_HASH_BYTES + + std::mem::size_of::() + + GOVERNANCE_DEVELOPMENT_LOCATION_BYTES; + if schedule.len() != expected_length { + return Err(format!( + "Stored consensus activation has an invalid byte count: expected {expected_length}, found {}.", + schedule.len() + )); + } + + if schedule[..GOVERNANCE_DOCUMENT_HASH_BYTES] != expected_development_hash { + return Err( + "This proposal scheduled a different implementation than this software expects." + .to_string(), + ); + } + + let height_start = GOVERNANCE_DOCUMENT_HASH_BYTES; + let height_end = height_start + std::mem::size_of::(); + let activation_height = u32::from_le_bytes( + schedule[height_start..height_end] + .try_into() + .map_err(|_| "Could not decode the consensus activation height.".to_string())?, + ); + + Ok((true, activation_height)) +} + +pub fn proposal_vote_record_key( + proposal_key: &str, + voter_address: &str, +) -> Result, String> { + let mut key = + decode(proposal_key).map_err(|err| format!("Could not decode proposal key: {err}"))?; + if key.len() != GOVERNANCE_DOCUMENT_HASH_BYTES { + return Err("Proposal key must decode to exactly 32 bytes.".to_string()); + } + let address = Wallet::short_address_to_bytes(voter_address) + .ok_or_else(|| "Proposal voter address is invalid.".to_string())?; + key.extend(address); + Ok(key) +} + +pub fn activation_candidate_key( + proposal_key: &str, + development_hash: &str, +) -> Result, String> { + let mut key = + decode(proposal_key).map_err(|err| format!("Could not decode proposal key: {err}"))?; + if key.len() != GOVERNANCE_DOCUMENT_HASH_BYTES { + return Err("Proposal key must decode to exactly 32 bytes.".to_string()); + } + let development = decode(development_hash) + .map_err(|err| format!("Could not decode development document hash: {err}"))?; + if development.len() != GOVERNANCE_DOCUMENT_HASH_BYTES { + return Err("Development document hash must decode to exactly 32 bytes.".to_string()); + } + key.extend(development); + Ok(key) +} + +pub fn activation_vote_record_key( + proposal_key: &str, + development_hash: &str, + voter_address: &str, +) -> Result, String> { + let mut key = activation_candidate_key(proposal_key, development_hash)?; + let address = Wallet::short_address_to_bytes(voter_address) + .ok_or_else(|| "Activation voter address is invalid.".to_string())?; + key.extend(address); + Ok(key) +} + +pub fn encode_finished_activation_vote( + yes_votes: u64, + no_votes: u64, + eligible_nodes: u64, + finalized_height: u32, + activation_height: u32, + development_location: &str, +) -> Result, String> { + if !is_valid_padded_development_location(development_location) { + return Err("Development location is not a canonical 100-byte value.".to_string()); + } + let mut value = Vec::with_capacity(132); + value.extend_from_slice(&yes_votes.to_le_bytes()); + value.extend_from_slice(&no_votes.to_le_bytes()); + value.extend_from_slice(&eligible_nodes.to_le_bytes()); + value.extend_from_slice(&finalized_height.to_le_bytes()); + value.extend_from_slice(&activation_height.to_le_bytes()); + value.extend_from_slice(development_location.as_bytes()); + Ok(value) +} + +pub fn encode_consensus_activation( + development_hash: &str, + activation_height: u32, + development_location: &str, +) -> Result, String> { + let development = decode(development_hash) + .map_err(|err| format!("Could not decode development document hash: {err}"))?; + if development.len() != GOVERNANCE_DOCUMENT_HASH_BYTES { + return Err("Development document hash must decode to exactly 32 bytes.".to_string()); + } + if !is_valid_padded_development_location(development_location) { + return Err("Development location is not a canonical 100-byte value.".to_string()); + } + let mut value = Vec::with_capacity(136); + value.extend(development); + value.extend_from_slice(&activation_height.to_le_bytes()); + value.extend_from_slice(development_location.as_bytes()); + Ok(value) +} + +pub fn encode_finished_vote( + yes_votes: u64, + no_votes: u64, + eligible_nodes: u64, + block_height: u32, +) -> Vec { + let mut value = Vec::with_capacity(28); + value.extend_from_slice(&yes_votes.to_le_bytes()); + value.extend_from_slice(&no_votes.to_le_bytes()); + value.extend_from_slice(&eligible_nodes.to_le_bytes()); + value.extend_from_slice(&block_height.to_le_bytes()); + value +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node() -> GovernanceNodeSnapshot { + GovernanceNodeSnapshot { + address: "address".to_string(), + ip: "127.0.0.1".to_string(), + blocks_mined: GOVERNANCE_MIN_BLOCKS_MINED, + added_timestamp: 1_000, + deleted_block: 0, + } + } + + #[test] + fn approval_uses_an_exact_85_percent_threshold() { + assert!(governance_approval_reached(17, 20)); + assert!(!governance_approval_reached(16, 20)); + assert!(governance_approval_reached(8, 9)); + assert!(!governance_approval_reached(0, 0)); + } + + #[test] + fn governance_hash_commits_to_exact_document_bytes() { + let unix = hash_governance_document(b"proposal\n"); + let windows = hash_governance_document(b"proposal\r\n"); + + assert_ne!(unix, windows); + assert!(is_valid_governance_hash(&unix)); + assert!(!is_valid_governance_hash("abcd")); + } + + #[test] + fn development_location_is_fixed_width_ascii() { + let location = normalize_development_location("CLP-1-implementation.md").unwrap(); + + assert_eq!(location.len(), GOVERNANCE_DEVELOPMENT_LOCATION_BYTES); + assert!(location.starts_with("CLP-1-implementation.md")); + assert!(normalize_development_location("").is_err()); + assert!(normalize_development_location("proposal-🚫").is_err()); + assert!(normalize_development_location(&"a".repeat(101)).is_err()); + } + + #[test] + fn vote_values_are_strict() { + assert_eq!(GovernanceVote::try_from(0).unwrap(), GovernanceVote::No); + assert_eq!(GovernanceVote::try_from(1).unwrap(), GovernanceVote::Yes); + assert!(GovernanceVote::try_from(2).is_err()); + } + + #[test] + fn voting_requires_active_mature_node_with_mined_blocks() { + let reference_time = 1_000 + GOVERNANCE_MIN_NODE_AGE_MILLIS; + let mut candidate = node(); + + assert!(candidate.can_cast_vote(reference_time)); + + candidate.blocks_mined = GOVERNANCE_MIN_BLOCKS_MINED - 1; + assert!(!candidate.can_cast_vote(reference_time)); + + candidate.blocks_mined = GOVERNANCE_MIN_BLOCKS_MINED; + candidate.deleted_block = 1; + assert!(!candidate.can_cast_vote(reference_time)); + } + + #[test] + fn offline_vote_grace_expires_at_5760_blocks() { + let mut candidate = node(); + candidate.deleted_block = 100; + + assert!(candidate.existing_vote_is_countable(5_859)); + assert!(!candidate.existing_vote_is_countable(5_860)); + } + + #[test] + fn active_replacement_wallet_supersedes_old_vote_on_same_ip() { + let reference_time = 1_000 + GOVERNANCE_MIN_NODE_AGE_MILLIS; + let old = GovernanceNodeSnapshot { + address: "old".to_string(), + ip: "1.2.3.4".to_string(), + blocks_mined: 100, + added_timestamp: 1_000, + deleted_block: 100, + }; + let replacement = GovernanceNodeSnapshot { + address: "new".to_string(), + ip: "1.2.3.4".to_string(), + blocks_mined: 0, + added_timestamp: reference_time, + deleted_block: 0, + }; + let voters = HashSet::from(["old".to_string()]); + + let countable = + countable_governance_addresses(&[old, replacement], &voters, reference_time, 101); + + assert!(!countable.contains("old")); + assert!(!countable.contains("new")); + } + + #[test] + fn existing_vote_reactivates_immediately_with_same_wallet() { + let reference_time = 1_000 + GOVERNANCE_MIN_NODE_AGE_MILLIS; + let returning = GovernanceNodeSnapshot { + address: "returning".to_string(), + ip: "1.2.3.4".to_string(), + blocks_mined: 100, + added_timestamp: reference_time, + deleted_block: 0, + }; + let voters = HashSet::from(["returning".to_string()]); + + let countable = + countable_governance_addresses(&[returning], &voters, reference_time, 10_000); + + assert!(countable.contains("returning")); + } + + #[test] + fn missing_implementation_schedule_is_not_active() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let proposal_key = "11".repeat(GOVERNANCE_DOCUMENT_HASH_BYTES); + let development_hash = "22".repeat(GOVERNANCE_DOCUMENT_HASH_BYTES); + + assert_eq!( + implementation_activation_status(&db, &proposal_key, &development_hash).unwrap(), + (false, 0) + ); + } + + #[test] + fn approved_implementation_returns_its_scheduled_height() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let proposal_key = "11".repeat(GOVERNANCE_DOCUMENT_HASH_BYTES); + let development_hash = "22".repeat(GOVERNANCE_DOCUMENT_HASH_BYTES); + let location = normalize_development_location("CLPs/CLP-0001-IMPLEMENTATION.md").unwrap(); + let schedule = encode_consensus_activation(&development_hash, 500, &location).unwrap(); + db.open_tree(CONSENSUS_ACTIVATIONS_TREE) + .unwrap() + .insert(decode(&proposal_key).unwrap(), schedule) + .unwrap(); + + assert_eq!( + implementation_activation_status(&db, &proposal_key, &development_hash).unwrap(), + (true, 500) + ); + } + + #[test] + fn unexpected_scheduled_implementation_fails_closed() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let proposal_key = "11".repeat(GOVERNANCE_DOCUMENT_HASH_BYTES); + let scheduled_hash = "22".repeat(GOVERNANCE_DOCUMENT_HASH_BYTES); + let expected_hash = "33".repeat(GOVERNANCE_DOCUMENT_HASH_BYTES); + let location = normalize_development_location("CLPs/CLP-0001-IMPLEMENTATION.md").unwrap(); + let schedule = encode_consensus_activation(&scheduled_hash, 500, &location).unwrap(); + db.open_tree(CONSENSUS_ACTIVATIONS_TREE) + .unwrap() + .insert(decode(&proposal_key).unwrap(), schedule) + .unwrap(); + + let error = + implementation_activation_status(&db, &proposal_key, &expected_hash).unwrap_err(); + + assert!(error.contains("different implementation")); + } + + #[test] + fn malformed_implementation_schedule_is_rejected() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let proposal_key = "11".repeat(GOVERNANCE_DOCUMENT_HASH_BYTES); + let development_hash = "22".repeat(GOVERNANCE_DOCUMENT_HASH_BYTES); + db.open_tree(CONSENSUS_ACTIVATIONS_TREE) + .unwrap() + .insert(decode(&proposal_key).unwrap(), vec![0; 10]) + .unwrap(); + + let error = + implementation_activation_status(&db, &proposal_key, &development_hash).unwrap_err(); + + assert!(error.contains("invalid byte count")); + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index e20973c..76649de 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -2,6 +2,7 @@ pub mod asset_names; pub mod binary_conversions; pub mod check_genesis; pub mod cli_prompts; +pub mod governance; pub mod loan_schedule; pub mod network_paths_and_settings; pub mod network_startup; diff --git a/src/common/nft_assets.rs b/src/common/nft_assets.rs index 42cd855..33ce2ad 100644 --- a/src/common/nft_assets.rs +++ b/src/common/nft_assets.rs @@ -1,3 +1,9 @@ +use crate::sled::Db; + +pub const NFT_UNIT: u64 = 100_000_000; +pub const NFT_OWNERSHIP_INDIVISIBLE: u8 = 0; +pub const NFT_OWNERSHIP_FRACTIONAL: u8 = 1; + // Build the visible NFT asset name, adding the series suffix only // for numbered series entries. pub fn nft_asset_name(name: &str, nft_series: u32) -> String { @@ -24,3 +30,56 @@ pub fn nft_asset_parts(asset_name: &str) -> (String, u32) { // Non-series assets keep their original name and use series zero. (asset_name.to_string(), 0) } + +// Return the immutable ownership rule stored for a concrete NFT/RWA asset. +// A missing value means the requested asset is not registered as an NFT/RWA. +pub fn nft_ownership_type(db: &Db, asset_name: &str) -> Result, String> { + let tree = db + .open_tree("nfts") + .map_err(|err| format!("Failed to open NFT registry: {err}"))?; + let Some(value) = tree + .get(asset_name.as_bytes()) + .map_err(|err| format!("Failed to read NFT registry: {err}"))? + else { + return Ok(None); + }; + + match value.as_ref() { + [NFT_OWNERSHIP_INDIVISIBLE] => Ok(Some(NFT_OWNERSHIP_INDIVISIBLE)), + [NFT_OWNERSHIP_FRACTIONAL] => Ok(Some(NFT_OWNERSHIP_FRACTIONAL)), + _ => Err("NFT registry contains an invalid ownership type.".to_string()), + } +} + +// Enforce the amount rule selected when the NFT/RWA was created. +pub fn validate_nft_amount(ownership_type: u8, value: u64, operation: &str) -> Result<(), String> { + match ownership_type { + NFT_OWNERSHIP_INDIVISIBLE if value == NFT_UNIT => Ok(()), + NFT_OWNERSHIP_INDIVISIBLE => Err(format!( + "Indivisible NFT/RWA {operation} amount must be exactly {NFT_UNIT} units." + )), + NFT_OWNERSHIP_FRACTIONAL if value > 0 && value <= NFT_UNIT => Ok(()), + NFT_OWNERSHIP_FRACTIONAL => Err(format!( + "Fractional NFT/RWA {operation} amount must be between 1 and {NFT_UNIT} units." + )), + _ => Err("NFT/RWA has an invalid ownership type.".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn indivisible_assets_require_one_complete_unit() { + assert!(validate_nft_amount(NFT_OWNERSHIP_INDIVISIBLE, NFT_UNIT, "transfer").is_ok()); + assert!(validate_nft_amount(NFT_OWNERSHIP_INDIVISIBLE, NFT_UNIT - 1, "transfer").is_err()); + } + + #[test] + fn fractional_assets_accept_partial_ownership() { + assert!(validate_nft_amount(NFT_OWNERSHIP_FRACTIONAL, 56_000_000, "transfer").is_ok()); + assert!(validate_nft_amount(NFT_OWNERSHIP_FRACTIONAL, 0, "transfer").is_err()); + assert!(validate_nft_amount(NFT_OWNERSHIP_FRACTIONAL, NFT_UNIT + 1, "transfer").is_err()); + } +} diff --git a/src/common/types.rs b/src/common/types.rs index 753deab..18aa2ca 100644 --- a/src/common/types.rs +++ b/src/common/types.rs @@ -1,3 +1,4 @@ +use crate::blocks::activation_vote::ActivationVote; use crate::blocks::burn::BurnTransaction; use crate::blocks::collateral::CollateralClaimTransaction; use crate::blocks::delete_key::DeleteKey; @@ -7,6 +8,8 @@ use crate::blocks::loan_payment::ContractPaymentTransaction; use crate::blocks::loans::LoanContractTransaction; use crate::blocks::marketing::MarketingTransaction; use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::proposal_key::ProposalKey; +use crate::blocks::proposal_vote::ProposalVote; use crate::blocks::rewards::RewardsTransaction; use crate::blocks::storage_key::StorageKey; use crate::blocks::storage_values::{ @@ -49,6 +52,11 @@ pub const STORAGE_I64_TYPE: u8 = 111; pub const STORAGE_I128_TYPE: u8 = 112; pub const DELETE_KEY_TYPE: u8 = 113; +// On-chain governance transaction types. +pub const PROPOSAL_KEY_TYPE: u8 = 200; +pub const PROPOSAL_VOTE_TYPE: u8 = 201; +pub const ACTIVATION_VOTE_TYPE: u8 = 202; + // Coin and asset names are stored in fixed 15-byte fields. pub const COIN_LENGTH: usize = 15; @@ -84,6 +92,9 @@ pub const ISSUE_TOKEN_FEE: u64 = 10000000000; // cost to issue more of an existi pub const VANITY_ADDRESS_FEE: u64 = 500000000; // cost to register or update a vanity address, 5 CLC pub const STORAGE_KEY_FEE: u64 = 50000000000; // cost to create an app storage key, 500 CLC pub const STORAGE_VALUE_FEE: u64 = 100000000; // cost to write app storage data, 1 CLC +pub const PROPOSAL_KEY_FEE: u64 = 100000000; // cost to create a governance proposal key, 1 CLC +pub const PROPOSAL_VOTE_FEE: u64 = 100000000; // cost to vote on a governance proposal, 1 CLC +pub const ACTIVATION_VOTE_FEE: u64 = 100000000; // cost to vote on proposal activation, 1 CLC pub const STORAGE_KEY_INDEX_TREE: &str = "storage_key_index"; pub const STORAGE_VALUE_ROLLBACK_TREE: &str = "storage_value_rollback"; @@ -130,6 +141,9 @@ pub enum Transaction { StorageI64(StorageI64), StorageI128(StorageI128), DeleteKey(DeleteKey), + ProposalKey(ProposalKey), + ProposalVote(ProposalVote), + ActivationVote(ActivationVote), } impl Transaction { @@ -164,6 +178,9 @@ impl Transaction { Transaction::StorageI64(tx) => Some(&tx.signature), Transaction::StorageI128(tx) => Some(&tx.signature), Transaction::DeleteKey(tx) => Some(&tx.signature), + Transaction::ProposalKey(tx) => Some(&tx.signature), + Transaction::ProposalVote(tx) => Some(&tx.signature), + Transaction::ActivationVote(tx) => Some(&tx.signature), } } } diff --git a/src/orphans/undo_block_transactions.rs b/src/orphans/undo_block_transactions.rs index 350b5ee..3e2b0f3 100644 --- a/src/orphans/undo_block_transactions.rs +++ b/src/orphans/undo_block_transactions.rs @@ -4,6 +4,7 @@ use crate::orphans::save_blocks::save_new_blocks; use crate::orphans::structs::UndoTransactions; use crate::orphans::undo_block::undo_block; use crate::orphans::undo_transactions::restore_mempool::restore_rolled_back_transaction; +use crate::orphans::undo_transactions::undo_activation_vote::undo_activation_vote_transaction; use crate::orphans::undo_transactions::undo_borrower::undo_borrower_transaction; use crate::orphans::undo_transactions::undo_burn::undo_burn_transaction; use crate::orphans::undo_transactions::undo_collateral::undo_collateral_transaction; @@ -13,6 +14,8 @@ use crate::orphans::undo_transactions::undo_delete_key::undo_delete_key_transact use crate::orphans::undo_transactions::undo_issue_token::undo_issue_token_transaction; use crate::orphans::undo_transactions::undo_loan_creation::undo_loan_creation_transaction; use crate::orphans::undo_transactions::undo_marketing::undo_marketing_transaction; +use crate::orphans::undo_transactions::undo_proposal_key::undo_proposal_key_transaction; +use crate::orphans::undo_transactions::undo_proposal_vote::undo_proposal_vote_transaction; use crate::orphans::undo_transactions::undo_rewards::undo_rewards_transaction; use crate::orphans::undo_transactions::undo_storage_key::undo_storage_key_transaction; use crate::orphans::undo_transactions::undo_storage_value::undo_storage_value_transaction; @@ -142,6 +145,35 @@ pub async fn undo_transactions( .await?; rolled_back_transactions.push(Transaction::StorageKey(storage_key_tx)); } + Transaction::ProposalKey(proposal_key_tx) => { + undo_proposal_key_transaction( + proposal_key_tx.clone(), + &mining_receiver, + ¶ms.db, + ) + .await?; + rolled_back_transactions.push(Transaction::ProposalKey(proposal_key_tx)); + } + Transaction::ProposalVote(proposal_vote_tx) => { + undo_proposal_vote_transaction( + proposal_vote_tx.clone(), + &mining_receiver, + current_height, + ¶ms.db, + ) + .await?; + rolled_back_transactions.push(Transaction::ProposalVote(proposal_vote_tx)); + } + Transaction::ActivationVote(activation_vote_tx) => { + undo_activation_vote_transaction( + activation_vote_tx.clone(), + &mining_receiver, + current_height, + ¶ms.db, + ) + .await?; + rolled_back_transactions.push(Transaction::ActivationVote(activation_vote_tx)); + } Transaction::StorageBool(_) | Transaction::StorageU8(_) | Transaction::StorageU16(_) diff --git a/src/orphans/undo_transactions/mod.rs b/src/orphans/undo_transactions/mod.rs index db60591..0192cec 100644 --- a/src/orphans/undo_transactions/mod.rs +++ b/src/orphans/undo_transactions/mod.rs @@ -1,4 +1,5 @@ pub mod restore_mempool; +pub mod undo_activation_vote; pub mod undo_borrower; pub mod undo_burn; pub mod undo_collateral; @@ -8,6 +9,8 @@ pub mod undo_delete_key; pub mod undo_issue_token; pub mod undo_loan_creation; pub mod undo_marketing; +pub mod undo_proposal_key; +pub mod undo_proposal_vote; pub mod undo_rewards; pub mod undo_storage_key; pub mod undo_storage_value; diff --git a/src/orphans/undo_transactions/restore_mempool.rs b/src/orphans/undo_transactions/restore_mempool.rs index 0cfeca7..edc2363 100644 --- a/src/orphans/undo_transactions/restore_mempool.rs +++ b/src/orphans/undo_transactions/restore_mempool.rs @@ -6,6 +6,8 @@ use crate::blocks::loan_payment::ContractPaymentTransaction; use crate::blocks::loans::LoanContractTransaction; use crate::blocks::marketing::MarketingTransaction; use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::proposal_key::ProposalKey; +use crate::blocks::proposal_vote::ProposalVote; use crate::blocks::storage_key::StorageKey; use crate::blocks::storage_values::StorageValueTransaction; use crate::blocks::swap::SwapTransaction; @@ -334,6 +336,58 @@ pub async fn restore_storage_key(transaction: &StorageKey, db: &Db) { .await; } +pub async fn restore_proposal_key(transaction: &ProposalKey, db: &Db) { + let proposal = &transaction.unsigned_proposalkey; + let spendable = + balance_checkup(db, 0, proposal.txfee, BASECOIN.clone(), &proposal.address).await; + let signature = transaction.signature.clone(); + + restore_if_spendable_void( + "proposal_key", + "unknown", + &[signature], + spendable, + || async { + let _ = transaction.add_to_memory().await; + }, + ) + .await; +} + +pub async fn restore_proposal_vote(transaction: &ProposalVote, db: &Db) { + let vote = &transaction.unsigned_proposalvote; + let spendable = balance_checkup(db, 0, vote.txfee, BASECOIN.clone(), &vote.address).await; + let signature = transaction.signature.clone(); + + restore_if_spendable_void( + "proposal_vote", + "unknown", + &[signature], + spendable, + || async { + let _ = transaction.add_to_memory().await; + }, + ) + .await; +} + +pub async fn restore_activation_vote(transaction: &ActivationVote, db: &Db) { + let vote = &transaction.unsigned_activationvote; + let spendable = balance_checkup(db, 0, vote.txfee, BASECOIN.clone(), &vote.address).await; + let signature = transaction.signature.clone(); + + restore_if_spendable_void( + "activation_vote", + "unknown", + &[signature], + spendable, + || async { + let _ = transaction.add_to_memory().await; + }, + ) + .await; +} + async fn restore_storage_value(tx_label: &str, transaction: &T, db: &Db, insert: F) where T: StorageValueTransaction, @@ -374,6 +428,9 @@ pub async fn restore_rolled_back_transaction(transaction: &Transaction, db: &Db) Transaction::Collateral(tx) => restore_collateral(tx, db).await, Transaction::Vanity(tx) => restore_vanity(tx, db).await, Transaction::StorageKey(tx) => restore_storage_key(tx, db).await, + Transaction::ProposalKey(tx) => restore_proposal_key(tx, db).await, + Transaction::ProposalVote(tx) => restore_proposal_vote(tx, db).await, + Transaction::ActivationVote(tx) => restore_activation_vote(tx, db).await, Transaction::StorageBool(tx) => { restore_storage_value("storage_bool", tx, db, || async { tx.add_to_memory().await @@ -432,3 +489,4 @@ pub async fn restore_rolled_back_transaction(transaction: &Transaction, db: &Db) Transaction::Genesis(_) | Transaction::Rewards(_) => {} } } +use crate::blocks::activation_vote::ActivationVote; diff --git a/src/orphans/undo_transactions/undo_activation_vote.rs b/src/orphans/undo_transactions/undo_activation_vote.rs new file mode 100644 index 0000000..719b5e4 --- /dev/null +++ b/src/orphans/undo_transactions/undo_activation_vote.rs @@ -0,0 +1,71 @@ +use crate::blocks::activation_vote::ActivationVote; +use crate::common::governance::{ + activation_candidate_key, activation_vote_record_key, ACTIVATION_VOTES_TREE, + CONSENSUS_ACTIVATIONS_TREE, FINISHED_ACTIVATION_VOTES_TREE, +}; +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::wallet_tx_index::remove_wallet_transaction_index; +use crate::sled::Db; + +pub async fn undo_activation_vote_transaction( + transaction: ActivationVote, + mining_receiver: &str, + block_height: u32, + db: &Db, +) -> Result<(), String> { + let vote = &transaction.unsigned_activationvote; + let txhash = vote.hash().await; + let txhash_bytes = decode(&txhash) + .map_err(|err| format!("Could not decode activation vote txhash during undo: {err}"))?; + let proposal_key = decode(&vote.proposal_key) + .map_err(|err| format!("Could not decode proposal key during undo: {err}"))?; + let candidate_key = activation_candidate_key(&vote.proposal_key, &vote.development_hash)?; + let vote_key = + activation_vote_record_key(&vote.proposal_key, &vote.development_hash, &vote.address)?; + + let _ = + balance_sheet_operation_with_db(db, mining_receiver, vote.txfee, &BASECOIN, "subtraction"); + let _ = balance_sheet_operation_with_db(db, &vote.address, vote.txfee, &BASECOIN, "addition"); + + db.open_tree(ACTIVATION_VOTES_TREE) + .map_err(|err| format!("Could not open activation votes during undo: {err}"))? + .remove(vote_key) + .map_err(|err| format!("Could not remove activation vote during undo: {err}"))?; + + let finished = db + .open_tree(FINISHED_ACTIVATION_VOTES_TREE) + .map_err(|err| format!("Could not open finished activation votes during undo: {err}"))?; + if let Some(value) = finished + .get(&candidate_key) + .map_err(|err| format!("Could not read activation finalization during undo: {err}"))? + { + if value.len() == 132 { + let finalized_height = u32::from_le_bytes(value[24..28].try_into().map_err(|_| { + "Finished activation vote contains an invalid block height.".to_string() + })?); + if finalized_height == block_height { + finished.remove(&candidate_key).map_err(|err| { + format!("Could not remove activation finalization during undo: {err}") + })?; + db.open_tree(CONSENSUS_ACTIVATIONS_TREE) + .map_err(|err| { + format!("Could not open consensus activations during undo: {err}") + })? + .remove(&proposal_key) + .map_err(|err| { + format!("Could not remove scheduled activation during undo: {err}") + })?; + } + } + } + + db.open_tree("txid") + .map_err(|err| format!("Could not open txid tree during activation vote undo: {err}"))? + .remove(&txhash_bytes) + .map_err(|err| format!("Could not remove activation vote txid during undo: {err}"))?; + let _ = remove_wallet_transaction_index(db, &[&vote.address, mining_receiver], &txhash_bytes); + + Ok(()) +} diff --git a/src/orphans/undo_transactions/undo_burn.rs b/src/orphans/undo_transactions/undo_burn.rs index 5a32200..d1bdbdf 100644 --- a/src/orphans/undo_transactions/undo_burn.rs +++ b/src/orphans/undo_transactions/undo_burn.rs @@ -1,6 +1,7 @@ use crate::blocks::burn::BurnTransaction; use crate::common::network_paths_and_settings::block_extension_and_paths; use crate::common::nft_assets::nft_asset_name; +use crate::common::nft_assets::NFT_UNIT; use crate::decode; use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; use crate::records::record_chain::nft_provenance::remove_nft_history_entry; @@ -67,15 +68,34 @@ pub async fn undo_burn_transaction(transaction: BurnTransaction, mining_receiver // Restore NFT rows directly, or add the burned amount back into the // fungible token supply if this burn targeted a token asset. let nft_tree = db.open_tree("nfts").unwrap(); - let nft_origin_tree = db.open_tree("nft_origins").unwrap(); - if nft_origin_tree + let nft_ownership_tree = db.open_tree("nft_ownership").unwrap(); + if nft_ownership_tree .contains_key(burned_asset.as_bytes()) .unwrap_or(false) { - // NFT burns remove the live NFT row; rollback restores the row and - // removes the burn from NFT history. + // Restore the exact burned ownership amount. The immutable ownership + // tree restores the live registry correctly after a final-unit burn. let _ = remove_nft_history_entry(db, &burned_asset, &hash_binary); - let _ = nft_tree.insert(burned_asset.as_bytes(), b"1"); + let ownership_type = nft_ownership_tree + .get(burned_asset.as_bytes()) + .unwrap() + .expect("NFT/RWA ownership record disappeared during burn rollback"); + let nft_supply_tree = db.open_tree("nft_supply").unwrap(); + let current_supply = nft_supply_tree + .get(burned_asset.as_bytes()) + .unwrap() + .map(|bytes| { + let mut supply_bytes = [0u8; 8]; + supply_bytes.copy_from_slice(bytes.as_ref()); + u64::from_le_bytes(supply_bytes) + }) + .unwrap_or(0); + let restored_supply = current_supply + .checked_add(transaction.unsigned_burn.value) + .filter(|supply| *supply <= NFT_UNIT) + .expect("NFT/RWA supply overflow during burn rollback"); + let _ = nft_supply_tree.insert(burned_asset.as_bytes(), &restored_supply.to_le_bytes()); + let _ = nft_tree.insert(burned_asset.as_bytes(), ownership_type); } else { // Token burns reduce supply; rollback adds the burned amount back. let _ = remove_token_history_entry(db, &transaction.unsigned_burn.coin, &hash_binary); diff --git a/src/orphans/undo_transactions/undo_create_nft.rs b/src/orphans/undo_transactions/undo_create_nft.rs index a10aa8f..ea0eea5 100644 --- a/src/orphans/undo_transactions/undo_create_nft.rs +++ b/src/orphans/undo_transactions/undo_create_nft.rs @@ -1,14 +1,12 @@ use crate::blocks::nft::CreateNftTransaction; use crate::common::network_paths_and_settings::block_extension_and_paths; -use crate::common::nft_assets::nft_asset_name; +use crate::common::nft_assets::{nft_asset_name, NFT_UNIT}; use crate::decode; use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; use crate::records::record_chain::nft_provenance::{remove_nft_history_entry, remove_nft_origin}; use crate::records::record_chain::wallet_tx_index::remove_wallet_transaction_index; use crate::sled::Db; -const NFT_UNIT: u64 = 100_000_000; - pub async fn undo_create_nft_transaction( transaction: CreateNftTransaction, mining_receiver: &str, @@ -69,6 +67,12 @@ pub async fn undo_create_nft_transaction( let _ = remove_nft_history_entry(db, &nft_name, &hash_binary); let _ = remove_nft_origin(db, &nft_name); tree.remove(nft_name.as_bytes()).unwrap(); + let _ = db + .open_tree("nft_ownership") + .and_then(|tree| tree.remove(nft_name.as_bytes())); + let _ = db + .open_tree("nft_supply") + .and_then(|tree| tree.remove(nft_name.as_bytes())); } } else { // Single NFT creation only writes the base NFT name. @@ -78,5 +82,11 @@ pub async fn undo_create_nft_transaction( let _ = remove_nft_history_entry(db, nft_name, &hash_binary); let _ = remove_nft_origin(db, nft_name); tree.remove(nft_name.as_bytes()).unwrap(); + let _ = db + .open_tree("nft_ownership") + .and_then(|tree| tree.remove(nft_name.as_bytes())); + let _ = db + .open_tree("nft_supply") + .and_then(|tree| tree.remove(nft_name.as_bytes())); } } diff --git a/src/orphans/undo_transactions/undo_proposal_key.rs b/src/orphans/undo_transactions/undo_proposal_key.rs new file mode 100644 index 0000000..d5a0dc3 --- /dev/null +++ b/src/orphans/undo_transactions/undo_proposal_key.rs @@ -0,0 +1,46 @@ +use crate::blocks::proposal_key::ProposalKey; +use crate::common::governance::PROPOSAL_KEYS_TREE; +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::wallet_tx_index::remove_wallet_transaction_index; +use crate::sled::Db; + +pub async fn undo_proposal_key_transaction( + transaction: ProposalKey, + mining_receiver: &str, + db: &Db, +) -> Result<(), String> { + let proposal = &transaction.unsigned_proposalkey; + let txhash = proposal.hash().await; + let txhash_bytes = decode(&txhash) + .map_err(|err| format!("Could not decode proposal key txhash during undo: {err}"))?; + + let _ = balance_sheet_operation_with_db( + db, + mining_receiver, + proposal.txfee, + &BASECOIN, + "subtraction", + ); + let _ = balance_sheet_operation_with_db( + db, + &proposal.address, + proposal.txfee, + &BASECOIN, + "addition", + ); + + db.open_tree(PROPOSAL_KEYS_TREE) + .map_err(|err| format!("Could not open proposal keys tree during undo: {err}"))? + .remove(&txhash_bytes) + .map_err(|err| format!("Could not remove proposal key during undo: {err}"))?; + db.open_tree("txid") + .map_err(|err| format!("Could not open txid tree during proposal key undo: {err}"))? + .remove(&txhash_bytes) + .map_err(|err| format!("Could not remove proposal key txid during undo: {err}"))?; + let _ = + remove_wallet_transaction_index(db, &[&proposal.address, mining_receiver], &txhash_bytes); + + Ok(()) +} diff --git a/src/orphans/undo_transactions/undo_proposal_vote.rs b/src/orphans/undo_transactions/undo_proposal_vote.rs new file mode 100644 index 0000000..c4a8990 --- /dev/null +++ b/src/orphans/undo_transactions/undo_proposal_vote.rs @@ -0,0 +1,60 @@ +use crate::blocks::proposal_vote::ProposalVote; +use crate::common::governance::{ + proposal_vote_record_key, FINISHED_PROPOSAL_VOTES_TREE, PROPOSAL_VOTES_TREE, +}; +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::wallet_tx_index::remove_wallet_transaction_index; +use crate::sled::Db; + +pub async fn undo_proposal_vote_transaction( + transaction: ProposalVote, + mining_receiver: &str, + block_height: u32, + db: &Db, +) -> Result<(), String> { + let vote = &transaction.unsigned_proposalvote; + let txhash = vote.hash().await; + let txhash_bytes = decode(&txhash) + .map_err(|err| format!("Could not decode proposal vote txhash during undo: {err}"))?; + let proposal_key = decode(&vote.proposal_key) + .map_err(|err| format!("Could not decode proposal key during undo: {err}"))?; + let vote_key = proposal_vote_record_key(&vote.proposal_key, &vote.address)?; + + let _ = + balance_sheet_operation_with_db(db, mining_receiver, vote.txfee, &BASECOIN, "subtraction"); + let _ = balance_sheet_operation_with_db(db, &vote.address, vote.txfee, &BASECOIN, "addition"); + + db.open_tree(PROPOSAL_VOTES_TREE) + .map_err(|err| format!("Could not open proposal votes during undo: {err}"))? + .remove(vote_key) + .map_err(|err| format!("Could not remove proposal vote during undo: {err}"))?; + + let finished = db + .open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .map_err(|err| format!("Could not open finished proposal votes during undo: {err}"))?; + if let Some(value) = finished + .get(&proposal_key) + .map_err(|err| format!("Could not read proposal finalization during undo: {err}"))? + { + if value.len() == 28 { + let finalized_height = u32::from_le_bytes(value[24..28].try_into().map_err(|_| { + "Finished proposal vote contains an invalid block height.".to_string() + })?); + if finalized_height == block_height { + finished.remove(&proposal_key).map_err(|err| { + format!("Could not remove proposal finalization during undo: {err}") + })?; + } + } + } + + db.open_tree("txid") + .map_err(|err| format!("Could not open txid tree during proposal vote undo: {err}"))? + .remove(&txhash_bytes) + .map_err(|err| format!("Could not remove proposal vote txid during undo: {err}"))?; + let _ = remove_wallet_transaction_index(db, &[&vote.address, mining_receiver], &txhash_bytes); + + Ok(()) +} diff --git a/src/records/memory/mempool/lookups.rs b/src/records/memory/mempool/lookups.rs index 782aa4e..f595361 100644 --- a/src/records/memory/mempool/lookups.rs +++ b/src/records/memory/mempool/lookups.rs @@ -1,5 +1,38 @@ use super::*; +pub async fn proposal_vote_exists(proposal_key: &str, address: &str) -> Result { + let client_handle = db_client().await?; + let row = client_handle + .query_one( + "SELECT EXISTS( + SELECT 1 FROM proposal_vote + WHERE proposal_key = $1 AND address = $2 AND processed = false + )", + &[&proposal_key, &address], + ) + .await?; + Ok(row.get(0)) +} + +pub async fn activation_vote_exists( + proposal_key: &str, + development_hash: &str, + address: &str, +) -> Result { + let client_handle = db_client().await?; + let row = client_handle + .query_one( + "SELECT EXISTS( + SELECT 1 FROM activation_vote + WHERE proposal_key = $1 AND development_hash = $2 + AND address = $3 AND processed = false + )", + &[&proposal_key, &development_hash, &address], + ) + .await?; + Ok(row.get(0)) +} + pub async fn exact_transaction_exists(signature: &str, original: &[u8]) -> Result { let client_handle = db_client().await?; let client = client_handle.as_ref(); @@ -26,6 +59,9 @@ pub async fn exact_transaction_exists(signature: &str, original: &[u8]) -> Resul OR EXISTS (SELECT 1 FROM loan_payment k WHERE k.signature = $1 AND k.original = $2 AND k.processed = false) OR EXISTS (SELECT 1 FROM collateral_claim l WHERE l.signature = $1 AND l.original = $2 AND l.processed = false) OR EXISTS (SELECT 1 FROM storage_key sk WHERE sk.signature = $1 AND sk.original = $2 AND sk.processed = false) + OR EXISTS (SELECT 1 FROM proposal_key pk WHERE pk.signature = $1 AND pk.original = $2 AND pk.processed = false) + OR EXISTS (SELECT 1 FROM proposal_vote pv WHERE pv.signature = $1 AND pv.original = $2 AND pv.processed = false) + OR EXISTS (SELECT 1 FROM activation_vote av WHERE av.signature = $1 AND av.original = $2 AND av.processed = false) OR EXISTS (SELECT 1 FROM storage_value sv WHERE sv.signature = $1 AND sv.original = $2 AND sv.processed = false) THEN 1 ELSE 0 @@ -80,6 +116,12 @@ pub async fn transaction_by_signature(signature: &str) -> RpcResponse { UNION ALL SELECT original FROM storage_key WHERE signature = $1 AND processed = false LIMIT 1 UNION ALL + SELECT original FROM proposal_key WHERE signature = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM proposal_vote WHERE signature = $1 AND processed = false LIMIT 1 + UNION ALL + SELECT original FROM activation_vote WHERE signature = $1 AND processed = false LIMIT 1 + UNION ALL SELECT original FROM storage_value WHERE signature = $1 AND processed = false LIMIT 1 ) AS subquery LIMIT 1 "#, @@ -139,6 +181,12 @@ pub async fn transactions_by_address(db: &Db, address: &str) -> RpcResponse { UNION ALL SELECT original FROM storage_key WHERE address = ANY($1) AND processed = false UNION ALL + SELECT original FROM proposal_key WHERE address = ANY($1) AND processed = false + UNION ALL + SELECT original FROM proposal_vote WHERE address = ANY($1) AND processed = false + UNION ALL + SELECT original FROM activation_vote WHERE address = ANY($1) AND processed = false + UNION ALL SELECT original FROM storage_value WHERE address = ANY($1) AND processed = false ) AS subquery; "#, @@ -213,6 +261,15 @@ pub async fn latest_pending_txids_by_address(db: &Db, address: &str, limit: usiz SELECT hash, time, id AS source_id FROM storage_key WHERE address = ANY($1) AND processed = false UNION ALL + SELECT hash, time, id AS source_id FROM proposal_key + WHERE address = ANY($1) AND processed = false + UNION ALL + SELECT hash, time, id AS source_id FROM proposal_vote + WHERE address = ANY($1) AND processed = false + UNION ALL + SELECT hash, time, id AS source_id FROM activation_vote + WHERE address = ANY($1) AND processed = false + UNION ALL SELECT hash, time, id AS source_id FROM storage_value WHERE address = ANY($1) AND processed = false ) AS pending @@ -276,6 +333,12 @@ pub async fn pending_transaction_by_txid(txid: &[u8]) -> Option> { UNION ALL SELECT original FROM storage_key WHERE hash = $1 AND processed = false UNION ALL + SELECT original FROM proposal_key WHERE hash = $1 AND processed = false + UNION ALL + SELECT original FROM proposal_vote WHERE hash = $1 AND processed = false + UNION ALL + SELECT original FROM activation_vote WHERE hash = $1 AND processed = false + UNION ALL SELECT original FROM storage_value WHERE hash = $1 AND processed = false ) AS subquery LIMIT 1 "#, @@ -325,6 +388,12 @@ pub async fn largest_fee() -> RpcResponse { UNION ALL SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM storage_key UNION ALL + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM proposal_key + UNION ALL + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM proposal_vote + UNION ALL + SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM activation_vote + UNION ALL SELECT CAST(MAX(fee) AS BIGINT) AS fee FROM storage_value ) AS combined_max_txids; "#, @@ -507,6 +576,9 @@ pub async fn get_basecoin_balance( + COALESCE((SELECT SUM(v.fee) FROM vanity_address v WHERE v.address = ANY($1) AND v.processed = false), 0) + COALESCE((SELECT SUM(cc.fee) FROM collateral_claim cc WHERE cc.address = ANY($1) AND cc.processed = false), 0) + COALESCE((SELECT SUM(sk.fee) FROM storage_key sk WHERE sk.address = ANY($1) AND sk.processed = false), 0) + + COALESCE((SELECT SUM(pk.fee) FROM proposal_key pk WHERE pk.address = ANY($1) AND pk.processed = false), 0) + + COALESCE((SELECT SUM(pv.fee) FROM proposal_vote pv WHERE pv.address = ANY($1) AND pv.processed = false), 0) + + COALESCE((SELECT SUM(av.fee) FROM activation_vote av WHERE av.address = ANY($1) AND av.processed = false), 0) + COALESCE((SELECT SUM(sv.fee) FROM storage_value sv WHERE sv.address = ANY($1) AND sv.processed = false), 0) + COALESCE((SELECT SUM(lc.fee) FROM loan_contract lc WHERE lc.lender = ANY($1) AND lc.processed = false), 0) + COALESCE((SELECT SUM(lp.fee) FROM loan_payment lp WHERE lp.address = ANY($1) AND lp.processed = false), 0) @@ -605,6 +677,12 @@ pub async fn total_transactions() -> RpcResponse { UNION ALL SELECT COUNT(*) AS row_count FROM storage_key UNION ALL + SELECT COUNT(*) AS row_count FROM proposal_key + UNION ALL + SELECT COUNT(*) AS row_count FROM proposal_vote + UNION ALL + SELECT COUNT(*) AS row_count FROM activation_vote + UNION ALL SELECT COUNT(*) AS row_count FROM storage_value ) AS combined; "#, diff --git a/src/records/memory/mempool/mod.rs b/src/records/memory/mempool/mod.rs index f609c09..b7a8f8d 100644 --- a/src/records/memory/mempool/mod.rs +++ b/src/records/memory/mempool/mod.rs @@ -158,6 +158,27 @@ enum SelectedMempoolTransaction { address: String, hash: String, }, + ProposalKey { + id: i64, + fee: i64, + address: String, + hash: String, + original: Vec, + }, + ProposalVote { + id: i64, + fee: i64, + address: String, + hash: String, + original: Vec, + }, + ActivationVote { + id: i64, + fee: i64, + address: String, + hash: String, + original: Vec, + }, StorageValue { id: i64, fee: i64, @@ -190,6 +211,9 @@ impl SelectedMempoolTransaction { SelectedMempoolTransaction::Borrower { .. } => "loan_payment", SelectedMempoolTransaction::Collateral { .. } => "collateral_claim", SelectedMempoolTransaction::StorageKey { .. } => "storage_key", + SelectedMempoolTransaction::ProposalKey { .. } => "proposal_key", + SelectedMempoolTransaction::ProposalVote { .. } => "proposal_vote", + SelectedMempoolTransaction::ActivationVote { .. } => "activation_vote", SelectedMempoolTransaction::StorageValue { .. } => "storage_value", } } @@ -208,6 +232,9 @@ impl SelectedMempoolTransaction { | SelectedMempoolTransaction::Borrower { id, .. } | SelectedMempoolTransaction::Collateral { id, .. } | SelectedMempoolTransaction::StorageKey { id, .. } + | SelectedMempoolTransaction::ProposalKey { id, .. } + | SelectedMempoolTransaction::ProposalVote { id, .. } + | SelectedMempoolTransaction::ActivationVote { id, .. } | SelectedMempoolTransaction::StorageValue { id, .. } => *id, } } @@ -225,10 +252,10 @@ mod schema; mod selection; pub use lookups::{ - exact_transaction_exists, get_basecoin_balance, get_coin_balance, + activation_vote_exists, exact_transaction_exists, get_basecoin_balance, get_coin_balance, get_pending_payments_for_contract, largest_fee, latest_pending_txids_by_address, - pending_transaction_by_txid, total_transactions, transaction_by_signature, - transactions_by_address, + pending_transaction_by_txid, proposal_vote_exists, total_transactions, + transaction_by_signature, transactions_by_address, }; pub use processing::{ delete_by_signatures, delete_unprocessed_by_address, mark_processed_by_signatures, @@ -402,6 +429,9 @@ async fn delete_processed_before_or_at(block_number: u32, limit: i64) -> Result< delete_processed_rows_limited("loan_payment", bn, limit).await?; delete_processed_rows_limited("collateral_claim", bn, limit).await?; delete_processed_rows_limited("storage_key", bn, limit).await?; + delete_processed_rows_limited("proposal_key", bn, limit).await?; + delete_processed_rows_limited("proposal_vote", bn, limit).await?; + delete_processed_rows_limited("activation_vote", bn, limit).await?; delete_processed_rows_limited("storage_value", bn, limit).await?; Ok(()) diff --git a/src/records/memory/mempool/processing.rs b/src/records/memory/mempool/processing.rs index 1b239c3..82549db 100644 --- a/src/records/memory/mempool/processing.rs +++ b/src/records/memory/mempool/processing.rs @@ -47,6 +47,14 @@ pub async fn mark_selected_transactions_processed( ) .await?; mark_rows_by_ids("storage_key", &ids_for_table(batch, "storage_key"), bn).await?; + mark_rows_by_ids("proposal_key", &ids_for_table(batch, "proposal_key"), bn).await?; + mark_rows_by_ids("proposal_vote", &ids_for_table(batch, "proposal_vote"), bn).await?; + mark_rows_by_ids( + "activation_vote", + &ids_for_table(batch, "activation_vote"), + bn, + ) + .await?; mark_rows_by_ids("storage_value", &ids_for_table(batch, "storage_value"), bn).await?; Ok(()) @@ -71,6 +79,9 @@ pub async fn restore_selected_transactions_processed(batch: &SelectedMempoolBatc ) .await?; unmark_rows_by_ids("storage_key", &ids_for_table(batch, "storage_key")).await?; + unmark_rows_by_ids("proposal_key", &ids_for_table(batch, "proposal_key")).await?; + unmark_rows_by_ids("proposal_vote", &ids_for_table(batch, "proposal_vote")).await?; + unmark_rows_by_ids("activation_vote", &ids_for_table(batch, "activation_vote")).await?; unmark_rows_by_ids("storage_value", &ids_for_table(batch, "storage_value")).await?; Ok(()) @@ -101,6 +112,9 @@ pub async fn restore_processed_by_signatures(signatures: &[String]) -> Result 0) @@ -233,6 +247,24 @@ pub async fn mark_processed_by_signatures(signatures: &[String], block_number: u signatures, ) .await?; + pg_execute_block_signatures( + "UPDATE proposal_key SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", + bn, + signatures, + ) + .await?; + pg_execute_block_signatures( + "UPDATE proposal_vote SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", + bn, + signatures, + ) + .await?; + pg_execute_block_signatures( + "UPDATE activation_vote SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", + bn, + signatures, + ) + .await?; pg_execute_block_signatures( "UPDATE storage_value SET processed=true, processed_block_number=$1 WHERE signature = ANY($2)", bn, @@ -296,6 +328,21 @@ pub async fn delete_by_signatures(signatures: &[String]) -> Result<()> { signatures, ) .await?; + pg_execute_signatures( + "DELETE FROM proposal_key WHERE signature = ANY($1)", + signatures, + ) + .await?; + pg_execute_signatures( + "DELETE FROM proposal_vote WHERE signature = ANY($1)", + signatures, + ) + .await?; + pg_execute_signatures( + "DELETE FROM activation_vote WHERE signature = ANY($1)", + signatures, + ) + .await?; pg_execute_signatures( "DELETE FROM storage_value WHERE signature = ANY($1)", signatures, @@ -374,6 +421,21 @@ pub async fn delete_unprocessed_by_address(db: &Db, address: &str) -> Result Result<()> { original BYTEA NOT NULL ); + CREATE TABLE IF NOT EXISTS proposal_key ( + id BIGSERIAL PRIMARY KEY, + time INTEGER NOT NULL, + fee BIGINT NOT NULL, + proposal_hash VARCHAR(64) NOT NULL, + address TEXT NOT NULL, + hash VARCHAR(64) NOT NULL, + signature TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + CREATE TABLE IF NOT EXISTS proposal_vote ( + id BIGSERIAL PRIMARY KEY, + time INTEGER NOT NULL, + fee BIGINT NOT NULL, + proposal_key VARCHAR(64) NOT NULL, + address TEXT NOT NULL, + vote SMALLINT NOT NULL, + hash VARCHAR(64) NOT NULL, + signature TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + + CREATE TABLE IF NOT EXISTS activation_vote ( + id BIGSERIAL PRIMARY KEY, + time INTEGER NOT NULL, + fee BIGINT NOT NULL, + proposal_key VARCHAR(64) NOT NULL, + development_hash VARCHAR(64) NOT NULL, + development_location VARCHAR(100) NOT NULL, + address TEXT NOT NULL, + vote SMALLINT NOT NULL, + hash VARCHAR(64) NOT NULL, + signature TEXT NOT NULL, + processed bool DEFAULT false, + processed_block_number INTEGER DEFAULT NULL, + original BYTEA NOT NULL + ); + CREATE TABLE IF NOT EXISTS storage_value ( id BIGSERIAL PRIMARY KEY, txtype SMALLINT NOT NULL, @@ -341,6 +384,12 @@ pub async fn setup_mempool() -> Result<()> { ALTER TABLE collateral_claim ALTER COLUMN signature TYPE TEXT; ALTER TABLE storage_key ALTER COLUMN address TYPE TEXT; ALTER TABLE storage_key ALTER COLUMN signature TYPE TEXT; + ALTER TABLE proposal_key ALTER COLUMN address TYPE TEXT; + ALTER TABLE proposal_key ALTER COLUMN signature TYPE TEXT; + ALTER TABLE proposal_vote ALTER COLUMN address TYPE TEXT; + ALTER TABLE proposal_vote ALTER COLUMN signature TYPE TEXT; + ALTER TABLE activation_vote ALTER COLUMN address TYPE TEXT; + ALTER TABLE activation_vote ALTER COLUMN signature TYPE TEXT; ALTER TABLE storage_value ALTER COLUMN address TYPE TEXT; ALTER TABLE storage_value ALTER COLUMN signature TYPE TEXT; "#; @@ -406,6 +455,18 @@ pub async fn setup_mempool() -> Result<()> { USING storage_key b WHERE a.id > b.id AND a.signature = b.signature; + DELETE FROM proposal_key a + USING proposal_key b + WHERE a.id > b.id AND a.signature = b.signature; + + DELETE FROM proposal_vote a + USING proposal_vote b + WHERE a.id > b.id AND a.signature = b.signature; + + DELETE FROM activation_vote a + USING activation_vote b + WHERE a.id > b.id AND a.signature = b.signature; + DELETE FROM storage_value a USING storage_value b WHERE a.id > b.id AND a.signature = b.signature; @@ -468,6 +529,20 @@ pub async fn setup_mempool() -> Result<()> { CREATE INDEX IF NOT EXISTS storage_key_sig_idx ON storage_key (signature); CREATE UNIQUE INDEX IF NOT EXISTS storage_key_sig_unique_idx ON storage_key (signature); + CREATE INDEX IF NOT EXISTS proposal_key_pick_idx ON proposal_key (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS proposal_key_sig_idx ON proposal_key (signature); + CREATE UNIQUE INDEX IF NOT EXISTS proposal_key_sig_unique_idx ON proposal_key (signature); + + CREATE INDEX IF NOT EXISTS proposal_vote_pick_idx ON proposal_vote (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS proposal_vote_sig_idx ON proposal_vote (signature); + CREATE UNIQUE INDEX IF NOT EXISTS proposal_vote_sig_unique_idx ON proposal_vote (signature); + CREATE UNIQUE INDEX IF NOT EXISTS proposal_vote_node_unique_idx ON proposal_vote (proposal_key, address); + + CREATE INDEX IF NOT EXISTS activation_vote_pick_idx ON activation_vote (processed, fee DESC, time ASC, id ASC); + CREATE INDEX IF NOT EXISTS activation_vote_sig_idx ON activation_vote (signature); + CREATE UNIQUE INDEX IF NOT EXISTS activation_vote_sig_unique_idx ON activation_vote (signature); + CREATE UNIQUE INDEX IF NOT EXISTS activation_vote_node_unique_idx ON activation_vote (proposal_key, development_hash, address); + CREATE INDEX IF NOT EXISTS storage_value_pick_idx ON storage_value (processed, fee DESC, time ASC, id ASC); CREATE INDEX IF NOT EXISTS storage_value_sig_idx ON storage_value (signature); CREATE UNIQUE INDEX IF NOT EXISTS storage_value_sig_unique_idx ON storage_value (signature); @@ -504,6 +579,9 @@ pub async fn clear_mempool() -> Result<()> { loan_payment, collateral_claim, storage_key, + proposal_key, + proposal_vote, + activation_vote, storage_value RESTART IDENTITY; "#, diff --git a/src/records/memory/mempool/selection.rs b/src/records/memory/mempool/selection.rs index 99b9e8d..de45153 100644 --- a/src/records/memory/mempool/selection.rs +++ b/src/records/memory/mempool/selection.rs @@ -1,17 +1,24 @@ use super::*; +use crate::blocks::activation_vote::ActivationVote; use crate::blocks::delete_key::{delete_record_keys, delete_rollback_key, DeleteKey}; +use crate::blocks::proposal_vote::ProposalVote; use crate::blocks::storage_values::{ storage_record_key, storage_string_record_key, StorageBool, StorageI128, StorageI16, StorageI32, StorageI64, StorageI8, StorageString, StorageU128, StorageU16, StorageU32, StorageU64, StorageU8, StorageValueTransaction, }; +use crate::common::governance::{ + activation_candidate_key, activation_vote_record_key, proposal_vote_record_key, +}; use crate::common::types::{ Transaction, DELETE_KEY_TYPE, STORAGE_BOOL_TYPE, STORAGE_I128_TYPE, STORAGE_I16_TYPE, STORAGE_I32_TYPE, STORAGE_I64_TYPE, STORAGE_I8_TYPE, STORAGE_STRING_TYPE, STORAGE_U128_TYPE, STORAGE_U16_TYPE, STORAGE_U32_TYPE, STORAGE_U64_TYPE, STORAGE_U8_TYPE, }; +use crate::records::memory::network_mapping::NodeInfo; use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; use crate::records::record_chain::wallet_tx_index::index_wallet_transaction; +use crate::wallets::structures::Wallet; struct SelectedStorageValueParts { storagekey: String, @@ -342,6 +349,63 @@ pub async fn select_transactions_for_block(limit: i64) -> Result Result SelectedMempoolTransaction::ProposalKey { + id: row.get("id"), + fee: row.get("priority_fee"), + address: required_string(&row, "address")?, + hash: row.get("hash"), + original: row.get("original"), + }, + "proposal_vote" => SelectedMempoolTransaction::ProposalVote { + id: row.get("id"), + fee: row.get("priority_fee"), + address: required_string(&row, "address")?, + hash: row.get("hash"), + original: row.get("original"), + }, + "activation_vote" => SelectedMempoolTransaction::ActivationVote { + id: row.get("id"), + fee: row.get("priority_fee"), + address: required_string(&row, "address")?, + hash: row.get("hash"), + original: row.get("original"), + }, "storage_value" => SelectedMempoolTransaction::StorageValue { id: row.get("id"), fee: row.get("priority_fee"), @@ -507,6 +592,7 @@ pub async fn apply_selected_transaction_math( db: &Db, miner: String, block_number: u32, + block_timestamp: u32, start_index: usize, pending_effects: &mut PendingEffects, ) -> Result<()> { @@ -516,7 +602,7 @@ pub async fn apply_selected_transaction_math( let miner_address = address_key_bytes(db, &miner); let base_coin = BASECOIN.as_bytes().to_vec(); let mut tx_index = start_index; - for tx in &batch.transactions { + for (batch_index, tx) in batch.transactions.iter().enumerate() { // Transaction index is stored with the block number for later txid // lookups inside the saved block payload. tx_index = tx_index.wrapping_add(1); @@ -713,6 +799,23 @@ pub async fn apply_selected_transaction_math( hash, .. } => { + // Ownership type is part of the signed original bytes. Read + // it here so candidate-block effects match committed effects. + let original = batch + .originals + .get(batch_index) + .ok_or_else(|| anyhow!("Missing original NFT transaction bytes"))?; + if original.first().copied() != Some(4) { + return Err(anyhow!("Invalid original NFT transaction type")); + } + let nft_transaction = + crate::blocks::nft::CreateNftTransaction::from_bytes(4, &original[1..]) + .await + .map_err(|err| { + anyhow!("Failed to parse original NFT transaction: {err}") + })?; + let ownership_type = nft_transaction.unsigned_create_nft.ownership_type; + // Series-one NFT creation expands into numbered assets; later // series use the given NFT name as the asset key. if *series == 1 { @@ -729,7 +832,17 @@ pub async fn apply_selected_transaction_math( pending_effects.set_tree( "nfts", nft_save_name.as_bytes().to_vec(), - b"1".to_vec(), + vec![ownership_type], + ); + pending_effects.set_tree( + "nft_ownership", + nft_save_name.as_bytes().to_vec(), + vec![ownership_type], + ); + pending_effects.set_tree( + "nft_supply", + nft_save_name.as_bytes().to_vec(), + NFT_UNIT.to_le_bytes().to_vec(), ); pending_effects.set_tree( "nft_origins", @@ -745,7 +858,21 @@ pub async fn apply_selected_transaction_math( } else { add_balance_change(db, &mut balance_changes, creator, nft_name, NFT_UNIT); - pending_effects.set_tree("nfts", nft_name.as_bytes().to_vec(), b"1".to_vec()); + pending_effects.set_tree( + "nfts", + nft_name.as_bytes().to_vec(), + vec![ownership_type], + ); + pending_effects.set_tree( + "nft_ownership", + nft_name.as_bytes().to_vec(), + vec![ownership_type], + ); + pending_effects.set_tree( + "nft_supply", + nft_name.as_bytes().to_vec(), + NFT_UNIT.to_le_bytes().to_vec(), + ); pending_effects.set_tree( "nft_origins", nft_name.as_bytes().to_vec(), @@ -1133,6 +1260,166 @@ pub async fn apply_selected_transaction_math( &txhash_bytes, )?; } + SelectedMempoolTransaction::ProposalKey { + fee, + address, + hash, + original, + .. + } => { + add_balance_change(db, &mut balance_changes, address, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + let txhash_bytes = decode(hash)?; + pending_effects.set_tree( + crate::common::governance::PROPOSAL_KEYS_TREE, + txhash_bytes.clone(), + original.clone(), + ); + pending_effects.set_tree( + "txid", + txhash_bytes.clone(), + format!("{block_number}:{tx_index}").into_bytes(), + ); + index_selected_wallet_transaction( + pending_effects, + &[address], + block_number, + tx_index, + &txhash_bytes, + )?; + } + SelectedMempoolTransaction::ProposalVote { + fee, + address, + hash, + original, + .. + } => { + let (&txtype, body) = original + .split_first() + .ok_or_else(|| anyhow!("Empty proposal vote transaction bytes"))?; + let transaction = ProposalVote::from_bytes(txtype, body).await?; + let vote = &transaction.unsigned_proposalvote; + let proposal_key = decode(&vote.proposal_key)?; + let vote_key = proposal_vote_record_key(&vote.proposal_key, &vote.address) + .map_err(|err| anyhow!(err))?; + let eligible_addresses = NodeInfo::governance_countable_nodes_for_proposal( + db, + &vote.proposal_key, + (block_timestamp as u64) * 1_000, + block_number, + ) + .await + .map_err(|err| anyhow!(err))? + .into_iter() + .map(|node| { + Wallet::short_address_to_bytes(&node.address) + .ok_or_else(|| anyhow!("Eligible governance node has an invalid address")) + }) + .collect::>>()?; + + add_balance_change(db, &mut balance_changes, address, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + pending_effects.proposal_vote( + proposal_key, + vote_key, + original.clone(), + eligible_addresses, + block_number, + ); + let txhash_bytes = decode(hash)?; + pending_effects.set_tree( + "txid", + txhash_bytes.clone(), + format!("{block_number}:{tx_index}").into_bytes(), + ); + index_selected_wallet_transaction( + pending_effects, + &[address], + block_number, + tx_index, + &txhash_bytes, + )?; + } + SelectedMempoolTransaction::ActivationVote { + fee, + address, + hash, + original, + .. + } => { + let (&txtype, body) = original + .split_first() + .ok_or_else(|| anyhow!("Empty activation vote transaction bytes"))?; + let transaction = ActivationVote::from_bytes(txtype, body).await?; + let vote = &transaction.unsigned_activationvote; + let proposal_key = decode(&vote.proposal_key)?; + let candidate_key = + activation_candidate_key(&vote.proposal_key, &vote.development_hash) + .map_err(|err| anyhow!(err))?; + let vote_key = activation_vote_record_key( + &vote.proposal_key, + &vote.development_hash, + &vote.address, + ) + .map_err(|err| anyhow!(err))?; + let eligible_addresses = NodeInfo::governance_countable_nodes_for_activation( + db, + &vote.proposal_key, + &vote.development_hash, + (block_timestamp as u64) * 1_000, + block_number, + ) + .await + .map_err(|err| anyhow!(err))? + .into_iter() + .map(|node| { + Wallet::short_address_to_bytes(&node.address) + .ok_or_else(|| anyhow!("Eligible governance node has an invalid address")) + }) + .collect::>>()?; + + add_balance_change(db, &mut balance_changes, address, &BASECOIN, -*fee); + add_balance_change_bytes( + &mut balance_changes, + miner_address.clone(), + base_coin.clone(), + *fee, + ); + pending_effects.activation_vote( + proposal_key, + candidate_key, + vote_key, + original.clone(), + vote.development_hash.clone(), + vote.development_location.clone(), + eligible_addresses, + block_number, + ); + let txhash_bytes = decode(hash)?; + pending_effects.set_tree( + "txid", + txhash_bytes.clone(), + format!("{block_number}:{tx_index}").into_bytes(), + ); + index_selected_wallet_transaction( + pending_effects, + &[address], + block_number, + tx_index, + &txhash_bytes, + )?; + } SelectedMempoolTransaction::StorageValue { fee, address, @@ -1234,6 +1521,7 @@ pub async fn clear_selected_transaction_sql( db, miner, block_number, + crate::Utc::now().timestamp() as u32, start_index, &mut pending_effects, ) @@ -1278,6 +1566,9 @@ pub async fn delete_selected_transactions(batch: &SelectedMempoolBatch) -> Resul let borrower_ids = ids_for_table(batch, "loan_payment"); let collateral_ids = ids_for_table(batch, "collateral_claim"); let storage_key_ids = ids_for_table(batch, "storage_key"); + let proposal_key_ids = ids_for_table(batch, "proposal_key"); + let proposal_vote_ids = ids_for_table(batch, "proposal_vote"); + let activation_vote_ids = ids_for_table(batch, "activation_vote"); let storage_value_ids = ids_for_table(batch, "storage_value"); delete_rows("transfer", &transfer_ids).await?; @@ -1292,6 +1583,9 @@ pub async fn delete_selected_transactions(batch: &SelectedMempoolBatch) -> Resul delete_rows("loan_payment", &borrower_ids).await?; delete_rows("collateral_claim", &collateral_ids).await?; delete_rows("storage_key", &storage_key_ids).await?; + delete_rows("proposal_key", &proposal_key_ids).await?; + delete_rows("proposal_vote", &proposal_vote_ids).await?; + delete_rows("activation_vote", &activation_vote_ids).await?; delete_rows("storage_value", &storage_value_ids).await?; Ok(()) diff --git a/src/records/memory/network_mapping/queries.rs b/src/records/memory/network_mapping/queries.rs index 8b268f5..3514a9e 100644 --- a/src/records/memory/network_mapping/queries.rs +++ b/src/records/memory/network_mapping/queries.rs @@ -1,6 +1,119 @@ use super::*; +use crate::common::governance::{ + activation_vote_record_key, countable_governance_addresses, proposal_vote_record_key, + GovernanceNodeSnapshot, ACTIVATION_VOTES_TREE, PROPOSAL_VOTES_TREE, +}; +use crate::sled::Db; +use std::collections::HashSet; impl NodeInfo { + pub async fn governance_node_snapshot(address: &str) -> Option { + let map = ADDRESS_MAP.lock().await; + map.get(address).map(|node| GovernanceNodeSnapshot { + address: address.to_string(), + ip: node.ip.clone(), + blocks_mined: node.blocks_mined, + added_timestamp: node.added_timestamp, + deleted_block: node.deleted_block, + }) + } + + pub async fn governance_node_snapshots() -> Vec { + let map = ADDRESS_MAP.lock().await; + let mut snapshots: Vec = map + .iter() + .map(|(address, node)| GovernanceNodeSnapshot { + address: address.clone(), + ip: node.ip.clone(), + blocks_mined: node.blocks_mined, + added_timestamp: node.added_timestamp, + deleted_block: node.deleted_block, + }) + .collect(); + + snapshots.sort_by(|left, right| left.address.cmp(&right.address)); + snapshots + } + + pub async fn governance_eligible_nodes( + reference_timestamp_millis: u64, + current_height: u32, + ) -> Vec { + Self::governance_node_snapshots() + .await + .into_iter() + .filter(|node| { + node.is_eligible_for_governance_count(reference_timestamp_millis, current_height) + }) + .collect() + } + + pub async fn governance_countable_nodes_for_proposal( + db: &Db, + proposal_key: &str, + reference_timestamp_millis: u64, + current_height: u32, + ) -> Result, String> { + let votes = db + .open_tree(PROPOSAL_VOTES_TREE) + .map_err(|err| format!("Could not open proposal votes: {err}"))?; + let snapshots = Self::governance_node_snapshots().await; + let mut existing_voters = HashSet::new(); + for node in &snapshots { + let vote_key = proposal_vote_record_key(proposal_key, &node.address)?; + if votes + .contains_key(vote_key) + .map_err(|err| format!("Could not check existing proposal vote: {err}"))? + { + existing_voters.insert(node.address.clone()); + } + } + let countable = countable_governance_addresses( + &snapshots, + &existing_voters, + reference_timestamp_millis, + current_height, + ); + Ok(snapshots + .into_iter() + .filter(|node| countable.contains(&node.address)) + .collect()) + } + + pub async fn governance_countable_nodes_for_activation( + db: &Db, + proposal_key: &str, + development_hash: &str, + reference_timestamp_millis: u64, + current_height: u32, + ) -> Result, String> { + let votes = db + .open_tree(ACTIVATION_VOTES_TREE) + .map_err(|err| format!("Could not open activation votes: {err}"))?; + let snapshots = Self::governance_node_snapshots().await; + let mut existing_voters = HashSet::new(); + for node in &snapshots { + let vote_key = + activation_vote_record_key(proposal_key, development_hash, &node.address)?; + if votes + .contains_key(vote_key) + .map_err(|err| format!("Could not check existing activation vote: {err}"))? + { + existing_voters.insert(node.address.clone()); + } + } + let countable = countable_governance_addresses( + &snapshots, + &existing_voters, + reference_timestamp_millis, + current_height, + ); + Ok(snapshots + .into_iter() + .filter(|node| countable.contains(&node.address)) + .collect()) + } + pub async fn address_checkup(address: &str, block_number: u32) -> bool { let map = ADDRESS_MAP.lock().await; if let Some(node_info) = map.get(address) { diff --git a/src/records/record_chain/activation_vote_tx.rs b/src/records/record_chain/activation_vote_tx.rs new file mode 100644 index 0000000..0a89424 --- /dev/null +++ b/src/records/record_chain/activation_vote_tx.rs @@ -0,0 +1,88 @@ +use crate::blocks::activation_vote::ActivationVote; +use crate::common::governance::{activation_candidate_key, activation_vote_record_key}; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::records::record_chain::wallet_tx_index::index_wallet_transaction; +use crate::sled::Db; +use crate::wallets::structures::Wallet; +use crate::Arc; +use crate::Mutex; + +#[allow(clippy::too_many_arguments)] +pub async fn process_activation_vote( + transaction: &ActivationVote, + mut binary_data: Vec, + db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + block_timestamp: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + let mut index = index_counter.lock().await; + **index += 1; + + let vote = &transaction.unsigned_activationvote; + let txhash = vote.hash().await; + let txhash_bytes = + decode(&txhash).map_err(|err| format!("Could not decode activation vote txhash: {err}"))?; + let proposal_key = decode(&vote.proposal_key) + .map_err(|err| format!("Could not decode proposal key: {err}"))?; + let candidate_key = activation_candidate_key(&vote.proposal_key, &vote.development_hash)?; + let vote_key = + activation_vote_record_key(&vote.proposal_key, &vote.development_hash, &vote.address)?; + let transaction_bytes = transaction + .to_bytes() + .await + .map_err(|err| format!("Could not serialize activation vote transaction: {err}"))?; + binary_data.extend(&transaction_bytes); + + let eligible_addresses = NodeInfo::governance_countable_nodes_for_activation( + db, + &vote.proposal_key, + &vote.development_hash, + (block_timestamp as u64) * 1_000, + block_header_number, + ) + .await? + .into_iter() + .map(|node| { + Wallet::short_address_to_bytes(&node.address) + .ok_or_else(|| "Eligible governance node has an invalid address.".to_string()) + }) + .collect::, _>>()?; + + pending_effects.activation_vote( + proposal_key, + candidate_key, + vote_key, + transaction_bytes, + vote.development_hash.clone(), + vote.development_location.clone(), + eligible_addresses, + block_header_number, + ); + pending_effects.add_balance(&miner, vote.txfee, &BASECOIN, BalanceOperand::Addition); + pending_effects.add_balance( + &vote.address, + vote.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + pending_effects.set_tree( + "txid", + txhash_bytes.clone(), + format!("{block_header_number}:{}", **index).into_bytes(), + ); + index_wallet_transaction( + pending_effects, + &[&vote.address], + 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 c521d6e..1c9e2cd 100644 --- a/src/records/record_chain/mod.rs +++ b/src/records/record_chain/mod.rs @@ -1,4 +1,5 @@ // The record_chain module contains the block-save processors for each transaction family. +pub mod activation_vote_tx; pub mod add_payments_db; pub mod borrower_tx; pub mod burn_tx; @@ -16,6 +17,8 @@ pub mod nft_tx; pub mod parse_transactions; pub mod pending_effects; pub mod previous_difficulty; +pub mod proposal_key_tx; +pub mod proposal_vote_tx; pub mod rewards_tx; pub mod save; pub mod save_flags; diff --git a/src/records/record_chain/nft_tx.rs b/src/records/record_chain/nft_tx.rs index 4414fba..af0a930 100644 --- a/src/records/record_chain/nft_tx.rs +++ b/src/records/record_chain/nft_tx.rs @@ -1,5 +1,5 @@ use crate::blocks::nft::CreateNftTransaction; -use crate::common::nft_assets::nft_asset_name; +use crate::common::nft_assets::{nft_asset_name, NFT_UNIT}; use crate::decode; use crate::records::memory::mempool::BASECOIN; use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; @@ -8,8 +8,6 @@ use crate::sled::Db; use crate::Arc; use crate::Mutex; -const NFT_UNIT: u64 = 100_000_000; - pub async fn process_nft( transaction: &CreateNftTransaction, mut binary_data: Vec, @@ -60,7 +58,21 @@ pub async fn process_nft( &nft_save_name, BalanceOperand::Addition, ); - pending_effects.set_tree("nfts", nft_save_name.as_bytes().to_vec(), b"1".to_vec()); + pending_effects.set_tree( + "nfts", + nft_save_name.as_bytes().to_vec(), + vec![transaction.unsigned_create_nft.ownership_type], + ); + pending_effects.set_tree( + "nft_ownership", + nft_save_name.as_bytes().to_vec(), + vec![transaction.unsigned_create_nft.ownership_type], + ); + pending_effects.set_tree( + "nft_supply", + nft_save_name.as_bytes().to_vec(), + NFT_UNIT.to_le_bytes().to_vec(), + ); pending_effects.set_tree( "nft_origins", nft_save_name.as_bytes().to_vec(), @@ -80,7 +92,21 @@ pub async fn process_nft( &nft_save_name, BalanceOperand::Addition, ); - pending_effects.set_tree("nfts", nft_save_name.as_bytes().to_vec(), b"1".to_vec()); + pending_effects.set_tree( + "nfts", + nft_save_name.as_bytes().to_vec(), + vec![transaction.unsigned_create_nft.ownership_type], + ); + pending_effects.set_tree( + "nft_ownership", + nft_save_name.as_bytes().to_vec(), + vec![transaction.unsigned_create_nft.ownership_type], + ); + pending_effects.set_tree( + "nft_supply", + nft_save_name.as_bytes().to_vec(), + NFT_UNIT.to_le_bytes().to_vec(), + ); pending_effects.set_tree( "nft_origins", nft_save_name.as_bytes().to_vec(), diff --git a/src/records/record_chain/parse_transactions.rs b/src/records/record_chain/parse_transactions.rs index c481881..0227f08 100644 --- a/src/records/record_chain/parse_transactions.rs +++ b/src/records/record_chain/parse_transactions.rs @@ -10,6 +10,8 @@ use crate::records::record_chain::lender_tx::process_lender; use crate::records::record_chain::marketing_tx::process_marketing; use crate::records::record_chain::nft_tx::process_nft; use crate::records::record_chain::pending_effects::PendingEffects; +use crate::records::record_chain::proposal_key_tx::process_proposal_key; +use crate::records::record_chain::proposal_vote_tx::process_proposal_vote; use crate::records::record_chain::rewards_tx::process_rewards; use crate::records::record_chain::storage_key_tx::process_storage_key; use crate::records::record_chain::storage_value_tx::process_storage_value; @@ -203,6 +205,44 @@ pub async fn handle_transactions( ) .await } + Transaction::ProposalKey(proposal_key_transaction) => { + process_proposal_key( + proposal_key_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + pending_effects, + ) + .await + } + Transaction::ProposalVote(proposal_vote_transaction) => { + process_proposal_vote( + proposal_vote_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + block.vrf_block.unmined_block.timestamp, + pending_effects, + ) + .await + } + Transaction::ActivationVote(activation_vote_transaction) => { + process_activation_vote( + activation_vote_transaction, + binary_data.clone(), + db, + index_mutex.clone(), + miner.clone(), + block_header_number, + block.vrf_block.unmined_block.timestamp, + pending_effects, + ) + .await + } Transaction::StorageBool(_) | Transaction::StorageU8(_) | Transaction::StorageU16(_) @@ -251,3 +291,4 @@ pub async fn handle_transactions( } Ok(binary_data) } +use crate::records::record_chain::activation_vote_tx::process_activation_vote; diff --git a/src/records/record_chain/pending_effects.rs b/src/records/record_chain/pending_effects.rs index 04f95ce..4b8bf9c 100644 --- a/src/records/record_chain/pending_effects.rs +++ b/src/records/record_chain/pending_effects.rs @@ -1,3 +1,13 @@ +use crate::blocks::activation_vote::ActivationVote; +use crate::blocks::proposal_vote::ProposalVote; +use crate::common::governance::{ + countable_governance_addresses, encode_consensus_activation, encode_finished_activation_vote, + encode_finished_vote, governance_activation_height, governance_approval_reached, + GovernanceNodeSnapshot, ACTIVATION_CANDIDATE_KEY_BYTES, ACTIVATION_VOTES_TREE, + ACTIVATION_VOTE_RECORD_KEY_BYTES, CONSENSUS_ACTIVATIONS_TREE, FINISHED_ACTIVATION_VOTES_TREE, + FINISHED_PROPOSAL_VOTES_TREE, GOVERNANCE_DEVELOPMENT_LOCATION_BYTES, + GOVERNANCE_DOCUMENT_HASH_BYTES, PROPOSAL_VOTES_TREE, PROPOSAL_VOTE_RECORD_KEY_BYTES, +}; use crate::common::types::{STORAGE_KEY_INDEX_TREE, STORAGE_VALUE_ROLLBACK_TREE}; use crate::log::error; use crate::records::balance_sheet::operations::balance_sheet_operation_with_db; @@ -7,6 +17,7 @@ use crate::records::wallet_registry::{ WALLET_VANITY_ROLLBACK_TREE, }; use crate::sled::Db; +use std::collections::{HashMap, HashSet}; #[derive(Clone, Copy)] pub enum BalanceOperand { @@ -71,6 +82,28 @@ pub enum PendingEffect { TreeCreate { name: Vec, }, + ProposalVote { + proposal_key: Vec, + vote_key: Vec, + value: Vec, + eligible_addresses: Vec>, + block_height: u32, + }, + ActivationVote { + proposal_key: Vec, + candidate_key: Vec, + vote_key: Vec, + value: Vec, + development_hash: String, + development_location: String, + eligible_addresses: Vec>, + block_height: u32, + }, + GovernanceLifecycle { + snapshots: Vec, + reference_timestamp_millis: u64, + block_height: u32, + }, TokenSupplyAdd { ticker: String, amount: u64, @@ -173,6 +206,60 @@ impl PendingEffects { self.push(PendingEffect::TreeCreate { name }); } + pub fn proposal_vote( + &mut self, + proposal_key: Vec, + vote_key: Vec, + value: Vec, + eligible_addresses: Vec>, + block_height: u32, + ) { + self.push(PendingEffect::ProposalVote { + proposal_key, + vote_key, + value, + eligible_addresses, + block_height, + }); + } + + #[allow(clippy::too_many_arguments)] + pub fn activation_vote( + &mut self, + proposal_key: Vec, + candidate_key: Vec, + vote_key: Vec, + value: Vec, + development_hash: String, + development_location: String, + eligible_addresses: Vec>, + block_height: u32, + ) { + self.push(PendingEffect::ActivationVote { + proposal_key, + candidate_key, + vote_key, + value, + development_hash, + development_location, + eligible_addresses, + block_height, + }); + } + + pub fn governance_lifecycle( + &mut self, + snapshots: Vec, + reference_timestamp_millis: u64, + block_height: u32, + ) { + self.push(PendingEffect::GovernanceLifecycle { + snapshots, + reference_timestamp_millis, + block_height, + }); + } + pub fn add_token_supply(&mut self, ticker: &str, amount: u64) { self.push(PendingEffect::TokenSupplyAdd { ticker: ticker.to_string(), @@ -448,6 +535,50 @@ fn apply_effect(db: &Db, effect: &PendingEffect) -> Result apply_proposal_vote_effect( + db, + proposal_key, + vote_key, + value, + eligible_addresses, + *block_height, + ), + PendingEffect::ActivationVote { + proposal_key, + candidate_key, + vote_key, + value, + development_hash, + development_location, + eligible_addresses, + block_height, + } => apply_activation_vote_effect( + db, + proposal_key, + candidate_key, + vote_key, + value, + development_hash, + development_location, + eligible_addresses, + *block_height, + ), + PendingEffect::GovernanceLifecycle { + snapshots, + reference_timestamp_millis, + block_height, + } => apply_governance_lifecycle_effect( + db, + snapshots, + *reference_timestamp_millis, + *block_height, + ), PendingEffect::TokenSupplyAdd { ticker, amount } => { let tree = db .open_tree("tokens") @@ -528,6 +659,471 @@ fn apply_effect(db: &Db, effect: &PendingEffect) -> Result Result { + let mut applied = Vec::new(); + let result = (|| { + finalize_open_proposals( + db, + snapshots, + reference_timestamp_millis, + block_height, + &mut applied, + )?; + finalize_open_activations( + db, + snapshots, + reference_timestamp_millis, + block_height, + &mut applied, + ) + })(); + + if let Err(err) = result { + while let Some(effect) = applied.pop() { + rollback_effect(db, effect)?; + } + return Err(err); + } + Ok(AppliedEffect::Composite(applied)) +} + +fn finalize_open_proposals( + db: &Db, + snapshots: &[GovernanceNodeSnapshot], + reference_timestamp_millis: u64, + block_height: u32, + applied: &mut Vec, +) -> Result<(), String> { + let votes = db + .open_tree(PROPOSAL_VOTES_TREE) + .map_err(|err| format!("Failed to open proposal votes tree: {err}"))?; + let finished = db + .open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .map_err(|err| format!("Failed to open finished proposal votes tree: {err}"))?; + let mut candidates: HashMap, Vec<(Vec, Vec)>> = HashMap::new(); + + for entry in votes.iter() { + let (key, value) = + entry.map_err(|err| format!("Failed to read proposal lifecycle vote: {err}"))?; + if key.len() != PROPOSAL_VOTE_RECORD_KEY_BYTES { + return Err("Stored proposal vote key has an invalid length.".to_string()); + } + candidates + .entry(key[..GOVERNANCE_DOCUMENT_HASH_BYTES].to_vec()) + .or_default() + .push((key.to_vec(), value.to_vec())); + } + + for (proposal_key, candidate_votes) in candidates { + if finished + .contains_key(&proposal_key) + .map_err(|err| format!("Failed to check proposal finalization: {err}"))? + { + continue; + } + let (yes, no, eligible) = count_lifecycle_votes( + snapshots, + &candidate_votes, + GOVERNANCE_DOCUMENT_HASH_BYTES, + ProposalVote::VOTE_BYTE_OFFSET, + reference_timestamp_millis, + block_height, + )?; + if !governance_approval_reached(yes, eligible) { + continue; + } + let value = encode_finished_vote(yes, no, eligible, block_height); + let previous = set_tree_value(db, FINISHED_PROPOSAL_VOTES_TREE, &proposal_key, &value)?; + applied.push(AppliedEffect::TreeMutation { + tree: FINISHED_PROPOSAL_VOTES_TREE, + key: proposal_key, + previous, + }); + } + Ok(()) +} + +fn finalize_open_activations( + db: &Db, + snapshots: &[GovernanceNodeSnapshot], + reference_timestamp_millis: u64, + block_height: u32, + applied: &mut Vec, +) -> Result<(), String> { + let votes = db + .open_tree(ACTIVATION_VOTES_TREE) + .map_err(|err| format!("Failed to open activation votes tree: {err}"))?; + let finished = db + .open_tree(FINISHED_ACTIVATION_VOTES_TREE) + .map_err(|err| format!("Failed to open finished activation votes tree: {err}"))?; + let activations = db + .open_tree(CONSENSUS_ACTIVATIONS_TREE) + .map_err(|err| format!("Failed to open consensus activations tree: {err}"))?; + let mut candidates: HashMap, Vec<(Vec, Vec)>> = HashMap::new(); + + for entry in votes.iter() { + let (key, value) = + entry.map_err(|err| format!("Failed to read activation lifecycle vote: {err}"))?; + if key.len() != ACTIVATION_VOTE_RECORD_KEY_BYTES { + return Err("Stored activation vote key has an invalid length.".to_string()); + } + candidates + .entry(key[..ACTIVATION_CANDIDATE_KEY_BYTES].to_vec()) + .or_default() + .push((key.to_vec(), value.to_vec())); + } + + for (candidate_key, candidate_votes) in candidates { + let proposal_key = candidate_key[..GOVERNANCE_DOCUMENT_HASH_BYTES].to_vec(); + if finished + .contains_key(&candidate_key) + .map_err(|err| format!("Failed to check activation finalization: {err}"))? + || activations + .contains_key(&proposal_key) + .map_err(|err| format!("Failed to check scheduled activation: {err}"))? + { + continue; + } + let (yes, no, eligible) = count_lifecycle_votes( + snapshots, + &candidate_votes, + ACTIVATION_CANDIDATE_KEY_BYTES, + ActivationVote::VOTE_BYTE_OFFSET, + reference_timestamp_millis, + block_height, + )?; + if !governance_approval_reached(yes, eligible) { + continue; + } + + let first_vote = &candidate_votes[0].1; + let location_start = 1 + 4 + GOVERNANCE_DOCUMENT_HASH_BYTES * 2; + let location_end = location_start + GOVERNANCE_DEVELOPMENT_LOCATION_BYTES; + let location = std::str::from_utf8( + first_vote + .get(location_start..location_end) + .ok_or_else(|| "Stored activation vote is too short.".to_string())?, + ) + .map_err(|_| "Stored activation location is not valid ASCII.".to_string())?; + let development_hash = crate::encode( + candidate_key[GOVERNANCE_DOCUMENT_HASH_BYTES..ACTIVATION_CANDIDATE_KEY_BYTES].to_vec(), + ); + let activation_height = governance_activation_height(block_height) + .ok_or_else(|| "Activation height overflowed u32.".to_string())?; + let finished_value = encode_finished_activation_vote( + yes, + no, + eligible, + block_height, + activation_height, + location, + )?; + let activation_value = + encode_consensus_activation(&development_hash, activation_height, location)?; + + let previous = set_tree_value( + db, + FINISHED_ACTIVATION_VOTES_TREE, + &candidate_key, + &finished_value, + )?; + applied.push(AppliedEffect::TreeMutation { + tree: FINISHED_ACTIVATION_VOTES_TREE, + key: candidate_key, + previous, + }); + let previous = set_tree_value( + db, + CONSENSUS_ACTIVATIONS_TREE, + &proposal_key, + &activation_value, + )?; + applied.push(AppliedEffect::TreeMutation { + tree: CONSENSUS_ACTIVATIONS_TREE, + key: proposal_key, + previous, + }); + } + Ok(()) +} + +fn count_lifecycle_votes( + snapshots: &[GovernanceNodeSnapshot], + votes: &[(Vec, Vec)], + voter_offset: usize, + vote_offset: usize, + reference_timestamp_millis: u64, + block_height: u32, +) -> Result<(u64, u64, u64), String> { + let mut votes_by_address = HashMap::new(); + for (key, value) in votes { + let address_bytes = key + .get(voter_offset..) + .ok_or_else(|| "Stored governance vote key is malformed.".to_string())?; + let address = crate::wallets::structures::Wallet::bytes_to_short_address(address_bytes) + .ok_or_else(|| "Stored governance voter address is invalid.".to_string())?; + let vote = value + .get(vote_offset) + .copied() + .ok_or_else(|| "Stored governance vote is too short.".to_string())?; + votes_by_address.insert(address, vote); + } + let existing_voters = votes_by_address.keys().cloned().collect(); + let countable = countable_governance_addresses( + snapshots, + &existing_voters, + reference_timestamp_millis, + block_height, + ); + let mut yes = 0_u64; + let mut no = 0_u64; + for address in &countable { + match votes_by_address.get(address) { + Some(1) => yes += 1, + Some(0) => no += 1, + Some(_) => return Err("Stored governance vote contains an invalid value.".to_string()), + None => {} + } + } + let eligible = u64::try_from(countable.len()) + .map_err(|_| "Eligible governance node count overflowed.".to_string())?; + Ok((yes, no, eligible)) +} + +fn apply_proposal_vote_effect( + db: &Db, + proposal_key: &[u8], + vote_key: &[u8], + value: &[u8], + eligible_addresses: &[Vec], + block_height: u32, +) -> Result { + let votes = db + .open_tree(PROPOSAL_VOTES_TREE) + .map_err(|err| format!("Failed to open proposal votes tree: {err}"))?; + if votes + .contains_key(vote_key) + .map_err(|err| format!("Failed to check existing proposal vote: {err}"))? + { + return Err("This node wallet has already voted on this proposal.".to_string()); + } + + let finished = db + .open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .map_err(|err| format!("Failed to open finished proposal votes tree: {err}"))?; + if finished + .contains_key(proposal_key) + .map_err(|err| format!("Failed to check proposal finalization: {err}"))? + { + return Err("This proposal vote is already finalized.".to_string()); + } + + let eligible: HashSet> = eligible_addresses.iter().cloned().collect(); + let mut yes_votes = 0_u64; + let mut no_votes = 0_u64; + + for entry in votes.scan_prefix(proposal_key) { + let (stored_key, stored_vote) = + entry.map_err(|err| format!("Failed to read proposal vote: {err}"))?; + if stored_key.len() < proposal_key.len() { + continue; + } + let voter = stored_key[proposal_key.len()..].to_vec(); + if !eligible.contains(&voter) { + continue; + } + match stored_vote.get(ProposalVote::VOTE_BYTE_OFFSET).copied() { + Some(1) => yes_votes += 1, + Some(0) => no_votes += 1, + _ => return Err("Stored proposal vote contains an invalid vote value.".to_string()), + } + } + + let current_voter = vote_key + .get(proposal_key.len()..) + .ok_or_else(|| "Proposal vote record key is malformed.".to_string())?; + if eligible.contains(current_voter) { + match value.get(ProposalVote::VOTE_BYTE_OFFSET).copied() { + Some(1) => yes_votes += 1, + Some(0) => no_votes += 1, + _ => return Err("Proposal vote contains an invalid vote value.".to_string()), + } + } + + let eligible_nodes = u64::try_from(eligible.len()) + .map_err(|_| "Eligible governance node count overflowed.".to_string())?; + let vote_previous = set_tree_value(db, PROPOSAL_VOTES_TREE, vote_key, value)?; + let vote_applied = AppliedEffect::TreeMutation { + tree: PROPOSAL_VOTES_TREE, + key: vote_key.to_vec(), + previous: vote_previous, + }; + + if !governance_approval_reached(yes_votes, eligible_nodes) { + return Ok(AppliedEffect::Composite(vec![vote_applied])); + } + + let finished_value = encode_finished_vote(yes_votes, no_votes, eligible_nodes, block_height); + match set_tree_value( + db, + FINISHED_PROPOSAL_VOTES_TREE, + proposal_key, + &finished_value, + ) { + Ok(previous) => Ok(AppliedEffect::Composite(vec![ + vote_applied, + AppliedEffect::TreeMutation { + tree: FINISHED_PROPOSAL_VOTES_TREE, + key: proposal_key.to_vec(), + previous, + }, + ])), + Err(err) => { + rollback_effect(db, vote_applied)?; + Err(err) + } + } +} + +#[allow(clippy::too_many_arguments)] +fn apply_activation_vote_effect( + db: &Db, + proposal_key: &[u8], + candidate_key: &[u8], + vote_key: &[u8], + value: &[u8], + development_hash: &str, + development_location: &str, + eligible_addresses: &[Vec], + block_height: u32, +) -> Result { + let votes = db + .open_tree(ACTIVATION_VOTES_TREE) + .map_err(|err| format!("Failed to open activation votes tree: {err}"))?; + if votes + .contains_key(vote_key) + .map_err(|err| format!("Failed to check existing activation vote: {err}"))? + { + return Err("This node wallet has already voted on this implementation.".to_string()); + } + + let finished = db + .open_tree(FINISHED_ACTIVATION_VOTES_TREE) + .map_err(|err| format!("Failed to open finished activation votes tree: {err}"))?; + let activations = db + .open_tree(CONSENSUS_ACTIVATIONS_TREE) + .map_err(|err| format!("Failed to open consensus activations tree: {err}"))?; + if finished + .contains_key(candidate_key) + .map_err(|err| format!("Failed to check activation finalization: {err}"))? + || activations + .contains_key(proposal_key) + .map_err(|err| format!("Failed to check scheduled proposal activation: {err}"))? + { + return Err("This proposal already has a scheduled implementation.".to_string()); + } + + let eligible: HashSet> = eligible_addresses.iter().cloned().collect(); + let mut yes_votes = 0_u64; + let mut no_votes = 0_u64; + for entry in votes.scan_prefix(candidate_key) { + let (stored_key, stored_vote) = + entry.map_err(|err| format!("Failed to read activation vote: {err}"))?; + let voter = stored_key + .get(candidate_key.len()..) + .ok_or_else(|| "Stored activation vote key is malformed.".to_string())? + .to_vec(); + if !eligible.contains(&voter) { + continue; + } + match stored_vote.get(ActivationVote::VOTE_BYTE_OFFSET).copied() { + Some(1) => yes_votes += 1, + Some(0) => no_votes += 1, + _ => return Err("Stored activation vote contains an invalid value.".to_string()), + } + } + + let current_voter = vote_key + .get(candidate_key.len()..) + .ok_or_else(|| "Activation vote record key is malformed.".to_string())?; + if eligible.contains(current_voter) { + match value.get(ActivationVote::VOTE_BYTE_OFFSET).copied() { + Some(1) => yes_votes += 1, + Some(0) => no_votes += 1, + _ => return Err("Activation vote contains an invalid value.".to_string()), + } + } + + let eligible_nodes = u64::try_from(eligible.len()) + .map_err(|_| "Eligible governance node count overflowed.".to_string())?; + let vote_previous = set_tree_value(db, ACTIVATION_VOTES_TREE, vote_key, value)?; + let vote_applied = AppliedEffect::TreeMutation { + tree: ACTIVATION_VOTES_TREE, + key: vote_key.to_vec(), + previous: vote_previous, + }; + if !governance_approval_reached(yes_votes, eligible_nodes) { + return Ok(AppliedEffect::Composite(vec![vote_applied])); + } + + let activation_height = governance_activation_height(block_height) + .ok_or_else(|| "Activation height overflowed u32.".to_string())?; + let finished_value = encode_finished_activation_vote( + yes_votes, + no_votes, + eligible_nodes, + block_height, + activation_height, + development_location, + )?; + let activation_value = + encode_consensus_activation(development_hash, activation_height, development_location)?; + + let finished_previous = match set_tree_value( + db, + FINISHED_ACTIVATION_VOTES_TREE, + candidate_key, + &finished_value, + ) { + Ok(previous) => previous, + Err(err) => { + rollback_effect(db, vote_applied)?; + return Err(err); + } + }; + let finished_applied = AppliedEffect::TreeMutation { + tree: FINISHED_ACTIVATION_VOTES_TREE, + key: candidate_key.to_vec(), + previous: finished_previous, + }; + match set_tree_value( + db, + CONSENSUS_ACTIVATIONS_TREE, + proposal_key, + &activation_value, + ) { + Ok(previous) => Ok(AppliedEffect::Composite(vec![ + vote_applied, + finished_applied, + AppliedEffect::TreeMutation { + tree: CONSENSUS_ACTIVATIONS_TREE, + key: proposal_key.to_vec(), + previous, + }, + ])), + Err(err) => { + rollback_effect(db, finished_applied)?; + rollback_effect(db, vote_applied)?; + Err(err) + } + } +} + fn rollback_effect(db: &Db, effect: AppliedEffect) -> Result<(), String> { match effect { AppliedEffect::Balance { @@ -656,6 +1252,232 @@ fn encode_storage_rollback_value(previous: Option<&[u8]>) -> Vec { mod tests { use super::*; + fn governance_address(index: u8) -> String { + format!("{:040x}.cltc", index) + } + + fn governance_vote_value(vote: u8) -> Vec { + let mut value = vec![0; ProposalVote::BYTE_LENGTH]; + value[ProposalVote::VOTE_BYTE_OFFSET] = vote; + value + } + + fn activation_vote_value(vote: u8) -> Vec { + let mut value = vec![0; ActivationVote::BYTE_LENGTH]; + value[ActivationVote::VOTE_BYTE_OFFSET] = vote; + value + } + + #[test] + fn accepted_activation_is_scheduled_5760_blocks_after_finalization() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let proposal = vec![4; 32]; + let development = vec![5; 32]; + let mut candidate = proposal.clone(); + candidate.extend(&development); + let eligible: Vec> = (0_u8..20) + .map(|index| { + let mut address = vec![0; 22]; + address[0] = index; + address + }) + .collect(); + let location = + crate::common::governance::normalize_development_location("CLP-1-code.md").unwrap(); + let development_hash = crate::encode(&development); + + let mut pending = PendingEffects::default(); + for address in eligible.iter().take(17) { + let mut vote_key = candidate.clone(); + vote_key.extend(address); + pending.activation_vote( + proposal.clone(), + candidate.clone(), + vote_key, + activation_vote_value(1), + development_hash.clone(), + location.clone(), + eligible.clone(), + 10_000, + ); + } + + let mut applied = pending.apply(&db).unwrap(); + let activation = db + .open_tree(CONSENSUS_ACTIVATIONS_TREE) + .unwrap() + .get(&proposal) + .unwrap() + .unwrap(); + assert_eq!( + u32::from_le_bytes(activation[32..36].try_into().unwrap()), + 15_760 + ); + let finished = db + .open_tree(FINISHED_ACTIVATION_VOTES_TREE) + .unwrap() + .get(&candidate) + .unwrap() + .unwrap(); + assert_eq!( + u32::from_le_bytes(finished[28..32].try_into().unwrap()), + 15_760 + ); + + applied.rollback(&db).unwrap(); + assert!(!db + .open_tree(CONSENSUS_ACTIVATIONS_TREE) + .unwrap() + .contains_key(proposal) + .unwrap()); + } + + #[test] + fn proposal_finalizes_only_when_yes_votes_reach_85_percent() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let proposal = vec![9; 32]; + let eligible: Vec> = (0_u8..20) + .map(|index| { + let mut address = vec![0; 22]; + address[0] = index; + address + }) + .collect(); + + let mut first_votes = PendingEffects::default(); + for address in eligible.iter().take(16) { + let mut vote_key = proposal.clone(); + vote_key.extend(address); + first_votes.proposal_vote( + proposal.clone(), + vote_key, + governance_vote_value(1), + eligible.clone(), + 500, + ); + } + let mut first_applied = first_votes.apply(&db).unwrap(); + assert!(!db + .open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .unwrap() + .contains_key(&proposal) + .unwrap()); + + let mut threshold_vote = PendingEffects::default(); + let mut vote_key = proposal.clone(); + vote_key.extend(&eligible[16]); + threshold_vote.proposal_vote( + proposal.clone(), + vote_key, + governance_vote_value(1), + eligible, + 501, + ); + let mut threshold_applied = threshold_vote.apply(&db).unwrap(); + let finished = db + .open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .unwrap() + .get(&proposal) + .unwrap() + .unwrap(); + assert_eq!(u64::from_le_bytes(finished[0..8].try_into().unwrap()), 17); + assert_eq!( + u32::from_le_bytes(finished[24..28].try_into().unwrap()), + 501 + ); + + threshold_applied.rollback(&db).unwrap(); + assert!(!db + .open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .unwrap() + .contains_key(&proposal) + .unwrap()); + first_applied.rollback(&db).unwrap(); + } + + #[test] + fn lifecycle_finalizes_when_offline_grace_reduces_denominator() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let proposal = vec![7; GOVERNANCE_DOCUMENT_HASH_BYTES]; + let reference_time = crate::common::governance::GOVERNANCE_MIN_NODE_AGE_MILLIS + 10_000; + let snapshots: Vec = (0_u8..10) + .map(|index| GovernanceNodeSnapshot { + address: governance_address(index), + ip: format!("10.0.0.{index}"), + blocks_mined: 100, + added_timestamp: 1, + deleted_block: if index == 9 { 100 } else { 0 }, + }) + .collect(); + let votes = db.open_tree(PROPOSAL_VOTES_TREE).unwrap(); + for snapshot in snapshots.iter().take(8) { + let mut key = proposal.clone(); + key.extend( + crate::wallets::structures::Wallet::short_address_to_bytes(&snapshot.address) + .unwrap(), + ); + votes.insert(key, governance_vote_value(1)).unwrap(); + } + + let mut pending = PendingEffects::default(); + pending.governance_lifecycle(snapshots, reference_time, 5_860); + let mut applied = pending.apply(&db).unwrap(); + + let finalization = db + .open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .unwrap() + .get(&proposal) + .unwrap() + .unwrap(); + assert_eq!( + u64::from_le_bytes(finalization[0..8].try_into().unwrap()), + 8 + ); + assert_eq!( + u64::from_le_bytes(finalization[16..24].try_into().unwrap()), + 9 + ); + + applied.rollback(&db).unwrap(); + assert!(!db + .open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .unwrap() + .contains_key(proposal) + .unwrap()); + } + + #[test] + fn duplicate_node_vote_rolls_back_the_entire_pending_batch() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let proposal = vec![7; 32]; + let address = vec![3; 22]; + let mut vote_key = proposal.clone(); + vote_key.extend(&address); + + let mut pending = PendingEffects::default(); + pending.proposal_vote( + proposal.clone(), + vote_key.clone(), + governance_vote_value(1), + vec![address.clone()], + 100, + ); + pending.proposal_vote( + proposal, + vote_key.clone(), + governance_vote_value(0), + vec![address], + 100, + ); + + assert!(pending.apply(&db).is_err()); + assert!(!db + .open_tree(PROPOSAL_VOTES_TREE) + .unwrap() + .contains_key(vote_key) + .unwrap()); + } + #[test] fn dynamic_tree_remove_is_reversible() { let db = crate::sled::Config::new() @@ -715,6 +1537,90 @@ mod tests { .unwrap() .is_none()); } + + #[test] + fn fractional_burn_updates_supply_without_removing_live_asset() { + let db = crate::sled::Config::new() + .temporary(true) + .open() + .expect("temporary sled database should open"); + let asset = "fractional "; + db.open_tree("nfts") + .unwrap() + .insert(asset.as_bytes(), &[1_u8]) + .unwrap(); + db.open_tree("nft_supply") + .unwrap() + .insert(asset.as_bytes(), &100_000_000_u64.to_le_bytes()) + .unwrap(); + + let mut pending = PendingEffects::default(); + pending.burn_asset_state(asset, asset, vec![7_u8; 32], 56_000_000); + let mut applied = pending.apply(&db).expect("fractional burn should apply"); + + assert!(db + .open_tree("nfts") + .unwrap() + .contains_key(asset.as_bytes()) + .unwrap()); + let supply = db + .open_tree("nft_supply") + .unwrap() + .get(asset.as_bytes()) + .unwrap() + .unwrap(); + assert_eq!(read_supply(&supply).unwrap(), 44_000_000); + + applied.rollback(&db).expect("burn should roll back"); + let supply = db + .open_tree("nft_supply") + .unwrap() + .get(asset.as_bytes()) + .unwrap() + .unwrap(); + assert_eq!(read_supply(&supply).unwrap(), 100_000_000); + } + + #[test] + fn final_fractional_burn_removes_live_asset_and_is_reversible() { + let db = crate::sled::Config::new() + .temporary(true) + .open() + .expect("temporary sled database should open"); + let asset = "fractional "; + db.open_tree("nfts") + .unwrap() + .insert(asset.as_bytes(), &[1_u8]) + .unwrap(); + db.open_tree("nft_supply") + .unwrap() + .insert(asset.as_bytes(), &44_000_000_u64.to_le_bytes()) + .unwrap(); + + let mut pending = PendingEffects::default(); + pending.burn_asset_state(asset, asset, vec![8_u8; 32], 44_000_000); + let mut applied = pending.apply(&db).expect("final burn should apply"); + + assert!(!db + .open_tree("nfts") + .unwrap() + .contains_key(asset.as_bytes()) + .unwrap()); + + applied.rollback(&db).expect("final burn should roll back"); + assert!(db + .open_tree("nfts") + .unwrap() + .contains_key(asset.as_bytes()) + .unwrap()); + let supply = db + .open_tree("nft_supply") + .unwrap() + .get(asset.as_bytes()) + .unwrap() + .unwrap(); + assert_eq!(read_supply(&supply).unwrap(), 44_000_000); + } } fn append_tree_value( @@ -811,6 +1717,18 @@ fn apply_burn_asset_state( .contains_key(burned_asset.as_bytes()) .map_err(|err| format!("Failed to check nfts tree: {err}"))? { + let supply_tree = db + .open_tree("nft_supply") + .map_err(|err| format!("Failed to open NFT supply tree: {err}"))?; + let supply_previous = supply_tree + .get(burned_asset.as_bytes()) + .map_err(|err| format!("Failed to read NFT supply: {err}"))? + .ok_or_else(|| "NFT/RWA has no remaining-supply record.".to_string())?; + let current_supply = read_supply(&supply_previous)?; + let remaining_supply = current_supply + .checked_sub(amount) + .ok_or_else(|| "NFT/RWA burn exceeds its remaining supply.".to_string())?; + let history_previous = append_tree_value(db, "nft_history", burned_asset.as_bytes(), txhash_bytes)?; effects.push(AppliedEffect::TreeMutation { @@ -819,19 +1737,37 @@ fn apply_burn_asset_state( previous: history_previous, }); - let nft_previous = match nft_tree.remove(burned_asset.as_bytes()) { - Ok(previous) => previous.map(|value| value.to_vec()), - Err(err) => { - let _ = rollback_effect(db, AppliedEffect::Composite(effects)); - return Err(format!("Failed to remove burned NFT: {err}")); - } - }; + let previous = + match supply_tree.insert(burned_asset.as_bytes(), &remaining_supply.to_le_bytes()) { + Ok(previous) => previous.map(|value| value.to_vec()), + Err(err) => { + let _ = rollback_effect(db, AppliedEffect::Composite(effects)); + return Err(format!("Failed to update NFT supply: {err}")); + } + }; effects.push(AppliedEffect::TreeMutation { - tree: "nfts", + tree: "nft_supply", key: burned_asset.as_bytes().to_vec(), - previous: nft_previous, + previous, }); + // A partial fractional burn leaves the asset live. Only the final + // ownership unit removes it from the live NFT/RWA registry. + if remaining_supply == 0 { + let nft_previous = match nft_tree.remove(burned_asset.as_bytes()) { + Ok(previous) => previous.map(|value| value.to_vec()), + Err(err) => { + let _ = rollback_effect(db, AppliedEffect::Composite(effects)); + return Err(format!("Failed to remove fully burned NFT/RWA: {err}")); + } + }; + effects.push(AppliedEffect::TreeMutation { + tree: "nfts", + key: burned_asset.as_bytes().to_vec(), + previous: nft_previous, + }); + } + return Ok(AppliedEffect::Composite(effects)); } diff --git a/src/records/record_chain/proposal_key_tx.rs b/src/records/record_chain/proposal_key_tx.rs new file mode 100644 index 0000000..90a4005 --- /dev/null +++ b/src/records/record_chain/proposal_key_tx.rs @@ -0,0 +1,57 @@ +use crate::blocks::proposal_key::ProposalKey; +use crate::common::governance::PROPOSAL_KEYS_TREE; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::records::record_chain::wallet_tx_index::index_wallet_transaction; +use crate::sled::Db; +use crate::Arc; +use crate::Mutex; + +pub async fn process_proposal_key( + transaction: &ProposalKey, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + let mut index = index_counter.lock().await; + **index += 1; + + let proposal = &transaction.unsigned_proposalkey; + let txhash = proposal.hash().await; + let txhash_bytes = + decode(&txhash).map_err(|err| format!("Could not decode proposal key txhash: {err}"))?; + let transaction_bytes = transaction + .to_bytes() + .await + .map_err(|err| format!("Could not serialize proposal key transaction: {err}"))?; + binary_data.extend(&transaction_bytes); + + // The signed transaction hash is the immutable proposal key. The value + // preserves the transaction and its exact proposal-document hash. + pending_effects.set_tree(PROPOSAL_KEYS_TREE, txhash_bytes.clone(), transaction_bytes); + pending_effects.add_balance(&miner, proposal.txfee, &BASECOIN, BalanceOperand::Addition); + pending_effects.add_balance( + &proposal.address, + proposal.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + pending_effects.set_tree( + "txid", + txhash_bytes.clone(), + format!("{block_header_number}:{}", **index).into_bytes(), + ); + index_wallet_transaction( + pending_effects, + &[&proposal.address], + block_header_number, + **index as u32, + &txhash_bytes, + )?; + + Ok(binary_data) +} diff --git a/src/records/record_chain/proposal_vote_tx.rs b/src/records/record_chain/proposal_vote_tx.rs new file mode 100644 index 0000000..a1e896c --- /dev/null +++ b/src/records/record_chain/proposal_vote_tx.rs @@ -0,0 +1,81 @@ +use crate::blocks::proposal_vote::ProposalVote; +use crate::common::governance::proposal_vote_record_key; +use crate::decode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::record_chain::pending_effects::{BalanceOperand, PendingEffects}; +use crate::records::record_chain::wallet_tx_index::index_wallet_transaction; +use crate::sled::Db; +use crate::wallets::structures::Wallet; +use crate::Arc; +use crate::Mutex; + +pub async fn process_proposal_vote( + transaction: &ProposalVote, + mut binary_data: Vec, + _db: &Db, + index_counter: Arc>, + miner: String, + block_header_number: u32, + block_timestamp: u32, + pending_effects: &mut PendingEffects, +) -> Result, String> { + let mut index = index_counter.lock().await; + **index += 1; + + let vote = &transaction.unsigned_proposalvote; + let txhash = vote.hash().await; + let txhash_bytes = + decode(&txhash).map_err(|err| format!("Could not decode proposal vote txhash: {err}"))?; + let proposal_key = decode(&vote.proposal_key) + .map_err(|err| format!("Could not decode proposal key: {err}"))?; + let vote_key = proposal_vote_record_key(&vote.proposal_key, &vote.address)?; + let transaction_bytes = transaction + .to_bytes() + .await + .map_err(|err| format!("Could not serialize proposal vote transaction: {err}"))?; + binary_data.extend(&transaction_bytes); + + let eligible_addresses = NodeInfo::governance_countable_nodes_for_proposal( + _db, + &vote.proposal_key, + (block_timestamp as u64) * 1_000, + block_header_number, + ) + .await? + .into_iter() + .map(|node| { + Wallet::short_address_to_bytes(&node.address) + .ok_or_else(|| "Eligible governance node has an invalid address.".to_string()) + }) + .collect::, _>>()?; + + pending_effects.proposal_vote( + proposal_key, + vote_key, + transaction_bytes, + eligible_addresses, + block_header_number, + ); + pending_effects.add_balance(&miner, vote.txfee, &BASECOIN, BalanceOperand::Addition); + pending_effects.add_balance( + &vote.address, + vote.txfee, + &BASECOIN, + BalanceOperand::Subtraction, + ); + pending_effects.set_tree( + "txid", + txhash_bytes.clone(), + format!("{block_header_number}:{}", **index).into_bytes(), + ); + index_wallet_transaction( + pending_effects, + &[&vote.address], + block_header_number, + **index as u32, + &txhash_bytes, + )?; + + Ok(binary_data) +} diff --git a/src/records/record_chain/save.rs b/src/records/record_chain/save.rs index d65be97..f6ebb41 100644 --- a/src/records/record_chain/save.rs +++ b/src/records/record_chain/save.rs @@ -145,6 +145,15 @@ pub async fn save_block(params: SaveBlockParams) -> Result<(), String> { ) .await?; + // Recalculate every unfinished governance decision once per accepted + // block. This catches offline-vote expiry, same-wallet reactivation, and + // same-IP wallet replacement without making socket events authoritative. + pending_effects.governance_lifecycle( + NodeInfo::governance_node_snapshots().await, + (timestamp as u64) * 1_000, + block_header_number, + ); + let mut saved_reward = None; for (position, transaction) in block.transactions.iter().enumerate() { if let Transaction::Rewards(rewards) = transaction { @@ -175,6 +184,7 @@ pub async fn save_block(params: SaveBlockParams) -> Result<(), String> { &db, miner.clone(), block_header_number, + timestamp, start_index, &mut pending_effects, ) diff --git a/src/records/unpack_block/load_by_binary_data.rs b/src/records/unpack_block/load_by_binary_data.rs index 78b8c3b..4f32c1a 100644 --- a/src/records/unpack_block/load_by_binary_data.rs +++ b/src/records/unpack_block/load_by_binary_data.rs @@ -8,6 +8,8 @@ use crate::blocks::loan_payment::ContractPaymentTransaction; use crate::blocks::loans::LoanContractTransaction; use crate::blocks::marketing::MarketingTransaction; use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::proposal_key::ProposalKey; +use crate::blocks::proposal_vote::ProposalVote; use crate::blocks::rewards::RewardsTransaction; use crate::blocks::storage_key::StorageKey; use crate::blocks::storage_values::{ @@ -19,12 +21,12 @@ use crate::blocks::token::CreateTokenTransaction; use crate::blocks::transfer::TransferTransaction; use crate::blocks::vanity::VanityAddressTransaction; use crate::common::types::{ - Transaction, BORROWER_TYPE, BURN_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, CREATE_TOKEN_TYPE, - DELETE_KEY_TYPE, GENESIS_TYPE, ISSUE_TOKEN_TYPE, LENDER_TYPE, MARKETING_TYPE, REWARDS_TYPE, - STORAGE_BOOL_TYPE, STORAGE_I128_TYPE, STORAGE_I16_TYPE, STORAGE_I32_TYPE, STORAGE_I64_TYPE, - STORAGE_I8_TYPE, STORAGE_KEY_TYPE, STORAGE_STRING_TYPE, STORAGE_U128_TYPE, STORAGE_U16_TYPE, - STORAGE_U32_TYPE, STORAGE_U64_TYPE, STORAGE_U8_TYPE, SWAP_TYPE, TRANSFER_TYPE, - VANITY_ADDRESS_TYPE, + Transaction, ACTIVATION_VOTE_TYPE, BORROWER_TYPE, BURN_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, + CREATE_TOKEN_TYPE, DELETE_KEY_TYPE, GENESIS_TYPE, ISSUE_TOKEN_TYPE, LENDER_TYPE, + MARKETING_TYPE, PROPOSAL_KEY_TYPE, PROPOSAL_VOTE_TYPE, REWARDS_TYPE, STORAGE_BOOL_TYPE, + STORAGE_I128_TYPE, STORAGE_I16_TYPE, STORAGE_I32_TYPE, STORAGE_I64_TYPE, STORAGE_I8_TYPE, + STORAGE_KEY_TYPE, STORAGE_STRING_TYPE, STORAGE_U128_TYPE, STORAGE_U16_TYPE, STORAGE_U32_TYPE, + STORAGE_U64_TYPE, STORAGE_U8_TYPE, SWAP_TYPE, TRANSFER_TYPE, VANITY_ADDRESS_TYPE, }; use crate::rpc::command_maps::get_bytes; @@ -212,6 +214,33 @@ pub async fn load_block_from_binary(binary_data: &[u8]) -> Result i += body_len; storage_key } + PROPOSAL_KEY_TYPE => { + let proposal_key = Transaction::ProposalKey( + ProposalKey::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + proposal_key + } + PROPOSAL_VOTE_TYPE => { + let proposal_vote = Transaction::ProposalVote( + ProposalVote::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + proposal_vote + } + ACTIVATION_VOTE_TYPE => { + let activation_vote = Transaction::ActivationVote( + ActivationVote::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + activation_vote + } STORAGE_BOOL_TYPE => { let storage = Transaction::StorageBool( StorageBool::from_bytes(txtype, body) @@ -345,3 +374,4 @@ pub async fn load_block_from_binary(binary_data: &[u8]) -> Result Ok(block) } +use crate::blocks::activation_vote::ActivationVote; diff --git a/src/records/unpack_block/load_by_block_number.rs b/src/records/unpack_block/load_by_block_number.rs index ce310d1..40b5faf 100644 --- a/src/records/unpack_block/load_by_block_number.rs +++ b/src/records/unpack_block/load_by_block_number.rs @@ -8,6 +8,8 @@ use crate::blocks::loan_payment::ContractPaymentTransaction; use crate::blocks::loans::LoanContractTransaction; use crate::blocks::marketing::MarketingTransaction; use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::proposal_key::ProposalKey; +use crate::blocks::proposal_vote::ProposalVote; use crate::blocks::rewards::RewardsTransaction; use crate::blocks::storage_key::StorageKey; use crate::blocks::storage_values::{ @@ -20,12 +22,12 @@ use crate::blocks::transfer::TransferTransaction; use crate::blocks::vanity::VanityAddressTransaction; use crate::common::network_paths_and_settings::block_extension_and_paths; use crate::common::types::{ - Transaction, BORROWER_TYPE, BURN_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, CREATE_TOKEN_TYPE, - DELETE_KEY_TYPE, GENESIS_TYPE, ISSUE_TOKEN_TYPE, LENDER_TYPE, MARKETING_TYPE, REWARDS_TYPE, - STORAGE_BOOL_TYPE, STORAGE_I128_TYPE, STORAGE_I16_TYPE, STORAGE_I32_TYPE, STORAGE_I64_TYPE, - STORAGE_I8_TYPE, STORAGE_KEY_TYPE, STORAGE_STRING_TYPE, STORAGE_U128_TYPE, STORAGE_U16_TYPE, - STORAGE_U32_TYPE, STORAGE_U64_TYPE, STORAGE_U8_TYPE, SWAP_TYPE, TRANSFER_TYPE, - VANITY_ADDRESS_TYPE, + Transaction, ACTIVATION_VOTE_TYPE, BORROWER_TYPE, BURN_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, + CREATE_TOKEN_TYPE, DELETE_KEY_TYPE, GENESIS_TYPE, ISSUE_TOKEN_TYPE, LENDER_TYPE, + MARKETING_TYPE, PROPOSAL_KEY_TYPE, PROPOSAL_VOTE_TYPE, REWARDS_TYPE, STORAGE_BOOL_TYPE, + STORAGE_I128_TYPE, STORAGE_I16_TYPE, STORAGE_I32_TYPE, STORAGE_I64_TYPE, STORAGE_I8_TYPE, + STORAGE_KEY_TYPE, STORAGE_STRING_TYPE, STORAGE_U128_TYPE, STORAGE_U16_TYPE, STORAGE_U32_TYPE, + STORAGE_U64_TYPE, STORAGE_U8_TYPE, SWAP_TYPE, TRANSFER_TYPE, VANITY_ADDRESS_TYPE, }; use crate::fs; use crate::rpc::command_maps::get_bytes; @@ -229,6 +231,33 @@ pub async fn load_block(block_number: u32) -> Result { i += body_len; storage_key } + PROPOSAL_KEY_TYPE => { + let proposal_key = Transaction::ProposalKey( + ProposalKey::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + proposal_key + } + PROPOSAL_VOTE_TYPE => { + let proposal_vote = Transaction::ProposalVote( + ProposalVote::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + proposal_vote + } + ACTIVATION_VOTE_TYPE => { + let activation_vote = Transaction::ActivationVote( + ActivationVote::from_bytes(txtype, body) + .await + .map_err(|e| e.to_string())?, + ); + i += body_len; + activation_vote + } STORAGE_BOOL_TYPE => { let storage = Transaction::StorageBool( StorageBool::from_bytes(txtype, body) @@ -362,3 +391,4 @@ pub async fn load_block(block_number: u32) -> Result { Ok(block) } +use crate::blocks::activation_vote::ActivationVote; diff --git a/src/rpc/client/governance_state_sync.rs b/src/rpc/client/governance_state_sync.rs new file mode 100644 index 0000000..6eface8 --- /dev/null +++ b/src/rpc/client/governance_state_sync.rs @@ -0,0 +1,345 @@ +use crate::common::governance::{ + governance_activation_height, governance_approval_reached, CONSENSUS_ACTIVATIONS_TREE, + FINISHED_ACTIVATION_VOTES_TREE, FINISHED_PROPOSAL_VOTES_TREE, PROPOSAL_KEYS_TREE, +}; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::response_channels::{reserve_entry, Command}; +use crate::rpc::command_maps::RPC_GOVERNANCE_STATE_SYNC; +use crate::rpc::commands::governance_state_sync::{ + CONSENSUS_ACTIVATION_TAG, FINISHED_ACTIVATION_TAG, FINISHED_PROPOSAL_TAG, +}; +use crate::rpc::responses::RpcResponse; +use crate::sled::{Batch, Db}; +use crate::timeout; +use crate::Arc; +use crate::Duration; +use crate::Mutex; +use crate::TcpStream; +use std::collections::{HashMap, HashSet}; + +const FINISHED_PROPOSAL_KEY_BYTES: usize = 32; +const FINISHED_PROPOSAL_VALUE_BYTES: usize = 28; +const FINISHED_ACTIVATION_KEY_BYTES: usize = 64; +const FINISHED_ACTIVATION_VALUE_BYTES: usize = 132; +const CONSENSUS_ACTIVATION_KEY_BYTES: usize = 32; +const CONSENSUS_ACTIVATION_VALUE_BYTES: usize = 136; + +#[derive(Default)] +struct GovernanceSnapshot { + proposals: Vec<(Vec, Vec)>, + activations: Vec<(Vec, Vec)>, + schedules: Vec<(Vec, Vec)>, +} + +pub async fn sync_governance_state( + stream: Arc>, + db: &Db, + map: Arc>, + connections_key: String, +) -> Result<(), String> { + let (uid, _tx, rx) = reserve_entry(map).await; + let mut request = Vec::with_capacity(4); + request.push(RPC_GOVERNANCE_STATE_SYNC); + request.extend_from_slice(&uid); + RpcResponse::send_raw(&stream, Some(&connections_key), &request).await; + + let payload = { + let mut rx = rx.lock().await; + timeout(Duration::from_secs(30), rx.recv()) + .await + .map_err(|_| "Timed out waiting for governance state sync response".to_string())? + .ok_or_else(|| "Governance state sync response channel closed".to_string())? + }; + let snapshot = parse_snapshot(&payload)?; + validate_snapshot(db, &snapshot)?; + import_snapshot(db, snapshot) +} + +fn parse_snapshot(payload: &[u8]) -> Result { + let mut snapshot = GovernanceSnapshot::default(); + let mut offset = 0; + while offset < payload.len() { + let tag = payload[offset]; + offset += 1; + let (key_bytes, value_bytes) = match tag { + FINISHED_PROPOSAL_TAG => (FINISHED_PROPOSAL_KEY_BYTES, FINISHED_PROPOSAL_VALUE_BYTES), + FINISHED_ACTIVATION_TAG => ( + FINISHED_ACTIVATION_KEY_BYTES, + FINISHED_ACTIVATION_VALUE_BYTES, + ), + CONSENSUS_ACTIVATION_TAG => ( + CONSENSUS_ACTIVATION_KEY_BYTES, + CONSENSUS_ACTIVATION_VALUE_BYTES, + ), + _ => return Err("Governance state sync contained an unknown record type.".to_string()), + }; + let end = offset + .checked_add(key_bytes + value_bytes) + .ok_or_else(|| "Governance state sync record length overflowed.".to_string())?; + if end > payload.len() { + return Err("Governance state sync ended inside a record.".to_string()); + } + let key = payload[offset..offset + key_bytes].to_vec(); + let value = payload[offset + key_bytes..end].to_vec(); + offset = end; + match tag { + FINISHED_PROPOSAL_TAG => snapshot.proposals.push((key, value)), + FINISHED_ACTIVATION_TAG => snapshot.activations.push((key, value)), + CONSENSUS_ACTIVATION_TAG => snapshot.schedules.push((key, value)), + _ => unreachable!(), + } + } + Ok(snapshot) +} + +fn validate_snapshot(db: &Db, snapshot: &GovernanceSnapshot) -> Result<(), String> { + let local_height = get_height(db); + let proposal_keys = db + .open_tree(PROPOSAL_KEYS_TREE) + .map_err(|err| format!("Could not open proposal keys during governance sync: {err}"))?; + let mut finished_proposals = HashMap::new(); + for (key, value) in &snapshot.proposals { + if finished_proposals.contains_key(key) { + return Err("Governance state sync contained a duplicate proposal.".to_string()); + } + if !proposal_keys + .contains_key(key) + .map_err(|err| format!("Could not verify synced proposal key: {err}"))? + { + return Err("Synced final proposal does not exist in the validated chain.".to_string()); + } + validate_finished_counts(value, local_height, 24)?; + finished_proposals.insert(key.clone(), value.clone()); + } + + let mut finished_activations = HashMap::new(); + for (key, value) in &snapshot.activations { + if finished_activations.contains_key(key) { + return Err("Governance state sync contained a duplicate implementation.".to_string()); + } + let proposal_key = &key[..32]; + if !finished_proposals.contains_key(proposal_key) + && !db + .open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .map_err(|err| format!("Could not open finished proposals: {err}"))? + .contains_key(proposal_key) + .map_err(|err| format!("Could not verify approved proposal: {err}"))? + { + return Err("Synced implementation belongs to an unapproved proposal.".to_string()); + } + validate_finished_counts(value, local_height, 24)?; + let finalized_height = read_u32(value, 24)?; + let activation_height = read_u32(value, 28)?; + if governance_activation_height(finalized_height) != Some(activation_height) { + return Err("Synced implementation has an invalid activation height.".to_string()); + } + finished_activations.insert(key.clone(), value.clone()); + } + + let mut scheduled_candidates = HashSet::new(); + let mut scheduled_proposals = HashSet::new(); + for (proposal_key, schedule) in &snapshot.schedules { + if !scheduled_proposals.insert(proposal_key.clone()) { + return Err( + "Governance state sync contained duplicate schedules for a proposal.".to_string(), + ); + } + let development_hash = &schedule[..32]; + let mut candidate_key = proposal_key.clone(); + candidate_key.extend_from_slice(development_hash); + let finished = match finished_activations.get(&candidate_key) { + Some(value) => value.clone(), + None => db + .open_tree(FINISHED_ACTIVATION_VOTES_TREE) + .map_err(|err| format!("Could not open finished activations: {err}"))? + .get(&candidate_key) + .map_err(|err| format!("Could not verify finalized implementation: {err}"))? + .map(|value| value.to_vec()) + .ok_or_else(|| { + "Synced activation schedule has no finalized implementation.".to_string() + })?, + }; + if schedule[32..36] != finished[28..32] || schedule[36..136] != finished[32..132] { + return Err("Synced activation schedule conflicts with its final vote.".to_string()); + } + scheduled_candidates.insert(candidate_key); + } + for candidate_key in finished_activations.keys() { + if !scheduled_candidates.contains(candidate_key) { + return Err( + "Synced finalized implementation is missing its activation schedule.".to_string(), + ); + } + } + validate_no_conflicts(db, FINISHED_PROPOSAL_VOTES_TREE, &snapshot.proposals)?; + validate_no_conflicts(db, FINISHED_ACTIVATION_VOTES_TREE, &snapshot.activations)?; + validate_no_conflicts(db, CONSENSUS_ACTIVATIONS_TREE, &snapshot.schedules)?; + Ok(()) +} + +fn validate_finished_counts( + value: &[u8], + local_height: u32, + height_offset: usize, +) -> Result<(), String> { + let yes = read_u64(value, 0)?; + let no = read_u64(value, 8)?; + let eligible = read_u64(value, 16)?; + if yes.checked_add(no).is_none() || yes + no > eligible { + return Err("Synced governance vote counts are invalid.".to_string()); + } + if !governance_approval_reached(yes, eligible) { + return Err("Synced governance result did not reach approval.".to_string()); + } + if read_u32(value, height_offset)? > local_height { + return Err("Synced governance result finalizes above the local chain.".to_string()); + } + Ok(()) +} + +fn read_u64(value: &[u8], offset: usize) -> Result { + let bytes = value + .get(offset..offset + 8) + .ok_or_else(|| "Synced governance value is too short.".to_string())?; + Ok(u64::from_le_bytes(bytes.try_into().unwrap())) +} + +fn read_u32(value: &[u8], offset: usize) -> Result { + let bytes = value + .get(offset..offset + 4) + .ok_or_else(|| "Synced governance value is too short.".to_string())?; + Ok(u32::from_le_bytes(bytes.try_into().unwrap())) +} + +fn import_snapshot(db: &Db, snapshot: GovernanceSnapshot) -> Result<(), String> { + apply_batch(db, FINISHED_PROPOSAL_VOTES_TREE, snapshot.proposals)?; + apply_batch(db, FINISHED_ACTIVATION_VOTES_TREE, snapshot.activations)?; + apply_batch(db, CONSENSUS_ACTIVATIONS_TREE, snapshot.schedules) +} + +fn validate_no_conflicts( + db: &Db, + tree_name: &'static str, + records: &[(Vec, Vec)], +) -> Result<(), String> { + let tree = db + .open_tree(tree_name) + .map_err(|err| format!("Could not open {tree_name} during governance sync: {err}"))?; + for (key, value) in records { + if let Some(existing) = tree + .get(key) + .map_err(|err| format!("Could not read existing {tree_name} record: {err}"))? + { + if existing.as_ref() != value { + return Err(format!( + "Synced governance state conflicts with local {tree_name} state." + )); + } + } + } + Ok(()) +} + +fn apply_batch( + db: &Db, + tree_name: &'static str, + records: Vec<(Vec, Vec)>, +) -> Result<(), String> { + let tree = db + .open_tree(tree_name) + .map_err(|err| format!("Could not open {tree_name} during governance sync: {err}"))?; + let mut batch = Batch::default(); + for (key, value) in records { + batch.insert(key, value); + } + tree.apply_batch(batch) + .map_err(|err| format!("Could not import {tree_name}: {err}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::governance::{ + encode_consensus_activation, encode_finished_activation_vote, encode_finished_vote, + normalize_development_location, + }; + + #[test] + fn parser_rejects_partial_governance_record() { + assert!(parse_snapshot(&[FINISHED_PROPOSAL_TAG, 1, 2, 3]).is_err()); + } + + #[test] + fn finalized_proposal_snapshot_validates_and_imports() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let proposal = vec![7; 32]; + db.open_tree(PROPOSAL_KEYS_TREE) + .unwrap() + .insert(&proposal, vec![1]) + .unwrap(); + let snapshot = GovernanceSnapshot { + proposals: vec![(proposal.clone(), encode_finished_vote(9, 1, 10, 0))], + ..GovernanceSnapshot::default() + }; + + validate_snapshot(&db, &snapshot).unwrap(); + import_snapshot(&db, snapshot).unwrap(); + + assert!(db + .open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .unwrap() + .contains_key(proposal) + .unwrap()); + } + + #[test] + fn conflicting_local_finality_is_rejected_before_import() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let proposal = vec![8; 32]; + db.open_tree(PROPOSAL_KEYS_TREE) + .unwrap() + .insert(&proposal, vec![1]) + .unwrap(); + db.open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .unwrap() + .insert(&proposal, encode_finished_vote(17, 3, 20, 0)) + .unwrap(); + let snapshot = GovernanceSnapshot { + proposals: vec![(proposal, encode_finished_vote(18, 2, 20, 0))], + ..GovernanceSnapshot::default() + }; + + assert!(validate_snapshot(&db, &snapshot) + .unwrap_err() + .contains("conflicts")); + } + + #[test] + fn finalized_activation_requires_matching_schedule() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let proposal = vec![9; 32]; + let development = vec![10; 32]; + let development_hash = crate::encode(&development); + let location = normalize_development_location("CLP-1-code.md").unwrap(); + db.open_tree(PROPOSAL_KEYS_TREE) + .unwrap() + .insert(&proposal, vec![1]) + .unwrap(); + + let mut candidate = proposal.clone(); + candidate.extend_from_slice(&development); + let snapshot = GovernanceSnapshot { + proposals: vec![(proposal.clone(), encode_finished_vote(9, 1, 10, 0))], + activations: vec![( + candidate, + encode_finished_activation_vote(9, 1, 10, 0, 5_760, &location).unwrap(), + )], + schedules: vec![( + proposal, + encode_consensus_activation(&development_hash, 5_760, &location).unwrap(), + )], + }; + + validate_snapshot(&db, &snapshot).unwrap(); + } +} diff --git a/src/rpc/client/handshake_processing.rs b/src/rpc/client/handshake_processing.rs index 7030450..d56e7fe 100644 --- a/src/rpc/client/handshake_processing.rs +++ b/src/rpc/client/handshake_processing.rs @@ -18,6 +18,7 @@ use crate::records::memory::network_mapping::NodeInfo; use crate::records::memory::response_channels::Command; use crate::records::memory::structs::Connection; use crate::rpc::client::genesis_compat::ensure_compatible_genesis; +use crate::rpc::client::governance_state_sync::sync_governance_state; use crate::rpc::client::handshake::connect_and_handshake; use crate::rpc::client::register_wallet::register_connected_wallet; use crate::rpc::client::structs::{Connect, Handshake}; @@ -74,6 +75,7 @@ pub async fn bootstrap_peer_discovery(mut params: BootstrapParams) -> Result<(), let mut current_key = params.connections_key.clone(); let mut stream = params.stream; params.first = false; + let mut imported_chain = false; let mut candidate_endpoints = NodeInfo::active_node_endpoints().await; candidate_endpoints.shuffle(&mut thread_rng()); @@ -150,6 +152,7 @@ pub async fn bootstrap_peer_discovery(mut params: BootstrapParams) -> Result<(), let local_genesis_exists = genesis_checkup().await; if !local_genesis_exists || remote_height > local_height + 10 { + imported_chain = true; info!("[sync] Starting sync from {local_height} to {remote_height}"); node_syncing( stream.clone(), @@ -212,6 +215,18 @@ pub async fn bootstrap_peer_discovery(mut params: BootstrapParams) -> Result<(), } } + if imported_chain { + sync_governance_state( + stream.clone(), + ¶ms.db, + params.map.clone(), + current_key.clone(), + ) + .await + .map_err(|err| format!("Governance state sync error: {err}"))?; + info!("[governance] finalized state sync completed: peer={current_key}"); + } + info!("[sync] post-sync checks complete, mining grace period started"); for (peer_key, _) in startup_synced_peer_streams().await { if !mark_peer_operational(&peer_key, params.map.clone()).await { diff --git a/src/rpc/client/mod.rs b/src/rpc/client/mod.rs index dfe6876..a69edf3 100644 --- a/src/rpc/client/mod.rs +++ b/src/rpc/client/mod.rs @@ -1,6 +1,7 @@ // The rpc client module contains the standalone client-side handshake and sync helpers. pub mod block_hash_vote; pub mod genesis_compat; +pub mod governance_state_sync; pub mod handshake; pub mod handshake_message; pub mod handshake_processing; diff --git a/src/rpc/command_maps.rs b/src/rpc/command_maps.rs index 2721dbc..6f45fbf 100644 --- a/src/rpc/command_maps.rs +++ b/src/rpc/command_maps.rs @@ -7,6 +7,8 @@ use crate::blocks::loan_payment::ContractPaymentTransaction; use crate::blocks::loans::LoanContractTransaction; use crate::blocks::marketing::MarketingTransaction; use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::proposal_key::ProposalKey; +use crate::blocks::proposal_vote::ProposalVote; use crate::blocks::rewards::RewardsTransaction; use crate::blocks::storage_key::StorageKey; use crate::blocks::storage_values::{ @@ -71,6 +73,8 @@ pub const RPC_STORAGE_LOOKUP_COST: u8 = 52; pub const RPC_STORAGE_LOOKUP: u8 = 53; pub const RPC_NETWORK_MONITOR_STATE: u8 = 54; pub const RPC_SETUP_COMPLETE: u8 = 55; +pub const RPC_GOVERNANCE_STATE_SYNC: u8 = 56; +pub const RPC_GOVERNANCE_PROPOSAL: u8 = 57; pub const RPC_REPLY: u8 = 255; pub const MAX_RPC_REPLY_BYTES: usize = 64 * 1024 * 1024; @@ -108,6 +112,10 @@ pub fn get_bytes(tx_type: u8) -> usize { 111 => StorageI64::BYTE_LENGTH, 112 => StorageI128::BYTE_LENGTH, 113 => DeleteKey::BYTE_LENGTH, + 200 => ProposalKey::BYTE_LENGTH, + 201 => ProposalVote::BYTE_LENGTH, + 202 => ActivationVote::BYTE_LENGTH, _ => 0, } } +use crate::blocks::activation_vote::ActivationVote; diff --git a/src/rpc/commands/address_history.rs b/src/rpc/commands/address_history.rs index 2dbf5d4..c96bef2 100644 --- a/src/rpc/commands/address_history.rs +++ b/src/rpc/commands/address_history.rs @@ -129,6 +129,11 @@ async fn miner_earnings_from_transactions( Transaction::Collateral(tx) => earnings.add_fee(tx.unsigned_collateral_claim.txfee)?, Transaction::Vanity(tx) => earnings.add_fee(tx.unsigned_vanity_address.txfee)?, Transaction::StorageKey(tx) => earnings.add_fee(tx.unsigned_storagekey.txfee)?, + Transaction::ProposalKey(tx) => earnings.add_fee(tx.unsigned_proposalkey.txfee)?, + Transaction::ProposalVote(tx) => earnings.add_fee(tx.unsigned_proposalvote.txfee)?, + Transaction::ActivationVote(tx) => { + earnings.add_fee(tx.unsigned_activationvote.txfee)? + } Transaction::StorageBool(tx) => earnings.add_fee(tx.unsigned_storagebool.txfee)?, Transaction::StorageU8(tx) => earnings.add_fee(tx.unsigned_storageu8.txfee)?, Transaction::StorageU16(tx) => earnings.add_fee(tx.unsigned_storageu16.txfee)?, diff --git a/src/rpc/commands/governance_proposal.rs b/src/rpc/commands/governance_proposal.rs new file mode 100644 index 0000000..bd45e87 --- /dev/null +++ b/src/rpc/commands/governance_proposal.rs @@ -0,0 +1,381 @@ +use crate::blocks::activation_vote::ActivationVote; +use crate::blocks::proposal_key::ProposalKey; +use crate::blocks::proposal_vote::ProposalVote; +use crate::common::governance::{ + countable_governance_addresses, ACTIVATION_CANDIDATE_KEY_BYTES, ACTIVATION_VOTES_TREE, + ACTIVATION_VOTE_RECORD_KEY_BYTES, CONSENSUS_ACTIVATIONS_TREE, FINISHED_ACTIVATION_VOTES_TREE, + FINISHED_PROPOSAL_VOTES_TREE, GOVERNANCE_DOCUMENT_HASH_BYTES, PROPOSAL_KEYS_TREE, + PROPOSAL_VOTES_TREE, PROPOSAL_VOTE_RECORD_KEY_BYTES, +}; +use crate::encode; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::network_mapping::NodeInfo; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; +use crate::wallets::structures::Wallet; +use crate::{to_string, Utc}; +use serde::Serialize; +use std::collections::{BTreeMap, HashMap, HashSet}; + +#[derive(Serialize)] +struct GovernanceVoteView { + address: String, + vote: &'static str, + counted: bool, +} + +#[derive(Serialize)] +struct CurrentVoteState { + yes: u64, + no: u64, + eligible_nodes: u64, + approval_percent: f64, + votes: Vec, +} + +#[derive(Serialize)] +struct FinalVoteState { + yes: u64, + no: u64, + eligible_nodes: u64, + approval_percent: f64, + finalized_block: u32, +} + +#[derive(Serialize)] +struct ImplementationView { + development_hash: String, + development_location: String, + state: &'static str, + current_vote: CurrentVoteState, + final_vote: Option, + activation_block: Option, +} + +#[derive(Serialize)] +struct ProposalView { + proposal_key: String, + proposal_document_hash: String, + creator: String, + created_timestamp: u32, + state: &'static str, + current_vote: CurrentVoteState, + final_vote: Option, + implementations: Vec, +} + +struct StoredVote { + address: String, + vote: u8, +} + +pub async fn lookup_proposal(db: &Db, proposal_key: Vec) -> RpcResponse { + match build_proposal_view(db, proposal_key).await { + Ok(view) => match to_string(&view) { + Ok(json) => RpcResponse::Binary(json.into_bytes()), + Err(err) => RpcResponse::Binary(format!(r#"{{"error":"{err}"}}"#).into_bytes()), + }, + Err(err) => RpcResponse::Binary(format!(r#"{{"error":"{err}"}}"#).into_bytes()), + } +} + +async fn build_proposal_view(db: &Db, proposal_key: Vec) -> Result { + if proposal_key.len() != GOVERNANCE_DOCUMENT_HASH_BYTES { + return Err("Proposal key must be exactly 32 bytes.".to_string()); + } + let proposal_bytes = db + .open_tree(PROPOSAL_KEYS_TREE) + .map_err(|err| format!("Could not open proposal keys: {err}"))? + .get(&proposal_key) + .map_err(|err| format!("Could not read proposal key: {err}"))? + .ok_or_else(|| "Proposal key was not found.".to_string())?; + if proposal_bytes.len() != ProposalKey::BYTE_LENGTH { + return Err("Stored proposal key has an invalid byte count.".to_string()); + } + let proposal = ProposalKey::from_bytes(proposal_bytes[0], &proposal_bytes[1..]) + .await + .map_err(|err| format!("Could not parse stored proposal key: {err}"))?; + + let height = get_height(db); + let reference_time = Utc::now().timestamp_millis().max(0) as u64; + let snapshots = NodeInfo::governance_node_snapshots().await; + let proposal_votes = load_proposal_votes(db, &proposal_key).await?; + let proposal_current = current_vote_state(&snapshots, &proposal_votes, reference_time, height); + let proposal_final = load_finished_vote(db, FINISHED_PROPOSAL_VOTES_TREE, &proposal_key)?; + let proposal_state = if proposal_final.is_some() { + "approved" + } else { + "open" + }; + + let implementations = + load_implementations(db, &proposal_key, &snapshots, reference_time, height).await?; + Ok(ProposalView { + proposal_key: encode(&proposal_key), + proposal_document_hash: proposal.unsigned_proposalkey.proposal_hash, + creator: proposal.unsigned_proposalkey.address, + created_timestamp: proposal.unsigned_proposalkey.time, + state: proposal_state, + current_vote: proposal_current, + final_vote: proposal_final, + implementations, + }) +} + +async fn load_proposal_votes(db: &Db, proposal_key: &[u8]) -> Result, String> { + let tree = db + .open_tree(PROPOSAL_VOTES_TREE) + .map_err(|err| format!("Could not open proposal votes: {err}"))?; + let mut votes = Vec::new(); + for entry in tree.scan_prefix(proposal_key) { + let (key, value) = entry.map_err(|err| format!("Could not read proposal vote: {err}"))?; + if key.len() != PROPOSAL_VOTE_RECORD_KEY_BYTES || value.len() != ProposalVote::BYTE_LENGTH { + return Err("Stored proposal vote has an invalid byte count.".to_string()); + } + let address = Wallet::bytes_to_short_address(&key[GOVERNANCE_DOCUMENT_HASH_BYTES..]) + .ok_or_else(|| "Stored proposal voter address is invalid.".to_string())?; + let vote = value[ProposalVote::VOTE_BYTE_OFFSET]; + if vote > 1 { + return Err("Stored proposal vote value is invalid.".to_string()); + } + votes.push(StoredVote { address, vote }); + } + votes.sort_by(|left, right| left.address.cmp(&right.address)); + Ok(votes) +} + +async fn load_implementations( + db: &Db, + proposal_key: &[u8], + snapshots: &[crate::common::governance::GovernanceNodeSnapshot], + reference_time: u64, + height: u32, +) -> Result, String> { + let tree = db + .open_tree(ACTIVATION_VOTES_TREE) + .map_err(|err| format!("Could not open activation votes: {err}"))?; + let mut candidates: BTreeMap, Vec<(Vec, Vec)>> = BTreeMap::new(); + for entry in tree.scan_prefix(proposal_key) { + let (key, value) = entry.map_err(|err| format!("Could not read activation vote: {err}"))?; + if key.len() != ACTIVATION_VOTE_RECORD_KEY_BYTES + || value.len() != ActivationVote::BYTE_LENGTH + { + return Err("Stored activation vote has an invalid byte count.".to_string()); + } + candidates + .entry(key[..ACTIVATION_CANDIDATE_KEY_BYTES].to_vec()) + .or_default() + .push((key.to_vec(), value.to_vec())); + } + + let finished_tree = db + .open_tree(FINISHED_ACTIVATION_VOTES_TREE) + .map_err(|err| format!("Could not open finished activation votes: {err}"))?; + for entry in finished_tree.scan_prefix(proposal_key) { + let (candidate, _) = + entry.map_err(|err| format!("Could not read finished activation vote: {err}"))?; + candidates.entry(candidate.to_vec()).or_default(); + } + + let schedule = db + .open_tree(CONSENSUS_ACTIVATIONS_TREE) + .map_err(|err| format!("Could not open activation schedules: {err}"))? + .get(proposal_key) + .map_err(|err| format!("Could not read activation schedule: {err}"))? + .map(|value| value.to_vec()); + let mut views = Vec::new(); + for (candidate_key, records) in candidates { + let development_hash = encode(&candidate_key[GOVERNANCE_DOCUMENT_HASH_BYTES..]); + let mut stored_votes = Vec::new(); + let mut development_location = String::new(); + for (key, value) in records { + let transaction = ActivationVote::from_bytes(value[0], &value[1..]) + .await + .map_err(|err| format!("Could not parse stored activation vote: {err}"))?; + if development_location.is_empty() { + development_location = transaction + .unsigned_activationvote + .development_location + .trim_end() + .to_string(); + } + let address = Wallet::bytes_to_short_address( + &key[ACTIVATION_CANDIDATE_KEY_BYTES..ACTIVATION_VOTE_RECORD_KEY_BYTES], + ) + .ok_or_else(|| "Stored activation voter address is invalid.".to_string())?; + stored_votes.push(StoredVote { + address, + vote: transaction.unsigned_activationvote.vote, + }); + } + stored_votes.sort_by(|left, right| left.address.cmp(&right.address)); + let current = current_vote_state(snapshots, &stored_votes, reference_time, height); + let final_vote = load_finished_vote(db, FINISHED_ACTIVATION_VOTES_TREE, &candidate_key)?; + let activation_block = final_vote.as_ref().and_then(|_| { + schedule.as_ref().and_then(|value| { + if value.len() == 136 && value[..32] == candidate_key[32..64] { + Some(u32::from_le_bytes(value[32..36].try_into().ok()?)) + } else { + None + } + }) + }); + let state = match activation_block { + Some(block) if height >= block => "active", + Some(_) => "scheduled", + None => "open", + }; + views.push(ImplementationView { + development_hash, + development_location, + state, + current_vote: current, + final_vote, + activation_block, + }); + } + Ok(views) +} + +fn current_vote_state( + snapshots: &[crate::common::governance::GovernanceNodeSnapshot], + votes: &[StoredVote], + reference_time: u64, + height: u32, +) -> CurrentVoteState { + let existing: HashSet = votes.iter().map(|vote| vote.address.clone()).collect(); + let countable = countable_governance_addresses(snapshots, &existing, reference_time, height); + let votes_by_address: HashMap<&str, u8> = votes + .iter() + .map(|vote| (vote.address.as_str(), vote.vote)) + .collect(); + let yes = countable + .iter() + .filter(|address| votes_by_address.get(address.as_str()) == Some(&1)) + .count() as u64; + let no = countable + .iter() + .filter(|address| votes_by_address.get(address.as_str()) == Some(&0)) + .count() as u64; + let eligible_nodes = countable.len() as u64; + CurrentVoteState { + yes, + no, + eligible_nodes, + approval_percent: percentage(yes, eligible_nodes), + votes: votes + .iter() + .map(|vote| GovernanceVoteView { + address: vote.address.clone(), + vote: if vote.vote == 1 { "yes" } else { "no" }, + counted: countable.contains(&vote.address), + }) + .collect(), + } +} + +fn load_finished_vote( + db: &Db, + tree_name: &'static str, + key: &[u8], +) -> Result, String> { + let Some(value) = db + .open_tree(tree_name) + .map_err(|err| format!("Could not open {tree_name}: {err}"))? + .get(key) + .map_err(|err| format!("Could not read {tree_name}: {err}"))? + else { + return Ok(None); + }; + if value.len() < 28 { + return Err(format!("Stored {tree_name} value is malformed.")); + } + let yes = read_u64(&value, 0)?; + let no = read_u64(&value, 8)?; + let eligible_nodes = read_u64(&value, 16)?; + Ok(Some(FinalVoteState { + yes, + no, + eligible_nodes, + approval_percent: percentage(yes, eligible_nodes), + finalized_block: read_u32(&value, 24)?, + })) +} + +fn percentage(yes: u64, eligible: u64) -> f64 { + if eligible == 0 { + 0.0 + } else { + yes as f64 * 100.0 / eligible as f64 + } +} + +fn read_u64(value: &[u8], offset: usize) -> Result { + Ok(u64::from_le_bytes( + value + .get(offset..offset + 8) + .ok_or_else(|| "Stored governance value is too short.".to_string())? + .try_into() + .unwrap(), + )) +} + +fn read_u32(value: &[u8], offset: usize) -> Result { + Ok(u32::from_le_bytes( + value + .get(offset..offset + 4) + .ok_or_else(|| "Stored governance value is too short.".to_string())? + .try_into() + .unwrap(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::blocks::proposal_key::UnsignedProposalKey; + + async fn store_proposal(db: &Db, key: &[u8]) { + let address = format!("{:040x}.cltc", 1); + let unsigned = + UnsignedProposalKey::new(200, 123, &crate::encode(vec![4; 32]), &address, 100_000_000) + .await; + let proposal = ProposalKey::load(unsigned, &"00".repeat(Wallet::SIGNATURE_LENGTH)).await; + db.open_tree(PROPOSAL_KEYS_TREE) + .unwrap() + .insert(key, proposal.to_bytes().await.unwrap()) + .unwrap(); + } + + #[tokio::test] + async fn lookup_returns_proposal_metadata_and_final_result() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let proposal_key = vec![3; 32]; + store_proposal(&db, &proposal_key).await; + db.open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .unwrap() + .insert( + &proposal_key, + crate::common::governance::encode_finished_vote(9, 1, 10, 50), + ) + .unwrap(); + + let view = build_proposal_view(&db, proposal_key).await.unwrap(); + + assert_eq!(view.state, "approved"); + assert_eq!(view.created_timestamp, 123); + let final_vote = view.final_vote.unwrap(); + assert_eq!(final_vote.yes, 9); + assert_eq!(final_vote.eligible_nodes, 10); + assert_eq!(final_vote.finalized_block, 50); + } + + #[tokio::test] + async fn missing_proposal_returns_json_error() { + let db = crate::sled::Config::new().temporary(true).open().unwrap(); + let RpcResponse::Binary(response) = lookup_proposal(&db, vec![7; 32]).await; + let json: serde_json::Value = serde_json::from_slice(&response).unwrap(); + + assert_eq!(json["error"], "Proposal key was not found."); + } +} diff --git a/src/rpc/commands/governance_state_sync.rs b/src/rpc/commands/governance_state_sync.rs new file mode 100644 index 0000000..708caec --- /dev/null +++ b/src/rpc/commands/governance_state_sync.rs @@ -0,0 +1,31 @@ +use crate::common::governance::{ + CONSENSUS_ACTIVATIONS_TREE, FINISHED_ACTIVATION_VOTES_TREE, FINISHED_PROPOSAL_VOTES_TREE, +}; +use crate::rpc::responses::RpcResponse; +use crate::sled::Db; + +pub const FINISHED_PROPOSAL_TAG: u8 = 0; +pub const FINISHED_ACTIVATION_TAG: u8 = 1; +pub const CONSENSUS_ACTIVATION_TAG: u8 = 2; + +pub async fn request_governance_state(db: &Db) -> RpcResponse { + let mut response = Vec::new(); + for (tree_name, tag) in [ + (FINISHED_PROPOSAL_VOTES_TREE, FINISHED_PROPOSAL_TAG), + (FINISHED_ACTIVATION_VOTES_TREE, FINISHED_ACTIVATION_TAG), + (CONSENSUS_ACTIVATIONS_TREE, CONSENSUS_ACTIVATION_TAG), + ] { + let Ok(tree) = db.open_tree(tree_name) else { + return RpcResponse::Binary(Vec::new()); + }; + for entry in tree.iter() { + let Ok((key, value)) = entry else { + return RpcResponse::Binary(Vec::new()); + }; + response.push(tag); + response.extend_from_slice(&key); + response.extend_from_slice(&value); + } + } + RpcResponse::Binary(response) +} diff --git a/src/rpc/commands/mod.rs b/src/rpc/commands/mod.rs index 7198791..adbcbb3 100644 --- a/src/rpc/commands/mod.rs +++ b/src/rpc/commands/mod.rs @@ -14,6 +14,8 @@ pub mod block_height; pub mod block_peer_ip; pub mod contract; pub mod difficulty; +pub mod governance_proposal; +pub mod governance_state_sync; pub mod largest_tx_fee; pub mod latest_block; pub mod marketing_campaign_history; diff --git a/src/rpc/commands/nft_list.rs b/src/rpc/commands/nft_list.rs index 6ee0cb7..3e5cf1d 100644 --- a/src/rpc/commands/nft_list.rs +++ b/src/rpc/commands/nft_list.rs @@ -8,12 +8,13 @@ pub async fn get_nfts(db: &Db) -> RpcResponse { // series number for the compact NFT-list RPC response format. let tree = db.open_tree("nfts").unwrap(); let origin_tree = db.open_tree("nft_origins").unwrap(); + let supply_tree = db.open_tree("nft_supply").unwrap(); let mut serialized_nfts = Vec::new(); for result in tree.iter() { match result { - Ok((key, _value)) => { + Ok((key, value)) => { let raw_key = String::from_utf8_lossy(&key).to_string(); let (nft_name, nft_series) = nft_asset_parts(&raw_key); // NFT origins are kept separately so list responses can @@ -36,6 +37,15 @@ pub async fn get_nfts(db: &Db) -> RpcResponse { if hash_bytes.len() != 32 { continue; } + let Some(ownership_type) = value.first().copied() else { + continue; + }; + let Some(supply_bytes) = supply_tree.get(&key).ok().flatten() else { + continue; + }; + if supply_bytes.len() != 8 { + continue; + } let mut padded_key = [32_u8; 15]; let nft_name_bytes = nft_name.as_bytes(); @@ -47,6 +57,8 @@ pub async fn get_nfts(db: &Db) -> RpcResponse { serialized_nfts.extend_from_slice(&hash_bytes); serialized_nfts.extend_from_slice(&padded_key); serialized_nfts.extend_from_slice(&nft_series.to_le_bytes()); + serialized_nfts.push(ownership_type); + serialized_nfts.extend_from_slice(&supply_bytes); } Err(_) => continue, } diff --git a/src/rpc/commands/tx_submit.rs b/src/rpc/commands/tx_submit.rs index c96ad02..f31ddff 100644 --- a/src/rpc/commands/tx_submit.rs +++ b/src/rpc/commands/tx_submit.rs @@ -6,6 +6,8 @@ use crate::blocks::loan_payment::ContractPaymentTransaction; use crate::blocks::loans::LoanContractTransaction; use crate::blocks::marketing::MarketingTransaction; use crate::blocks::nft::CreateNftTransaction; +use crate::blocks::proposal_key::ProposalKey; +use crate::blocks::proposal_vote::ProposalVote; use crate::blocks::storage_key::StorageKey; use crate::blocks::storage_values::{ StorageBool, StorageI128, StorageI16, StorageI32, StorageI64, StorageI8, StorageString, @@ -16,11 +18,12 @@ use crate::blocks::token::CreateTokenTransaction; use crate::blocks::transfer::TransferTransaction; use crate::blocks::vanity::VanityAddressTransaction; use crate::common::types::{ - BORROWER_TYPE, BURN_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, CREATE_TOKEN_TYPE, DELETE_KEY_TYPE, - ISSUE_TOKEN_TYPE, LENDER_TYPE, MARKETING_TYPE, STORAGE_BOOL_TYPE, STORAGE_I128_TYPE, - STORAGE_I16_TYPE, STORAGE_I32_TYPE, STORAGE_I64_TYPE, STORAGE_I8_TYPE, STORAGE_KEY_TYPE, - STORAGE_STRING_TYPE, STORAGE_U128_TYPE, STORAGE_U16_TYPE, STORAGE_U32_TYPE, STORAGE_U64_TYPE, - STORAGE_U8_TYPE, SWAP_TYPE, TRANSFER_TYPE, VANITY_ADDRESS_TYPE, + ACTIVATION_VOTE_TYPE, BORROWER_TYPE, BURN_TYPE, COLLATERAL_TYPE, CREATE_NFT_TYPE, + CREATE_TOKEN_TYPE, DELETE_KEY_TYPE, ISSUE_TOKEN_TYPE, LENDER_TYPE, MARKETING_TYPE, + PROPOSAL_KEY_TYPE, PROPOSAL_VOTE_TYPE, STORAGE_BOOL_TYPE, STORAGE_I128_TYPE, STORAGE_I16_TYPE, + STORAGE_I32_TYPE, STORAGE_I64_TYPE, STORAGE_I8_TYPE, STORAGE_KEY_TYPE, STORAGE_STRING_TYPE, + STORAGE_U128_TYPE, STORAGE_U16_TYPE, STORAGE_U32_TYPE, STORAGE_U64_TYPE, STORAGE_U8_TYPE, + SWAP_TYPE, TRANSFER_TYPE, VANITY_ADDRESS_TYPE, }; use crate::encode; use crate::records::memory::connections::get_client_type_from_memory; @@ -638,6 +641,9 @@ pub async fn save_and_submit( STORAGE_I64_TYPE => submit_storage_value!(StorageI64, txtype, tx, db, map), STORAGE_I128_TYPE => submit_storage_value!(StorageI128, txtype, tx, db, map), DELETE_KEY_TYPE => submit_storage_value!(DeleteKey, txtype, tx, db, map), + PROPOSAL_KEY_TYPE => submit_storage_value!(ProposalKey, txtype, tx, db, map), + PROPOSAL_VOTE_TYPE => submit_storage_value!(ProposalVote, txtype, tx, db, map), + ACTIVATION_VOTE_TYPE => submit_storage_value!(ActivationVote, txtype, tx, db, map), _ => { let msg = "error: No such transaction type" .to_string() @@ -647,3 +653,4 @@ pub async fn save_and_submit( } } } +use crate::blocks::activation_vote::ActivationVote; diff --git a/src/rpc/server/handshake.rs b/src/rpc/server/handshake.rs index a4a6636..dfaacc6 100644 --- a/src/rpc/server/handshake.rs +++ b/src/rpc/server/handshake.rs @@ -13,6 +13,7 @@ use crate::records::memory::response_channels::generate_uid; use crate::records::memory::response_channels::Command; use crate::records::memory::structs::Connection; use crate::rpc::client::genesis_compat::ensure_compatible_genesis; +use crate::rpc::client::governance_state_sync::sync_governance_state; use crate::rpc::client::register_wallet::register_connected_wallet; use crate::rpc::client::syncing::node_syncing; use crate::rpc::client::wallet_registry_sync::sync_wallet_registry_with_retries; @@ -86,6 +87,8 @@ async fn sync_incoming_peer_before_operational( ) .await?; let initial_local_genesis_exists = genesis_checkup().await; + let should_import_governance = + !initial_local_genesis_exists || initial_remote_height > initial_local_height + 10; if initial_local_genesis_exists && initial_remote_height < initial_local_height { warn!( @@ -175,6 +178,12 @@ async fn sync_incoming_peer_before_operational( .map_err(|err| format!("Incoming post-sync orphan check error: {err}"))?; } + if should_import_governance { + sync_governance_state(stream, db, map, connections_key.to_string()) + .await + .map_err(|err| format!("Incoming governance state sync error: {err}"))?; + } + Ok((true, Some(chain_sync_guard))) } diff --git a/src/rpc/server/rpc_command_loop.rs b/src/rpc/server/rpc_command_loop.rs index b79aca4..53eb853 100644 --- a/src/rpc/server/rpc_command_loop.rs +++ b/src/rpc/server/rpc_command_loop.rs @@ -802,6 +802,39 @@ pub async fn start_loop( .send(&stream_locked, Some(&connections_key), uid) .await; } + 56 => { + // Return finalized governance decisions and activation schedules. + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + + let result = commands::governance_state_sync::request_governance_state(&db).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } + 57 => { + // Look up the complete voting and activation state for a proposal. + let (uid, _) = read_bytes_from_stream::read_uid_from_stream( + &connections_key, + stream_locked.clone(), + ) + .await?; + let proposal_key = read_bytes_from_stream::read_usize_from_stream( + &connections_key, + 32, + stream_locked.clone(), + ) + .await?; + + let result = + commands::governance_proposal::lookup_proposal(&db, proposal_key).await; + result + .send(&stream_locked, Some(&connections_key), uid) + .await; + } 40 => { // request the vanity address registered to a canonical wallet let (uid, _) = read_bytes_from_stream::read_uid_from_stream( diff --git a/src/standalone_tools/connections/sending_request.rs b/src/standalone_tools/connections/sending_request.rs index 150be84..20a9d46 100644 --- a/src/standalone_tools/connections/sending_request.rs +++ b/src/standalone_tools/connections/sending_request.rs @@ -6,14 +6,14 @@ 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_COLLATERAL_STATUS, - RPC_CONTRACT_BY_ADDRESS, RPC_DIFFICULTY, 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_STORAGE_LOOKUP, RPC_STORAGE_LOOKUP_COST, 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, - RPC_WALLET_REGISTRY_SYNC, + RPC_CONTRACT_BY_ADDRESS, RPC_DIFFICULTY, RPC_GOVERNANCE_PROPOSAL, 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_STORAGE_LOOKUP, + RPC_STORAGE_LOOKUP_COST, 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, RPC_WALLET_REGISTRY_SYNC, }; use crate::standalone_tools::transaction_creator::create_transaction_request; use crate::standalone_tools::vanity_resolver::canonical_vanity_address_input; @@ -771,6 +771,25 @@ async fn build_request_bytes( bin_msg.extend_from_slice(&address_bytes); bin_msg.extend_from_slice(&transfer_tx); } + // Look up complete governance state by its 32-byte proposal key. + 57 => { + let command_number: u8 = RPC_GOVERNANCE_PROPOSAL; + let proposal_key = decode(&command_input).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid proposal key: {err}"), + ) + })?; + if proposal_key.len() != 32 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Proposal key must decode to 32 bytes", + )); + } + bin_msg.extend_from_slice(&command_number.to_le_bytes()); + bin_msg.extend_from_slice(&hashmap_key); + bin_msg.extend_from_slice(&proposal_key); + } _ => { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -785,7 +804,7 @@ async fn build_request_bytes( #[cfg(test)] mod tests { use super::build_request_bytes; - use crate::rpc::command_maps::RPC_VALIDATE_MESSAGE; + use crate::rpc::command_maps::{RPC_GOVERNANCE_PROPOSAL, RPC_VALIDATE_MESSAGE}; use crate::wallets::structures::Wallet; #[tokio::test] @@ -807,4 +826,21 @@ mod tests { assert_eq!(&bytes[6..13], b"message"); assert_eq!(bytes.len(), 1 + 3 + 2 + 7 + 22 + Wallet::SIGNATURE_LENGTH); } + + #[tokio::test] + async fn governance_lookup_request_uses_proposal_key_bytes() { + let uid = [4, 5, 6]; + let proposal_key = vec![9; 32]; + let bytes = build_request_bytes( + crate::encode(&proposal_key), + RPC_GOVERNANCE_PROPOSAL as usize, + uid, + ) + .await + .unwrap(); + + assert_eq!(bytes[0], RPC_GOVERNANCE_PROPOSAL); + assert_eq!(&bytes[1..4], &uid); + assert_eq!(&bytes[4..], proposal_key); + } } diff --git a/src/standalone_tools/governance.rs b/src/standalone_tools/governance.rs new file mode 100644 index 0000000..5d51b18 --- /dev/null +++ b/src/standalone_tools/governance.rs @@ -0,0 +1,86 @@ +use crate::common::cli_prompts::{ + prompt_hidden_nonempty, prompt_visible_with_default, prompt_wallet_path, +}; +use crate::common::governance::is_valid_governance_hash; +use crate::wallets::structures::Wallet; +use crate::{create_dir_all, AsyncWriteExt, File}; +use serde_json::Value; + +pub fn display_fee(value: u64) -> f64 { + value as f64 / 100_000_000.0 +} + +pub fn parse_fee(value: &str, minimum: u64) -> Result { + let decimal = value + .trim() + .parse::() + .map_err(|_| "Please enter a valid transaction fee.".to_string())?; + if !decimal.is_finite() || decimal.is_sign_negative() { + return Err("Please enter a valid transaction fee.".to_string()); + } + let atomic = (decimal * 100_000_000.0).round() as u64; + if atomic < minimum { + return Err(format!( + "Transaction fee must be at least {:.8}.", + display_fee(minimum) + )); + } + Ok(atomic) +} + +pub async fn prompt_fee(label: &str, minimum: u64) -> u64 { + let default = format!("{:.8}", display_fee(minimum)); + loop { + let value = + prompt_visible_with_default(&format!("Please enter the {label} fee"), &default).await; + match parse_fee(&value, minimum) { + Ok(fee) => return fee, + Err(err) => println!("{err}"), + } + } +} + +pub fn validate_hash(value: &str, label: &str) -> Result { + let value = value.trim(); + if !is_valid_governance_hash(value) { + return Err(format!("{label} must be 64 hexadecimal characters.")); + } + Ok(value.to_ascii_lowercase()) +} + +pub fn parse_vote(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "yes" | "y" | "1" => Ok(1), + "no" | "n" | "0" => Ok(0), + _ => Err("Vote must be yes or no.".to_string()), + } +} + +pub async fn load_wallet() -> Result { + let wallet_path = prompt_wallet_path().await; + let key = prompt_hidden_nonempty( + "What is your wallet decryption key? ", + "Wallet key cannot be empty. Please try again.", + ) + .await; + Wallet::try_obtain_wallet(key, Some(&wallet_path)) + .await + .map_err(|err| format!("Wallet decryption failed: {err}")) +} + +pub async fn write_transaction(hash: &str, output: &Value) -> Result<(), String> { + let output = serde_json::to_string_pretty(output) + .map_err(|err| format!("Could not serialize transaction JSON: {err}"))?; + create_dir_all("./transactions") + .await + .map_err(|err| format!("Could not create transactions directory: {err}"))?; + let path = format!("./transactions/{hash}.json"); + let mut file = File::create(&path) + .await + .map_err(|err| format!("Could not create {path}: {err}"))?; + file.write_all(output.as_bytes()) + .await + .map_err(|err| format!("Could not write {path}: {err}"))?; + println!("{output}"); + Ok(()) +} diff --git a/src/standalone_tools/mod.rs b/src/standalone_tools/mod.rs index 83c54d3..521e641 100644 --- a/src/standalone_tools/mod.rs +++ b/src/standalone_tools/mod.rs @@ -1,5 +1,6 @@ // The standalone_tools module holds utility helpers that mirror node serialization and connection behavior. pub mod connections; +pub mod governance; pub mod loan_lookup_json; pub mod transaction_creator; pub mod vanity_resolver; diff --git a/src/standalone_tools/transaction_creator.rs b/src/standalone_tools/transaction_creator.rs index df7fec0..c29b3a2 100644 --- a/src/standalone_tools/transaction_creator.rs +++ b/src/standalone_tools/transaction_creator.rs @@ -1,3 +1,4 @@ +use crate::blocks::activation_vote::{ActivationVote, UnsignedActivationVote}; use crate::blocks::burn::{BurnTransaction, UnsignedBurnTransaction}; use crate::blocks::collateral::{CollateralClaimTransaction, UnsignedCollateralClaimTransaction}; use crate::blocks::delete_key::{DeleteKey, UnsignedDeleteKey}; @@ -6,6 +7,8 @@ use crate::blocks::loan_payment::{ContractPaymentTransaction, UnsignedContractPa use crate::blocks::loans::{LoanContractTransaction, UnsignedLoanContractTransaction}; use crate::blocks::marketing::{MarketingTransaction, UnsignedMarketingTransaction}; use crate::blocks::nft::{CreateNftTransaction, UnsignedCreateNftTransaction}; +use crate::blocks::proposal_key::{ProposalKey, UnsignedProposalKey}; +use crate::blocks::proposal_vote::{ProposalVote, UnsignedProposalVote}; use crate::blocks::storage_key::{StorageKey, UnsignedStorageKey}; use crate::blocks::storage_values::{ StorageBool, StorageI128, StorageI16, StorageI32, StorageI64, StorageI8, StorageString, @@ -19,9 +22,10 @@ use crate::blocks::token::{CreateTokenTransaction, UnsignedCreateTokenTransactio use crate::blocks::transfer::{TransferTransaction, UnsignedTransferTransaction}; use crate::blocks::vanity::{UnsignedVanityAddressTransaction, VanityAddressTransaction}; use crate::common::types::{ - DELETE_KEY_TYPE, STORAGE_BOOL_TYPE, STORAGE_I128_TYPE, STORAGE_I16_TYPE, STORAGE_I32_TYPE, - STORAGE_I64_TYPE, STORAGE_I8_TYPE, STORAGE_KEY_TYPE, STORAGE_STRING_TYPE, STORAGE_U128_TYPE, - STORAGE_U16_TYPE, STORAGE_U32_TYPE, STORAGE_U64_TYPE, STORAGE_U8_TYPE, + ACTIVATION_VOTE_TYPE, DELETE_KEY_TYPE, PROPOSAL_KEY_TYPE, PROPOSAL_VOTE_TYPE, + STORAGE_BOOL_TYPE, STORAGE_I128_TYPE, STORAGE_I16_TYPE, STORAGE_I32_TYPE, STORAGE_I64_TYPE, + STORAGE_I8_TYPE, STORAGE_KEY_TYPE, STORAGE_STRING_TYPE, STORAGE_U128_TYPE, STORAGE_U16_TYPE, + STORAGE_U32_TYPE, STORAGE_U64_TYPE, STORAGE_U8_TYPE, }; use crate::records::memory::response_channels::Byte3; use crate::rpc::command_maps::RPC_SUBMIT_TRANSACTION; @@ -238,6 +242,9 @@ async fn create_transaction_bytes(transaction: &Value) -> Result, String .as_str() .ok_or("Missing creator wallet address.")?, transaction["series"].as_u64().ok_or("Missing series.")? as u8, + transaction["ownership_type"] + .as_u64() + .ok_or("Missing ownership_type.")? as u8, transaction["nft_name"] .as_str() .ok_or("Missing or invalid NFT name.")?, @@ -1042,8 +1049,164 @@ async fn create_transaction_bytes(transaction: &Value) -> Result, String .await .map_err(|e| format!("Failed to serialize delete-key transaction: {e}"))? } + PROPOSAL_KEY_TYPE => { + let unsigned = UnsignedProposalKey::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["proposal_hash"] + .as_str() + .ok_or("Missing proposal document hash.")?, + transaction["address"] + .as_str() + .ok_or("Missing proposal creator wallet address.")?, + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + ProposalKey::load( + unsigned, + transaction["signature"] + .as_str() + .ok_or("Missing transaction signature.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize proposal-key transaction: {e}"))? + } + PROPOSAL_VOTE_TYPE => { + let unsigned = UnsignedProposalVote::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["proposal_key"] + .as_str() + .ok_or("Missing proposal key.")?, + transaction["address"] + .as_str() + .ok_or("Missing proposal voter wallet address.")?, + transaction["vote"] + .as_u64() + .ok_or("Missing or invalid proposal vote.")? as u8, + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + ProposalVote::load( + unsigned, + transaction["signature"] + .as_str() + .ok_or("Missing transaction signature.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize proposal-vote transaction: {e}"))? + } + ACTIVATION_VOTE_TYPE => { + let unsigned = UnsignedActivationVote::new( + txtype, + read_u32_field(transaction, "timestamp", "time").ok_or("Missing timestamp.")?, + transaction["proposal_key"] + .as_str() + .ok_or("Missing proposal key.")?, + transaction["development_hash"] + .as_str() + .ok_or("Missing implementation document hash.")?, + transaction["development_location"] + .as_str() + .ok_or("Missing implementation document location.")?, + transaction["address"] + .as_str() + .ok_or("Missing activation voter wallet address.")?, + transaction["vote"] + .as_u64() + .ok_or("Missing or invalid activation vote.")? as u8, + transaction["txfee"] + .as_u64() + .ok_or("Missing or invalid txfee.")?, + ) + .await; + ActivationVote::load( + unsigned, + transaction["signature"] + .as_str() + .ok_or("Missing transaction signature.")?, + ) + .await + .to_bytes() + .await + .map_err(|e| format!("Failed to serialize activation-vote transaction: {e}"))? + } _ => return Err("No such transaction type".to_string()), }; Ok(transaction_bytes) } + +#[cfg(test)] +mod tests { + use super::create_transaction_bytes; + use crate::blocks::activation_vote::ActivationVote; + use crate::blocks::proposal_key::ProposalKey; + use crate::blocks::proposal_vote::ProposalVote; + use crate::common::governance::normalize_development_location; + use crate::json; + use crate::wallets::structures::Wallet; + + fn address() -> String { + format!("{:040x}.cltc", 1) + } + + #[tokio::test] + async fn governance_transaction_json_rebuilds_fixed_wire_bytes() { + let signature = "00".repeat(Wallet::SIGNATURE_LENGTH); + let proposal_hash = crate::encode(vec![2; 32]); + let proposal_key = crate::encode(vec![3; 32]); + let development_hash = crate::encode(vec![4; 32]); + let location = normalize_development_location("CLP-1-code.md").unwrap(); + + let proposal = create_transaction_bytes(&json!({ + "txtype": 200, + "timestamp": 10, + "proposal_hash": proposal_hash, + "address": address(), + "txfee": 100_000_000_u64, + "signature": signature, + })) + .await + .unwrap(); + let proposal_vote = create_transaction_bytes(&json!({ + "txtype": 201, + "timestamp": 11, + "proposal_key": proposal_key, + "address": address(), + "vote": 1, + "txfee": 100_000_000_u64, + "signature": "00".repeat(Wallet::SIGNATURE_LENGTH), + })) + .await + .unwrap(); + let activation_vote = create_transaction_bytes(&json!({ + "txtype": 202, + "timestamp": 12, + "proposal_key": crate::encode(vec![3; 32]), + "development_hash": development_hash, + "development_location": location, + "address": address(), + "vote": 0, + "txfee": 100_000_000_u64, + "signature": "00".repeat(Wallet::SIGNATURE_LENGTH), + })) + .await + .unwrap(); + + assert_eq!(proposal.len(), ProposalKey::BYTE_LENGTH); + assert_eq!(proposal_vote.len(), ProposalVote::BYTE_LENGTH); + assert_eq!(activation_vote.len(), ActivationVote::BYTE_LENGTH); + assert_eq!(proposal[0], 200); + assert_eq!(proposal_vote[0], 201); + assert_eq!(activation_vote[0], 202); + } +} diff --git a/src/verifications/async_funcs/checks/block_balance.rs b/src/verifications/async_funcs/checks/block_balance.rs index 563b2a6..8230f89 100644 --- a/src/verifications/async_funcs/checks/block_balance.rs +++ b/src/verifications/async_funcs/checks/block_balance.rs @@ -438,6 +438,48 @@ async fn transaction_balance_view( check_saved_txid: true, }) } + Transaction::ProposalKey(tx) => { + let proposal = &tx.unsigned_proposalkey; + let mut debits = Vec::new(); + + add_debit( + &mut debits, + &proposal.address, + BASECOIN.clone(), + proposal.txfee, + ); + + Ok(TransactionBalanceView { + hash: proposal.hash().await, + signatures: vec![tx.signature.clone()], + debits, + check_saved_txid: true, + }) + } + Transaction::ProposalVote(tx) => { + let vote = &tx.unsigned_proposalvote; + let mut debits = Vec::new(); + + add_debit(&mut debits, &vote.address, BASECOIN.clone(), vote.txfee); + + Ok(TransactionBalanceView { + hash: vote.hash().await, + signatures: vec![tx.signature.clone()], + debits, + check_saved_txid: true, + }) + } + Transaction::ActivationVote(tx) => { + let vote = &tx.unsigned_activationvote; + let mut debits = Vec::new(); + add_debit(&mut debits, &vote.address, BASECOIN.clone(), vote.txfee); + Ok(TransactionBalanceView { + hash: vote.hash().await, + signatures: vec![tx.signature.clone()], + debits, + check_saved_txid: true, + }) + } Transaction::StorageBool(tx) => { let storage = &tx.unsigned_storagebool; let mut debits = Vec::new(); diff --git a/src/verifications/async_funcs/mod.rs b/src/verifications/async_funcs/mod.rs index 426040b..a0a5884 100644 --- a/src/verifications/async_funcs/mod.rs +++ b/src/verifications/async_funcs/mod.rs @@ -5,6 +5,7 @@ pub mod checks; pub mod total_payments; pub mod transactions; pub mod validate_torrent_data; +pub mod verify_activation_vote; pub mod verify_block; pub mod verify_borrower; pub mod verify_burn; @@ -16,6 +17,8 @@ pub mod verify_genesis; pub mod verify_issue_token; pub mod verify_lender; pub mod verify_marketing; +pub mod verify_proposal_key; +pub mod verify_proposal_vote; pub mod verify_rewards; pub mod verify_storage_key; pub mod verify_storage_values; diff --git a/src/verifications/async_funcs/transactions.rs b/src/verifications/async_funcs/transactions.rs index 99b8c1b..3d11ba3 100644 --- a/src/verifications/async_funcs/transactions.rs +++ b/src/verifications/async_funcs/transactions.rs @@ -313,6 +313,62 @@ async fn verify_transaction( } } } + if let Transaction::ProposalKey(proposal_key_tx) = &transaction { + match proposal_key_tx.verify(db).await { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker, already_in_mempool) + .await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for proposal key transaction: {err}" + )); + } + } + } + if let Transaction::ProposalVote(proposal_vote_tx) = &transaction { + match proposal_vote_tx + .verify_at( + db, + block_timestamp, + crate::records::block_height::get_block_height::get_height(db).saturating_add(1), + ) + .await + { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker, already_in_mempool) + .await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for proposal vote transaction: {err}" + )); + } + } + } + if let Transaction::ActivationVote(activation_vote_tx) = &transaction { + match activation_vote_tx + .verify_at( + db, + block_timestamp, + crate::records::block_height::get_block_height::get_height(db).saturating_add(1), + ) + .await + { + Ok(value) => { + reserve_verified_transaction(db, &transaction, balance_tracker, already_in_mempool) + .await?; + return Ok(value); + } + Err(err) => { + return Err(format!( + "Validation failed for activation vote transaction: {err}" + )); + } + } + } if let Transaction::StorageBool(storage_tx) = &transaction { match storage_tx.verify(db).await { Ok(value) => { diff --git a/src/verifications/async_funcs/verify_activation_vote.rs b/src/verifications/async_funcs/verify_activation_vote.rs new file mode 100644 index 0000000..7006df8 --- /dev/null +++ b/src/verifications/async_funcs/verify_activation_vote.rs @@ -0,0 +1,142 @@ +use crate::blocks::activation_vote::ActivationVote; +use crate::common::governance::{ + activation_candidate_key, activation_vote_record_key, is_valid_governance_hash, + is_valid_padded_development_location, GovernanceVote, ACTIVATION_VOTES_TREE, + CONSENSUS_ACTIVATIONS_TREE, FINISHED_ACTIVATION_VOTES_TREE, FINISHED_PROPOSAL_VOTES_TREE, +}; +use crate::common::types::{ACTIVATION_VOTE_FEE, ACTIVATION_VOTE_TYPE}; +use crate::encode; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::mempool::{activation_vote_exists, BASECOIN}; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::wallet_registry::{ + require_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::verify_db::db_hex_verification; +use crate::wallets::structures::Wallet; + +impl ActivationVote { + pub async fn verify(&self, db: &Db) -> Result { + self.verify_at( + db, + crate::Utc::now().timestamp() as u32, + get_height(db).saturating_add(1), + ) + .await + } + + pub async fn verify_at( + &self, + db: &Db, + reference_timestamp: u32, + vote_height: u32, + ) -> Result { + let vote = &self.unsigned_activationvote; + if vote.txtype != ACTIVATION_VOTE_TYPE { + return Err("Activation vote transaction type is invalid.".to_string()); + } + GovernanceVote::try_from(vote.vote)?; + if !is_valid_governance_hash(&vote.proposal_key) { + return Err("Proposal key must decode to exactly 32 bytes.".to_string()); + } + if !is_valid_governance_hash(&vote.development_hash) { + return Err("Development document hash must decode to exactly 32 bytes.".to_string()); + } + if !is_valid_padded_development_location(&vote.development_location) { + return Err( + "Development location must be a padded, non-empty 100-byte ASCII value." + .to_string(), + ); + } + + if !Wallet::short_address_validation(&vote.address) { + return Err("Activation voter wallet address is invalid.".to_string()); + } + require_canonical_registered_short_address( + db, + &vote.address, + "Activation voter wallet address", + )?; + let public_key = resolve_pubkey_from_short_address(db, &vote.address) + .map_err(|_| "Activation voter wallet address is not registered.".to_string())? + .ok_or_else(|| "Activation voter wallet address is not registered.".to_string())?; + let transaction_hash = vote.hash().await; + if !Wallet::verify_transaction_with_public_key( + &transaction_hash, + &self.signature, + &encode(&public_key), + ) + .await + { + return Err("Invalid signature for the Activation Vote Transaction.".to_string()); + } + + let proposal_key = crate::decode(&vote.proposal_key) + .map_err(|err| format!("Could not decode proposal key: {err}"))?; + if !db + .open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .map_err(|err| format!("Could not open approved proposals: {err}"))? + .contains_key(&proposal_key) + .map_err(|err| format!("Could not check proposal approval: {err}"))? + { + return Err("The proposal has not been approved.".to_string()); + } + if db + .open_tree(CONSENSUS_ACTIVATIONS_TREE) + .map_err(|err| format!("Could not open consensus activations: {err}"))? + .contains_key(&proposal_key) + .map_err(|err| format!("Could not check existing activation: {err}"))? + { + return Err("This proposal already has a scheduled implementation.".to_string()); + } + + let candidate = activation_candidate_key(&vote.proposal_key, &vote.development_hash)?; + if db + .open_tree(FINISHED_ACTIVATION_VOTES_TREE) + .map_err(|err| format!("Could not open finished activation votes: {err}"))? + .contains_key(&candidate) + .map_err(|err| format!("Could not check activation finalization: {err}"))? + { + return Err("This implementation vote is already finalized.".to_string()); + } + let vote_key = + activation_vote_record_key(&vote.proposal_key, &vote.development_hash, &vote.address)?; + if db + .open_tree(ACTIVATION_VOTES_TREE) + .map_err(|err| format!("Could not open activation votes: {err}"))? + .contains_key(vote_key) + .map_err(|err| format!("Could not check existing activation vote: {err}"))? + || activation_vote_exists(&vote.proposal_key, &vote.development_hash, &vote.address) + .await + .map_err(|err| err.to_string())? + { + return Err("This node wallet has already voted on this implementation.".to_string()); + } + + let voter = NodeInfo::governance_node_snapshot(&vote.address) + .await + .ok_or_else(|| "Activation votes may only be cast by network nodes.".to_string())?; + if !voter.can_cast_vote_at((reference_timestamp as u64) * 1_000, vote_height) { + return Err( + "Activation voter must be active for 60 minutes and have mined 100 blocks." + .to_string(), + ); + } + + if vote.txfee < ACTIVATION_VOTE_FEE { + return Err(format!( + "Activation vote transaction fee is below the minimum required fee of {ACTIVATION_VOTE_FEE}." + )); + } + if !balance_checkup(db, 0, vote.txfee, BASECOIN.clone(), &vote.address).await { + return Err("Insufficient funds for this Activation Vote Transaction!".to_string()); + } + if !db_hex_verification(db, "txid", &transaction_hash).await { + return Err("This transaction already exists.".to_string()); + } + + Ok(String::new()) + } +} diff --git a/src/verifications/async_funcs/verify_burn.rs b/src/verifications/async_funcs/verify_burn.rs index 53834f8..d1a9458 100644 --- a/src/verifications/async_funcs/verify_burn.rs +++ b/src/verifications/async_funcs/verify_burn.rs @@ -1,6 +1,6 @@ use crate::blocks::burn::BurnTransaction; use crate::common::asset_names::is_canonical_padded_asset_identifier; -use crate::common::nft_assets::nft_asset_name; +use crate::common::nft_assets::{nft_asset_name, nft_ownership_type, validate_nft_amount}; use crate::common::types::{BURN_FEE, COIN_LENGTH}; use crate::encode; use crate::records::memory::mempool::BASECOIN; @@ -14,8 +14,6 @@ use crate::verifications::async_funcs::checks::verify_db::{ }; use crate::wallets::structures::Wallet; -const NFT_UNIT: u64 = 100_000_000; - impl BurnTransaction { pub async fn verify(&self, db: &Db) -> Result { let hash = self.unsigned_burn.hash().await; @@ -55,7 +53,8 @@ impl BurnTransaction { } let burn_asset = nft_asset_name(&self.unsigned_burn.coin, self.unsigned_burn.nft_series); - let nft_exists = db_bytes_verification(db, "nfts", &burn_asset).await; + let nft_ownership = nft_ownership_type(db, &burn_asset)?; + let nft_exists = nft_ownership.is_some(); let token_exists = self.unsigned_burn.nft_series == 0 && db_bytes_verification(db, "tokens", &self.unsigned_burn.coin).await; @@ -64,12 +63,10 @@ impl BurnTransaction { return Err("This asset does not exist.".to_string()); } - // Live NFTs use a full NFT unit balance, while fungible tokens can - // burn any positive integer amount. - if nft_exists { - if self.unsigned_burn.value != NFT_UNIT { - return Err(format!("NFT burns must destroy exactly {NFT_UNIT} units.")); - } + // Indivisible assets burn as one complete unit. Fractional assets + // may burn any positive ownership amount up to one complete unit. + if let Some(ownership_type) = nft_ownership { + validate_nft_amount(ownership_type, self.unsigned_burn.value, "burn")?; } else if self.unsigned_burn.value == 0 { return Err("Burn value must be greater than 0.".to_string()); } diff --git a/src/verifications/async_funcs/verify_create_nft.rs b/src/verifications/async_funcs/verify_create_nft.rs index d2501f9..562d8b7 100644 --- a/src/verifications/async_funcs/verify_create_nft.rs +++ b/src/verifications/async_funcs/verify_create_nft.rs @@ -1,6 +1,8 @@ use crate::blocks::nft::CreateNftTransaction; use crate::common::asset_names::is_canonical_padded_asset_identifier; -use crate::common::nft_assets::nft_asset_name; +use crate::common::nft_assets::{ + nft_asset_name, NFT_OWNERSHIP_FRACTIONAL, NFT_OWNERSHIP_INDIVISIBLE, +}; use crate::common::types::{COIN_LENGTH, CREATE_NFT_FEE}; use crate::encode; use crate::records::memory::mempool::BASECOIN; @@ -75,9 +77,24 @@ impl CreateNftTransaction { return Err("A series must contain at least 2 items").map_err(|s| s.to_string())?; } + // Standalone NFTs and RWAs may be indivisible or fractional. + // Numbered collection items are always indivisible. + let ownership_type = self.unsigned_create_nft.ownership_type; + if ownership_type != NFT_OWNERSHIP_INDIVISIBLE && ownership_type != NFT_OWNERSHIP_FRACTIONAL + { + return Err("Ownership type must be 0 for indivisible or 1 for fractional.") + .map_err(|s| s.to_string())?; + } + if series == 1 && ownership_type != NFT_OWNERSHIP_INDIVISIBLE { + return Err("NFT/RWA collections must use indivisible ownership.".to_string()); + } + // Series NFTs reserve each numbered item name separately, while // standalone NFTs reserve the base name directly. - let tree = db.open_tree("nfts").unwrap(); + // NFT identities remain reserved after their circulating supply is + // completely burned. Use the permanent ownership registry here + // rather than the live NFT registry, which removes fully burned assets. + let tree = db.open_tree("nft_ownership").unwrap(); if series == 1 { for item_number in 1..=count { let nft_save_name = nft_asset_name(&self.unsigned_create_nft.nft_name, item_number); diff --git a/src/verifications/async_funcs/verify_lender.rs b/src/verifications/async_funcs/verify_lender.rs index bd0ad9b..daf9728 100644 --- a/src/verifications/async_funcs/verify_lender.rs +++ b/src/verifications/async_funcs/verify_lender.rs @@ -3,6 +3,7 @@ use crate::common::asset_names::{ is_canonical_loan_collateral_or_base, is_canonical_padded_asset_or_base, loan_collateral_asset_name_or_base, LOAN_COLLATERAL_LENGTH, }; +use crate::common::nft_assets::{nft_ownership_type, validate_nft_amount}; use crate::common::types::{COIN_LENGTH, LENDER_FEE}; use crate::encode; use crate::records::memory::mempool::BASECOIN; @@ -117,6 +118,13 @@ impl LoanContractTransaction { return Err("Coin/Token/NFT does not exist.".to_string()); } + // NFT/RWA collateral follows the immutable ownership rule selected + // at creation. Fractional standalone assets may pledge a fraction; + // indivisible assets and collection items must pledge one full unit. + if let Some(ownership_type) = nft_ownership_type(db, &collateral)? { + validate_nft_amount(ownership_type, collateral_amount, "collateral")?; + } + // The lender must be able to fund the loan and fee, while the // borrower must already own the pledged collateral. if !balance_checkup(db, loan_amount, txfee, loan_coin, lender).await { diff --git a/src/verifications/async_funcs/verify_proposal_key.rs b/src/verifications/async_funcs/verify_proposal_key.rs new file mode 100644 index 0000000..a6b57dc --- /dev/null +++ b/src/verifications/async_funcs/verify_proposal_key.rs @@ -0,0 +1,70 @@ +use crate::blocks::proposal_key::ProposalKey; +use crate::common::governance::is_valid_governance_hash; +use crate::common::types::{PROPOSAL_KEY_FEE, PROPOSAL_KEY_TYPE}; +use crate::encode; +use crate::records::memory::mempool::BASECOIN; +use crate::records::wallet_registry::{ + require_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::verify_db::db_hex_verification; +use crate::wallets::structures::Wallet; + +impl ProposalKey { + pub async fn verify(&self, db: &Db) -> Result { + let proposal = &self.unsigned_proposalkey; + + // Governance proposal keys use their reserved transaction type. + if proposal.txtype != PROPOSAL_KEY_TYPE { + return Err("Proposal key transaction type is invalid.".to_string()); + } + + // This hash commits the key to the exact bytes of the proposal document. + if !is_valid_governance_hash(&proposal.proposal_hash) { + return Err("Proposal document hash must decode to exactly 32 bytes.".to_string()); + } + + // Proposal creators must use a canonical registered wallet address. + if !Wallet::short_address_validation(&proposal.address) { + return Err("Proposal creator wallet address is invalid.".to_string()); + } + require_canonical_registered_short_address( + db, + &proposal.address, + "Proposal creator wallet address", + )?; + + // The registered public key proves who created this proposal key. + let public_key = resolve_pubkey_from_short_address(db, &proposal.address) + .map_err(|_| "Proposal creator wallet address is not registered.".to_string())? + .ok_or_else(|| "Proposal creator wallet address is not registered.".to_string())?; + let transaction_hash = proposal.hash().await; + if !Wallet::verify_transaction_with_public_key( + &transaction_hash, + &self.signature, + &encode(&public_key), + ) + .await + { + return Err("Invalid signature for the Proposal Key Transaction.".to_string()); + } + + if proposal.txfee < PROPOSAL_KEY_FEE { + return Err(format!( + "Proposal key transaction fee is below the minimum required fee of {PROPOSAL_KEY_FEE}." + )); + } + + if !balance_checkup(db, 0, proposal.txfee, BASECOIN.clone(), &proposal.address).await { + return Err("Insufficient funds for this Proposal Key Transaction!".to_string()); + } + + // A confirmed transaction ID cannot be accepted into the chain twice. + if !db_hex_verification(db, "txid", &transaction_hash).await { + return Err("This transaction already exists.".to_string()); + } + + Ok(String::new()) + } +} diff --git a/src/verifications/async_funcs/verify_proposal_vote.rs b/src/verifications/async_funcs/verify_proposal_vote.rs new file mode 100644 index 0000000..7657922 --- /dev/null +++ b/src/verifications/async_funcs/verify_proposal_vote.rs @@ -0,0 +1,125 @@ +use crate::blocks::proposal_vote::ProposalVote; +use crate::common::governance::{ + is_valid_governance_hash, proposal_vote_record_key, GovernanceVote, + FINISHED_PROPOSAL_VOTES_TREE, PROPOSAL_KEYS_TREE, PROPOSAL_VOTES_TREE, +}; +use crate::common::types::{PROPOSAL_VOTE_FEE, PROPOSAL_VOTE_TYPE}; +use crate::encode; +use crate::records::block_height::get_block_height::get_height; +use crate::records::memory::mempool::{proposal_vote_exists, BASECOIN}; +use crate::records::memory::network_mapping::NodeInfo; +use crate::records::wallet_registry::{ + require_canonical_registered_short_address, resolve_pubkey_from_short_address, +}; +use crate::sled::Db; +use crate::verifications::async_funcs::checks::balance_check::balance_checkup; +use crate::verifications::async_funcs::checks::verify_db::db_hex_verification; +use crate::wallets::structures::Wallet; + +impl ProposalVote { + pub async fn verify(&self, db: &Db) -> Result { + self.verify_at( + db, + crate::Utc::now().timestamp() as u32, + get_height(db).saturating_add(1), + ) + .await + } + + pub async fn verify_at( + &self, + db: &Db, + reference_timestamp: u32, + vote_height: u32, + ) -> Result { + let vote = &self.unsigned_proposalvote; + + if vote.txtype != PROPOSAL_VOTE_TYPE { + return Err("Proposal vote transaction type is invalid.".to_string()); + } + GovernanceVote::try_from(vote.vote)?; + if !is_valid_governance_hash(&vote.proposal_key) { + return Err("Proposal key must decode to exactly 32 bytes.".to_string()); + } + + if !Wallet::short_address_validation(&vote.address) { + return Err("Proposal voter wallet address is invalid.".to_string()); + } + require_canonical_registered_short_address( + db, + &vote.address, + "Proposal voter wallet address", + )?; + + let public_key = resolve_pubkey_from_short_address(db, &vote.address) + .map_err(|_| "Proposal voter wallet address is not registered.".to_string())? + .ok_or_else(|| "Proposal voter wallet address is not registered.".to_string())?; + let transaction_hash = vote.hash().await; + if !Wallet::verify_transaction_with_public_key( + &transaction_hash, + &self.signature, + &encode(&public_key), + ) + .await + { + return Err("Invalid signature for the Proposal Vote Transaction.".to_string()); + } + + let proposal_key = crate::decode(&vote.proposal_key) + .map_err(|err| format!("Could not decode proposal key: {err}"))?; + let proposals = db + .open_tree(PROPOSAL_KEYS_TREE) + .map_err(|err| format!("Could not open proposal keys: {err}"))?; + if !proposals + .contains_key(&proposal_key) + .map_err(|err| format!("Could not check proposal key: {err}"))? + { + return Err("Proposal key does not exist.".to_string()); + } + if db + .open_tree(FINISHED_PROPOSAL_VOTES_TREE) + .map_err(|err| format!("Could not open finished proposal votes: {err}"))? + .contains_key(&proposal_key) + .map_err(|err| format!("Could not check proposal finalization: {err}"))? + { + return Err("This proposal vote is already finalized.".to_string()); + } + + let vote_key = proposal_vote_record_key(&vote.proposal_key, &vote.address)?; + if db + .open_tree(PROPOSAL_VOTES_TREE) + .map_err(|err| format!("Could not open proposal votes: {err}"))? + .contains_key(vote_key) + .map_err(|err| format!("Could not check existing proposal vote: {err}"))? + || proposal_vote_exists(&vote.proposal_key, &vote.address) + .await + .map_err(|err| err.to_string())? + { + return Err("This node wallet has already voted on this proposal.".to_string()); + } + + let voter = NodeInfo::governance_node_snapshot(&vote.address) + .await + .ok_or_else(|| "Proposal votes may only be cast by network nodes.".to_string())?; + if !voter.can_cast_vote_at((reference_timestamp as u64) * 1_000, vote_height) { + return Err( + "Proposal voter must be active for 60 minutes and have mined 100 blocks." + .to_string(), + ); + } + + if vote.txfee < PROPOSAL_VOTE_FEE { + return Err(format!( + "Proposal vote transaction fee is below the minimum required fee of {PROPOSAL_VOTE_FEE}." + )); + } + if !balance_checkup(db, 0, vote.txfee, BASECOIN.clone(), &vote.address).await { + return Err("Insufficient funds for this Proposal Vote Transaction!".to_string()); + } + if !db_hex_verification(db, "txid", &transaction_hash).await { + return Err("This transaction already exists.".to_string()); + } + + Ok(String::new()) + } +} diff --git a/src/verifications/async_funcs/verify_swap.rs b/src/verifications/async_funcs/verify_swap.rs index baeb21b..69d2aed 100644 --- a/src/verifications/async_funcs/verify_swap.rs +++ b/src/verifications/async_funcs/verify_swap.rs @@ -1,7 +1,7 @@ use crate::blocks::swap::SwapTransaction; use crate::common::asset_names::is_canonical_padded_asset_or_base; use crate::common::network_paths_and_settings::block_extension_and_paths; -use crate::common::nft_assets::nft_asset_name; +use crate::common::nft_assets::{nft_asset_name, nft_ownership_type, validate_nft_amount}; use crate::common::types::{COIN_LENGTH, SWAP_FEE}; use crate::encode; use crate::records::wallet_registry::{ @@ -109,44 +109,39 @@ impl SwapTransaction { // mainnet and testnet validate against their own ticker. let network_base_coin = network_base_coin_padded.trim().to_lowercase(); - // Numbered NFT swaps are single-item transfers, while non-series - // assets must already exist as a token, NFT, or base coin. - let ticker1_is_nft = if self.unsigned_swap.nft_series1 > 0 { - if self.unsigned_swap.value1 != 1 { - return Err("Series NFTs must swap exactly 1 item.".to_string()); - } - if !db_bytes_verification(db, "nfts", &asset1).await { - return Err("Ticker1 NFT item does not exist.".to_string()); - } + // Resolve NFT/RWA ownership rules from the concrete asset record. + let ticker1_ownership = nft_ownership_type(db, &asset1)?; + let ticker1_is_nft = if let Some(ownership_type) = ticker1_ownership { + validate_nft_amount(ownership_type, self.unsigned_swap.value1, "swap")?; true } else { + if self.unsigned_swap.nft_series1 > 0 { + return Err("Ticker1 NFT/RWA collection item does not exist.".to_string()); + } let is_base_coin = self.unsigned_swap.ticker1.trim().to_lowercase() == network_base_coin; let is_token = db_bytes_verification(db, "tokens", &self.unsigned_swap.ticker1).await; - let is_nft = db_bytes_verification(db, "nfts", &self.unsigned_swap.ticker1).await; - if !(is_base_coin || is_token || is_nft) { + if !(is_base_coin || is_token) { return Err("Ticker1 does not exist.".to_string()); } - is_nft + false }; - let ticker2_is_nft = if self.unsigned_swap.nft_series2 > 0 { - if self.unsigned_swap.value2 != 1 { - return Err("Series NFTs must swap exactly 1 item.".to_string()); - } - if !db_bytes_verification(db, "nfts", &asset2).await { - return Err("Ticker2 NFT item does not exist.".to_string()); - } + let ticker2_ownership = nft_ownership_type(db, &asset2)?; + let ticker2_is_nft = if let Some(ownership_type) = ticker2_ownership { + validate_nft_amount(ownership_type, self.unsigned_swap.value2, "swap")?; true } else { + if self.unsigned_swap.nft_series2 > 0 { + return Err("Ticker2 NFT/RWA collection item does not exist.".to_string()); + } let is_base_coin = self.unsigned_swap.ticker2.trim().to_lowercase() == network_base_coin; let is_token = db_bytes_verification(db, "tokens", &self.unsigned_swap.ticker2).await; - let is_nft = db_bytes_verification(db, "nfts", &self.unsigned_swap.ticker2).await; - if !(is_base_coin || is_token || is_nft) { + if !(is_base_coin || is_token) { return Err("Ticker2 does not exist.".to_string()); } - is_nft + false }; // Each signer pays the fixed swap fee for their side of the trade. diff --git a/src/verifications/async_funcs/verify_transfer.rs b/src/verifications/async_funcs/verify_transfer.rs index 9381de0..c9321f8 100644 --- a/src/verifications/async_funcs/verify_transfer.rs +++ b/src/verifications/async_funcs/verify_transfer.rs @@ -1,6 +1,6 @@ use crate::blocks::transfer::TransferTransaction; use crate::common::asset_names::is_canonical_padded_asset_or_base; -use crate::common::nft_assets::nft_asset_name; +use crate::common::nft_assets::{nft_asset_name, nft_ownership_type, validate_nft_amount}; use crate::common::types::{minimum_transfer_fee, COIN_LENGTH}; use crate::encode; use crate::records::memory::mempool::BASECOIN; @@ -10,9 +10,7 @@ use crate::records::wallet_registry::{ }; use crate::sled::Db; use crate::verifications::async_funcs::checks::balance_check::balance_checkup; -use crate::verifications::async_funcs::checks::verify_db::{ - db_bytes_verification, db_hex_verification, -}; +use crate::verifications::async_funcs::checks::verify_db::db_hex_verification; use crate::wallets::structures::Wallet; fn is_zero_burn_address(address: &str) -> bool { @@ -102,16 +100,12 @@ impl TransferTransaction { )); } - // Series NFTs are handled as numbered single-item assets and must - // already exist in the NFT registry before they can be transferred. - if self.unsigned_transfer.nft_series > 0 { - if self.unsigned_transfer.value != 1 { - return Err("Series NFTs must transfer exactly 1 item.".to_string()); - } - - if !db_bytes_verification(db, "nfts", &transfer_asset).await { - return Err("This NFT item does not exist.".to_string()); - } + // NFT/RWA divisibility comes from the immutable asset registry, + // including standalone assets whose series number is zero. + if let Some(ownership_type) = nft_ownership_type(db, &transfer_asset)? { + validate_nft_amount(ownership_type, self.unsigned_transfer.value, "transfer")?; + } else if self.unsigned_transfer.nft_series > 0 { + return Err("This NFT/RWA collection item does not exist.".to_string()); } // Balance checks combine confirmed wallet state with pending