iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit e37d2a3371e1d96e9af5c8fb93eb4f3abe3e968a
parent 561e92d2ed62263e737608579740b3442870a8b6
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue,  4 Aug 2026 15:52:28 +0200

Persist owned blinded transactions

Diffstat:
Msrc/adapters/http.rs | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/adapters/wallet_store.rs | 228++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/app.rs | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/domain.rs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/main.rs | 41++++++++++++++++++++++++++++++-----------
5 files changed, 678 insertions(+), 45 deletions(-)

diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -3,7 +3,7 @@ use std::{ net::SocketAddr, path::{Path, PathBuf}, sync::Arc, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result, bail}; @@ -19,7 +19,7 @@ use axum::{ use getrandom::getrandom; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use tokio::{net::TcpListener, sync::Mutex}; +use tokio::{net::TcpListener, sync::Mutex, time::sleep}; use crate::{ adapters::{ @@ -34,8 +34,8 @@ use crate::{ }, domain::{ Amount, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, Ledger, - MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, Transaction, TxInput, TxOutput, hex_hash, - revealed_blinded_transactions, + MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, Transaction, TxInput, TxOutput, Wallet, + hex_hash, revealed_blinded_transactions, }, }; @@ -422,6 +422,7 @@ pub async fn serve( auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), }; + tokio::spawn(run_owned_blinded_outbox_persistence(state.clone())); let app = Router::new() .route("/", get(index)) .route("/favicon.ico", get(favicon)) @@ -523,6 +524,51 @@ async fn app_js() -> impl IntoResponse { ) } +async fn run_owned_blinded_outbox_persistence(state: HttpState) { + loop { + sleep(Duration::from_millis(500)).await; + let (version, entries) = { + let node = state.node.lock().await; + let version = node.owned_blinded_outbox_version(); + if version == 0 { + continue; + } + (version, node.owned_blinded_transactions()) + }; + let password = wallet_persistence_password(&state).await; + match wallet_store::replace_owned_blinded_transactions( + &state.wallet_path, + password.as_deref(), + entries, + ) { + Ok(()) => { + state + .node + .lock() + .await + .mark_owned_blinded_outbox_persisted(version); + } + Err(error) if format!("{error:#}").contains("wallet is encrypted") => {} + Err(error) => eprintln!("failed to persist owned blinded transactions: {error:#}"), + } + } +} + +async fn wallet_persistence_password(state: &HttpState) -> Option<String> { + let metadata = wallet_store::metadata(&state.wallet_path).ok().flatten()?; + if !metadata.encrypted { + return None; + } + let now = now_ms(); + state + .auth_sessions + .lock() + .await + .values() + .find(|session| session.expires_at > now) + .map(|session| session.wallet_password.clone()) +} + async fn require_auth_middleware( State(state): State<HttpState>, headers: HeaderMap, @@ -2369,7 +2415,7 @@ async fn setup_auth_password( drop(config); wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?; let wallet = wallet_store::load_with_password(&state.wallet_path, password)?; - state.node.lock().await.replace_wallet(wallet); + restore_node_wallet_from_store(state, wallet, Some(password)).await?; clear_auth_backoff(state, client_key).await; create_session_cookie(state, password).await } @@ -2393,7 +2439,7 @@ async fn login_auth_password( } wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?; let wallet = wallet_store::load_with_password(&state.wallet_path, password)?; - state.node.lock().await.replace_wallet(wallet); + restore_node_wallet_from_store(state, wallet, Some(password)).await?; clear_auth_backoff(state, client_key).await; create_session_cookie(state, password).await } @@ -2424,12 +2470,24 @@ async fn change_auth_password( config.auth_password_hash = Some(hash_password(new_password)?); config_store::save(&state.config_path, &config)?; } - state.node.lock().await.replace_wallet(wallet); + restore_node_wallet_from_store(state, wallet, Some(new_password)).await?; state.auth_sessions.lock().await.clear(); clear_auth_backoff(state, client_key).await; create_session_cookie(state, new_password).await } +async fn restore_node_wallet_from_store( + state: &HttpState, + wallet: Wallet, + password: Option<&str>, +) -> Result<()> { + let owned_blinded_transactions = + wallet_store::load_owned_blinded_transactions(&state.wallet_path, password)?; + let mut node = state.node.lock().await; + node.replace_wallet(wallet); + node.restore_owned_blinded_transactions(owned_blinded_transactions) +} + async fn check_auth_backoff(state: &HttpState, client_key: &str) -> Result<()> { let now = now_ms(); let mut backoffs = state.auth_backoff.lock().await; diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs @@ -14,7 +14,7 @@ use pbkdf2::pbkdf2_hmac; use serde::{Deserialize, Serialize}; use sha2::Sha256; -use crate::domain::Wallet; +use crate::domain::{OwnedBlindedTransaction, Wallet}; const WALLET_FILE_VERSION: u32 = 3; const PLAINTEXT_WALLET_FILE_VERSION: u32 = 2; @@ -30,10 +30,19 @@ struct WalletFile { #[serde(default, skip_serializing_if = "Option::is_none")] seed: Option<String>, address: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + owned_blinded_transactions: Vec<OwnedBlindedTransaction>, #[serde(default, skip_serializing_if = "Option::is_none")] encryption: Option<EncryptedWalletSeed>, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct WalletData { + seed: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + owned_blinded_transactions: Vec<OwnedBlindedTransaction>, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct WalletMetadata { pub address: String, @@ -135,6 +144,36 @@ pub fn load_with_password(path: &Path, password: &str) -> Result<Wallet> { load_encrypted_or_plaintext(path, Some(password)) } +pub fn load_owned_blinded_transactions( + path: &Path, + password: Option<&str>, +) -> Result<Vec<OwnedBlindedTransaction>> { + let stored = read_wallet_file(path)?; + Ok(wallet_data(&stored, password)?.owned_blinded_transactions) +} + +pub fn replace_owned_blinded_transactions( + path: &Path, + password: Option<&str>, + owned_blinded_transactions: Vec<OwnedBlindedTransaction>, +) -> Result<()> { + if !path.exists() { + return Ok(()); + } + let stored = read_wallet_file(path)?; + let mut data = wallet_data(&stored, password)?; + data.owned_blinded_transactions = owned_blinded_transactions; + let mut file = open_wallet_file(path, WalletFileMode::Replace)?; + if stored.encryption.is_some() { + let password = password + .context("wallet is encrypted; unlock it before persisting blinded transactions")?; + write_encrypted_wallet_data_file(&mut file, data, &stored.address, password) + } else { + write_wallet_data_file(&mut file, data, &stored.address) + } + .with_context(|| format!("failed to update wallet file {}", path.display())) +} + pub fn encrypt_existing_with_password(path: &Path, password: &str) -> Result<()> { if !path.exists() { return Ok(()); @@ -144,7 +183,8 @@ pub fn encrypt_existing_with_password(path: &Path, password: &str) -> Result<()> let _ = wallet_from_stored(&stored, Some(password))?; return Ok(()); } - let seed = stored.seed.context("wallet file does not contain a seed")?; + let data = wallet_data(&stored, None)?; + let seed = data.seed; let seed = normalize_seed_phrase(&seed).unwrap_or(seed); let wallet = Wallet::from_seed(&seed); if wallet.address() != stored.address { @@ -155,8 +195,16 @@ pub fn encrypt_existing_with_password(path: &Path, password: &str) -> Result<()> ); } let mut file = open_wallet_file(path, WalletFileMode::Replace)?; - write_encrypted_wallet_file(&mut file, seed, wallet.address(), password) - .with_context(|| format!("failed to encrypt wallet file {}", path.display())) + write_encrypted_wallet_data_file( + &mut file, + WalletData { + seed, + owned_blinded_transactions: data.owned_blinded_transactions, + }, + wallet.address(), + password, + ) + .with_context(|| format!("failed to encrypt wallet file {}", path.display())) } pub fn reencrypt_with_password( @@ -165,7 +213,8 @@ pub fn reencrypt_with_password( new_password: &str, ) -> Result<Wallet> { let stored = read_wallet_file(path)?; - let seed = wallet_seed(&stored, Some(current_password))?; + let data = wallet_data(&stored, Some(current_password))?; + let seed = data.seed; let seed = normalize_seed_phrase(&seed).unwrap_or(seed); let wallet = Wallet::from_seed(&seed); if wallet.address() != stored.address { @@ -176,8 +225,16 @@ pub fn reencrypt_with_password( ); } let mut file = open_wallet_file(path, WalletFileMode::Replace)?; - write_encrypted_wallet_file(&mut file, seed, wallet.address(), new_password) - .with_context(|| format!("failed to re-encrypt wallet file {}", path.display()))?; + write_encrypted_wallet_data_file( + &mut file, + WalletData { + seed, + owned_blinded_transactions: data.owned_blinded_transactions, + }, + wallet.address(), + new_password, + ) + .with_context(|| format!("failed to re-encrypt wallet file {}", path.display()))?; Ok(wallet) } @@ -270,10 +327,22 @@ fn write_wallet_encrypted( } fn write_wallet_file(file: &mut File, seed: String, address: &str) -> Result<()> { + write_wallet_data_file( + file, + WalletData { + seed, + owned_blinded_transactions: Vec::new(), + }, + address, + ) +} + +fn write_wallet_data_file(file: &mut File, data: WalletData, address: &str) -> Result<()> { let stored = WalletFile { version: PLAINTEXT_WALLET_FILE_VERSION, - seed: Some(seed), + seed: Some(data.seed), address: address.to_string(), + owned_blinded_transactions: data.owned_blinded_transactions, encryption: None, }; let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?; @@ -288,11 +357,29 @@ fn write_encrypted_wallet_file( address: &str, password: &str, ) -> Result<()> { - let encryption = encrypt_seed(&seed, address, password)?; + write_encrypted_wallet_data_file( + file, + WalletData { + seed, + owned_blinded_transactions: Vec::new(), + }, + address, + password, + ) +} + +fn write_encrypted_wallet_data_file( + file: &mut File, + data: WalletData, + address: &str, + password: &str, +) -> Result<()> { + let encryption = encrypt_wallet_data(&data, address, password)?; let stored = WalletFile { version: WALLET_FILE_VERSION, seed: None, address: address.to_string(), + owned_blinded_transactions: Vec::new(), encryption: Some(encryption), }; let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?; @@ -301,16 +388,37 @@ fn write_encrypted_wallet_file( Ok(()) } -fn encrypt_seed(seed: &str, address: &str, password: &str) -> Result<EncryptedWalletSeed> { +fn wallet_data(stored: &WalletFile, password: Option<&str>) -> Result<WalletData> { + if let Some(encryption) = &stored.encryption { + let password = password.context("wallet is encrypted; unlock it with the UI password")?; + return decrypt_wallet_data(encryption, &stored.address, password); + } + let seed = stored + .seed + .clone() + .context("wallet file does not contain a seed")?; + Ok(WalletData { + seed, + owned_blinded_transactions: stored.owned_blinded_transactions.clone(), + }) +} + +fn encrypt_wallet_data( + data: &WalletData, + address: &str, + password: &str, +) -> Result<EncryptedWalletSeed> { let salt = random_bytes::<16>()?; let nonce = random_bytes::<12>()?; let key = wallet_encryption_key(password, &salt, WALLET_ENCRYPTION_ITERATIONS); let cipher = ChaCha20Poly1305::new((&key).into()); + let plaintext = + serde_json::to_vec(data).context("failed to serialize encrypted wallet data")?; let ciphertext = cipher .encrypt( Nonce::from_slice(&nonce), Payload { - msg: seed.as_bytes(), + msg: &plaintext, aad: address.as_bytes(), }, ) @@ -326,6 +434,14 @@ fn encrypt_seed(seed: &str, address: &str, password: &str) -> Result<EncryptedWa } fn decrypt_seed(encryption: &EncryptedWalletSeed, address: &str, password: &str) -> Result<String> { + Ok(decrypt_wallet_data(encryption, address, password)?.seed) +} + +fn decrypt_wallet_data( + encryption: &EncryptedWalletSeed, + address: &str, + password: &str, +) -> Result<WalletData> { if encryption.algorithm != WALLET_ENCRYPTION_ALGORITHM { bail!("unsupported wallet encryption algorithm"); } @@ -349,7 +465,13 @@ fn decrypt_seed(encryption: &EncryptedWalletSeed, address: &str, password: &str) }, ) .map_err(|_| anyhow!("invalid wallet password"))?; - String::from_utf8(plaintext).context("wallet seed is not valid utf-8") + match serde_json::from_slice::<WalletData>(&plaintext) { + Ok(data) => Ok(data), + Err(_) => Ok(WalletData { + seed: String::from_utf8(plaintext).context("wallet seed is not valid utf-8")?, + owned_blinded_transactions: Vec::new(), + }), + } } fn wallet_encryption_key(password: &str, salt: &[u8], iterations: u32) -> [u8; 32] { @@ -465,8 +587,13 @@ mod tests { use bip39::{Language, Mnemonic}; use tempfile::tempdir; + use crate::domain::{ + BlindedReveal, BlindedTransaction, OwnedBlindedTransaction, Transaction, TxInput, TxOutput, + }; + use super::{ - encrypt_existing_with_password, load_or_create, load_with_password, metadata, + encrypt_existing_with_password, load_or_create, load_owned_blinded_transactions, + load_with_password, metadata, replace_owned_blinded_transactions, replace_with_generated_seed_phrase, replace_with_generated_seed_phrase_encrypted, replace_with_imported_seed_phrase, setup_seed_phrase, setup_seed_phrase_with_password, }; @@ -592,6 +719,46 @@ mod tests { } #[test] + fn plaintext_wallet_persists_owned_blinded_transactions() { + let dir = tempdir().unwrap(); + let path = dir.path().join("wallet.json"); + replace_with_generated_seed_phrase(&path).unwrap(); + let owned = sample_owned_blinded_transaction(); + + replace_owned_blinded_transactions(&path, None, vec![owned.clone()]).unwrap(); + + assert_eq!( + load_owned_blinded_transactions(&path, None).unwrap(), + vec![owned] + ); + } + + #[test] + fn encrypted_wallet_persists_owned_blinded_transactions_without_plaintext() { + let dir = tempdir().unwrap(); + let path = dir.path().join("wallet.json"); + replace_with_generated_seed_phrase_encrypted(&path, "correct horse battery staple") + .unwrap(); + let owned = sample_owned_blinded_transaction(); + + replace_owned_blinded_transactions( + &path, + Some("correct horse battery staple"), + vec![owned.clone()], + ) + .unwrap(); + + let stored = fs::read_to_string(&path).unwrap(); + assert!(!stored.contains(&owned.payload.signature().to_string())); + assert!(!stored.contains(&owned.reveal.key)); + assert_eq!( + load_owned_blinded_transactions(&path, Some("correct horse battery staple")).unwrap(), + vec![owned] + ); + assert!(load_owned_blinded_transactions(&path, Some("wrong password")).is_err()); + } + + #[test] fn imports_normalized_seed_phrase() { let dir = tempdir().unwrap(); let path = dir.path().join("wallet.json"); @@ -647,6 +814,41 @@ mod tests { } } + fn sample_owned_blinded_transaction() -> OwnedBlindedTransaction { + let payload = Transaction::Transfer { + inputs: vec![TxInput { + outpoint: crate::domain::OutPoint { + txid: "a".repeat(64), + index: 0, + }, + owner: "mv_sample_owner".to_string(), + signature: "b".repeat(64), + }], + outputs: vec![TxOutput { + address: "mv_sample_recipient".to_string(), + amount: 1, + }], + fee: 1, + signature: "c".repeat(64), + }; + OwnedBlindedTransaction { + transaction: BlindedTransaction { + commitment: "d".repeat(64), + fee: 1, + encrypted_size: 42, + expires_at_height: 10, + nonce: "e".repeat(24), + ciphertext: "f".repeat(84), + payload_hash: "1".repeat(64), + }, + payload, + reveal: BlindedReveal { + commitment: "d".repeat(64), + key: "2".repeat(64), + }, + } + } + #[test] fn migrates_v1_wallet_file_to_current_address() { let dir = tempdir().unwrap(); diff --git a/src/app.rs b/src/app.rs @@ -15,8 +15,8 @@ use tokio::sync::Mutex; use crate::domain::{ Amount, BlindedReveal, BlindedTransaction, Block, BuiltBlindedTransaction, BurnLeaderRank, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, Ledger, - MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MINE_FINALIZER_FEE, OutPoint, PreparedBlock, - StratumMineShare, StratumMineTemplate, Transaction, TransactionSubmitOutcome, + MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MINE_FINALIZER_FEE, OutPoint, OwnedBlindedTransaction, + PreparedBlock, StratumMineShare, StratumMineTemplate, Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, }; @@ -245,8 +245,10 @@ pub struct NodeCore { last_auto_pow_mine_anchor: Option<String>, last_auto_pow_mine_status: Option<String>, auto_pow_mine_cursor: Option<AutoPowMineCursor>, + owned_blinded_transactions: BTreeMap<String, BlindedTransaction>, owned_blinded_reveals: BTreeMap<String, BlindedReveal>, owned_blinded_payloads: BTreeMap<String, Transaction>, + owned_blinded_outbox_version: u64, local_block_anchor_burn: Option<(u64, Transaction)>, outbox: Vec<GossipEnvelope>, } @@ -333,8 +335,10 @@ impl NodeCore { last_auto_pow_mine_anchor: None, last_auto_pow_mine_status: None, auto_pow_mine_cursor: None, + owned_blinded_transactions: BTreeMap::new(), owned_blinded_reveals: BTreeMap::new(), owned_blinded_payloads: BTreeMap::new(), + owned_blinded_outbox_version: 0, local_block_anchor_burn: None, outbox: Vec::new(), } @@ -354,8 +358,10 @@ impl NodeCore { self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; self.auto_pow_mine_cursor = None; + self.owned_blinded_transactions.clear(); self.owned_blinded_reveals.clear(); self.owned_blinded_payloads.clear(); + self.bump_owned_blinded_outbox_version(); self.local_block_anchor_burn = None; } @@ -411,6 +417,73 @@ impl NodeCore { self.owned_blinded_payloads.values().cloned().collect() } + pub fn owned_blinded_outbox_version(&self) -> u64 { + self.owned_blinded_outbox_version + } + + pub fn owned_blinded_transactions(&self) -> Vec<OwnedBlindedTransaction> { + self.owned_blinded_transactions + .iter() + .filter_map(|(commitment, transaction)| { + let payload = self.owned_blinded_payloads.get(commitment)?; + let reveal = self.owned_blinded_reveals.get(commitment)?; + Some(OwnedBlindedTransaction { + transaction: transaction.clone(), + payload: payload.clone(), + reveal: reveal.clone(), + }) + }) + .collect() + } + + pub fn mark_owned_blinded_outbox_persisted(&mut self, version: u64) { + if self.owned_blinded_outbox_version == version { + self.owned_blinded_outbox_version = 0; + } + } + + pub fn restore_owned_blinded_transactions( + &mut self, + transactions: Vec<OwnedBlindedTransaction>, + ) -> Result<()> { + self.owned_blinded_transactions.clear(); + self.owned_blinded_reveals.clear(); + self.owned_blinded_payloads.clear(); + for owned in transactions { + let commitment = owned.transaction.commitment.clone(); + if owned.reveal.commitment != commitment { + continue; + } + if self.ledger.has_blinded_reveal(&commitment) + || !self.ledger.has_unrevealed_blinded_transaction(&commitment) + && owned.transaction.expires_at_height <= self.ledger.height().saturating_add(1) + { + continue; + } + if !self.ledger.has_blinded_transaction(&commitment) + && self + .ledger + .submit_blinded_transaction(owned.transaction.clone())? + { + self.outbox.push(GossipEnvelope::BlindedTransaction( + owned.transaction.clone(), + )); + } + if !self.ledger.has_unrevealed_blinded_transaction(&commitment) { + continue; + } + self.owned_blinded_transactions + .insert(commitment.clone(), owned.transaction); + self.owned_blinded_payloads + .insert(commitment.clone(), owned.payload); + self.owned_blinded_reveals + .insert(commitment.clone(), owned.reveal); + } + self.publish_owned_reveals_for_active_commits()?; + self.bump_owned_blinded_outbox_version(); + Ok(()) + } + pub fn mempool_gossip(&self) -> Vec<GossipEnvelope> { let mut gossip = Vec::new(); gossip.extend( @@ -771,10 +844,13 @@ impl NodeCore { built: BuiltBlindedTransaction, ) -> Result<BlindedTransaction> { let transaction = built.transaction; + self.owned_blinded_transactions + .insert(transaction.commitment.clone(), transaction.clone()); self.owned_blinded_payloads .insert(transaction.commitment.clone(), built.payload); self.owned_blinded_reveals .insert(transaction.commitment.clone(), built.reveal); + self.bump_owned_blinded_outbox_version(); if self .ledger .submit_blinded_transaction(transaction.clone())? @@ -802,10 +878,26 @@ impl NodeCore { fn publish_owned_reveals_for_block(&mut self, block: &Block) -> Result<()> { for transaction in &block.blinded_transactions { - let Some(reveal) = self.owned_blinded_reveals.remove(&transaction.commitment) else { + let Some(reveal) = self.owned_blinded_reveals.get(&transaction.commitment) else { continue; }; if self.ledger.submit_blinded_reveal(reveal.clone())? { + self.outbox + .push(GossipEnvelope::BlindedReveal(reveal.clone())); + } + } + Ok(()) + } + + fn publish_owned_reveals_for_active_commits(&mut self) -> Result<()> { + let reveals = self + .owned_blinded_reveals + .iter() + .filter(|(commitment, _)| self.ledger.has_active_blinded_transaction(commitment)) + .map(|(_, reveal)| reveal.clone()) + .collect::<Vec<_>>(); + for reveal in reveals { + if self.ledger.submit_blinded_reveal(reveal.clone())? { self.outbox.push(GossipEnvelope::BlindedReveal(reveal)); } } @@ -813,11 +905,36 @@ impl NodeCore { } fn prune_owned_blinded_payloads_for_block(&mut self, block: &Block) { + let before = self.owned_blinded_transactions.len() + + self.owned_blinded_reveals.len() + + self.owned_blinded_payloads.len(); for reveal in &block.blinded_reveals { + self.owned_blinded_transactions.remove(&reveal.commitment); + self.owned_blinded_reveals.remove(&reveal.commitment); self.owned_blinded_payloads.remove(&reveal.commitment); } + let unrevealed = self + .owned_blinded_transactions + .keys() + .filter(|commitment| self.ledger.has_unrevealed_blinded_transaction(commitment)) + .cloned() + .collect::<Vec<_>>(); + self.owned_blinded_transactions + .retain(|commitment, _| unrevealed.contains(commitment)); + self.owned_blinded_reveals + .retain(|commitment, _| unrevealed.contains(commitment)); self.owned_blinded_payloads - .retain(|commitment, _| self.ledger.has_unrevealed_blinded_transaction(commitment)); + .retain(|commitment, _| unrevealed.contains(commitment)); + let after = self.owned_blinded_transactions.len() + + self.owned_blinded_reveals.len() + + self.owned_blinded_payloads.len(); + if before != after { + self.bump_owned_blinded_outbox_version(); + } + } + + fn bump_owned_blinded_outbox_version(&mut self) { + self.owned_blinded_outbox_version = self.owned_blinded_outbox_version.saturating_add(1); } fn build_burn_with_fee_rate( @@ -2210,6 +2327,111 @@ mod tests { } #[test] + fn owned_blinded_transaction_restore_requeues_pending_commit() { + let alice = Wallet::from_seed("owned-blinded-restore-pending-alice"); + let bob = Wallet::from_seed("owned-blinded-restore-pending-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new(allocations, 1); + let mut node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0); + + let blinded = node + .blinded_transfer_with_fee(bob.address(), MICRO_IUNA, 7, node.chain_height() + 4) + .unwrap(); + let owned = node.owned_blinded_transactions(); + let mut restarted = NodeCore::from_ledger(alice, ledger, 0); + + restarted.restore_owned_blinded_transactions(owned).unwrap(); + + assert_eq!( + restarted.ledger().pending_blinded_transactions(), + std::slice::from_ref(&blinded) + ); + let outbox = restarted.drain_outbox(); + assert!(outbox.iter().any(|envelope| matches!( + envelope, + GossipEnvelope::BlindedTransaction(transaction) + if transaction.commitment == blinded.commitment + ))); + assert!(!outbox.iter().any(|envelope| matches!( + envelope, + GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment + ))); + } + + #[test] + fn owned_blinded_transaction_restore_publishes_reveal_after_commit() { + let alice = Wallet::from_seed("owned-blinded-restore-reveal-alice"); + let bob = Wallet::from_seed("owned-blinded-restore-reveal-bob"); + let carol = Wallet::from_seed("owned-blinded-restore-reveal-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let mut wallet_node = NodeCore::from_ledger(carol.clone(), ledger.clone(), 0); + let mut finalizer_ledger = ledger; + + let blinded = wallet_node + .blinded_burn_with_fee(3, 7, wallet_node.chain_height() + 4) + .unwrap(); + let owned = wallet_node.owned_blinded_transactions(); + finalizer_ledger + .submit_blinded_transaction(blinded.clone()) + .unwrap(); + let leader = finalizer_ledger.expected_leader_for_next_block().unwrap(); + let finalizer = finalizers + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap(); + let burn = finalizer_ledger.build_burn(finalizer, 1, 0).unwrap(); + finalizer_ledger.submit_transaction(burn).unwrap(); + let commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap(); + finalizer_ledger.apply_block(commit_block.clone()).unwrap(); + let mut restarted_ledger = NodeCore::from_ledger( + carol, + Ledger::from_snapshot(finalizer_ledger.snapshot()).unwrap(), + 0, + ); + + restarted_ledger + .restore_owned_blinded_transactions(owned) + .unwrap(); + + assert!( + restarted_ledger + .ledger() + .pending_blinded_reveals() + .iter() + .any(|reveal| reveal.commitment == blinded.commitment) + ); + assert!( + restarted_ledger + .drain_outbox() + .iter() + .any(|envelope| matches!( + envelope, + GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment + )) + ); + assert!( + commit_block + .blinded_transactions + .iter() + .any(|transaction| transaction.commitment == blinded.commitment) + ); + } + + #[test] fn automatic_non_leader_burn_is_queued_as_blinded() { let alice = Wallet::from_seed("auto-blinded-burn-alice"); let bob = Wallet::from_seed("auto-blinded-burn-bob"); diff --git a/src/domain.rs b/src/domain.rs @@ -172,6 +172,14 @@ pub struct BuiltBlindedTransaction { pub reveal: BlindedReveal, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OwnedBlindedTransaction { + pub transaction: BlindedTransaction, + pub payload: Transaction, + pub reveal: BlindedReveal, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct RevealedBlindedTransaction { pub height: u64, @@ -1869,6 +1877,10 @@ impl Ledger { || self.active_blinded.contains_key(commitment) } + pub fn has_active_blinded_transaction(&self, commitment: &str) -> bool { + self.active_blinded.contains_key(commitment) + } + pub fn has_blinded_reveal(&self, commitment: &str) -> bool { self.pending_reveals .iter() @@ -2763,7 +2775,7 @@ impl Ledger { blinded_reveals: selected_reveals.clone(), }; candidate.transactions.push(tx.clone()); - if estimated_block_selection_size_bytes(&candidate)? + if estimated_block_selection_size_bytes(&candidate, required_burn_owner.is_some())? <= self.launch_profile.max_block_bytes { apply_transaction(&tx, &mut utxos)?; @@ -2798,8 +2810,10 @@ impl Ledger { blinded_reveals: selected_reveals.clone(), }; candidate.transactions.push(tx.clone()); - if estimated_block_selection_size_bytes(&candidate)? - <= self.launch_profile.max_block_bytes + if estimated_block_selection_size_bytes( + &candidate, + required_burn_owner.is_some(), + )? <= self.launch_profile.max_block_bytes { apply_transaction(&tx, &mut utxos)?; selected.push(tx); @@ -2813,8 +2827,10 @@ impl Ledger { blinded_reveals: selected_reveals.clone(), }; candidate.blinded_transactions.push(transaction.clone()); - if estimated_block_selection_size_bytes(&candidate)? - <= self.launch_profile.max_block_bytes + if estimated_block_selection_size_bytes( + &candidate, + required_burn_owner.is_some(), + )? <= self.launch_profile.max_block_bytes { selected_blinded.push(transaction); } @@ -2827,8 +2843,10 @@ impl Ledger { blinded_reveals: selected_reveals.clone(), }; candidate.blinded_reveals.push(reveal.clone()); - if estimated_block_selection_size_bytes(&candidate)? - <= self.launch_profile.max_block_bytes + if estimated_block_selection_size_bytes( + &candidate, + required_burn_owner.is_some(), + )? <= self.launch_profile.max_block_bytes { selected_reveals.push(reveal); } @@ -3801,18 +3819,25 @@ fn compact_len(mut value: u128) -> usize { bytes } -fn estimated_block_selection_size_bytes(selection: &BlockSelection) -> Result<usize> { +fn estimated_block_selection_size_bytes( + selection: &BlockSelection, + recovery: bool, +) -> Result<usize> { let block = Block { height: u64::MAX, prev_hash: "f".repeat(64), timestamp_ms: u64::MAX, miner: "f".repeat(64), - finalizer_mode: FinalizerMode::Ticket, + finalizer_mode: if recovery { + FinalizerMode::Recovery + } else { + FinalizerMode::Ticket + }, finalizer_rank: 0, reward: u64::MAX, vdf_rounds: u64::MAX, vdf_output: "f".repeat(64), - leader_proof: Some(LeaderProof { + leader_proof: (!recovery).then(|| LeaderProof { ticket_id: "f".repeat(64), public_key: "f".repeat(64), signature: "f".repeat(128), @@ -5875,6 +5900,113 @@ mod tests { } #[test] + fn recovery_block_includes_pending_blinded_transactions_when_space_allows() { + let alice = Wallet::from_seed("recovery-blinded-commit-alice"); + let bob = Wallet::from_seed("recovery-blinded-commit-bob"); + let carol = Wallet::from_seed("recovery-blinded-commit-carol"); + let mut ledger = ledger_with_finalizers( + &[alice], + &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)], + ); + let blinded = ledger + .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(recovery_burn).unwrap(); + + let block = ledger + .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) + .unwrap(); + + assert!( + block + .blinded_transactions + .iter() + .any(|transaction| transaction.commitment == blinded.transaction.commitment) + ); + } + + #[test] + fn recovery_block_size_selection_uses_recovery_skeleton() { + let alice = Wallet::from_seed("recovery-size-commit-alice"); + let bob = Wallet::from_seed("recovery-size-commit-bob"); + let carol = Wallet::from_seed("recovery-size-commit-carol"); + let mut ledger = ledger_with_finalizers( + &[alice], + &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)], + ); + let blinded = ledger + .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(recovery_burn.clone()).unwrap(); + let recovery_selection = BlockSelection { + transactions: vec![recovery_burn], + blinded_transactions: vec![blinded.transaction.clone()], + blinded_reveals: Vec::new(), + }; + let recovery_estimate = + estimated_block_selection_size_bytes(&recovery_selection, true).unwrap(); + let ticket_estimate = + estimated_block_selection_size_bytes(&recovery_selection, false).unwrap(); + assert!(recovery_estimate < ticket_estimate); + + ledger.launch_profile.max_block_bytes = recovery_estimate; + let tight_block = ledger + .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) + .unwrap(); + + assert!( + tight_block + .blinded_transactions + .iter() + .any(|transaction| transaction.commitment == blinded.transaction.commitment) + ); + } + + #[test] + fn recovery_block_includes_pending_blinded_reveals_when_space_allows() { + let alice = Wallet::from_seed("recovery-blinded-reveal-alice"); + let bob = Wallet::from_seed("recovery-blinded-reveal-bob"); + let carol = Wallet::from_seed("recovery-blinded-reveal-carol"); + let finalizers = [alice.clone()]; + let mut ledger = ledger_with_finalizers( + &finalizers, + &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)], + ); + let blinded = ledger + .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + ledger + .submit_blinded_reveal(blinded.reveal.clone()) + .unwrap(); + let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(recovery_burn).unwrap(); + + let block = ledger + .mine_recovery_block(&bob, ledger.recovery_block_min_timestamp()) + .unwrap(); + + assert!( + block + .blinded_reveals + .iter() + .any(|reveal| reveal.commitment == blinded.transaction.commitment) + ); + } + + #[test] fn blinded_transaction_expiring_at_next_height_is_not_selected() { let alice = Wallet::from_seed("blinded-next-expire-finalizer-alice"); let bob = Wallet::from_seed("blinded-next-expire-finalizer-bob"); diff --git a/src/main.rs b/src/main.rs @@ -72,13 +72,20 @@ async fn main() -> Result<()> { let initial_burn_fee = initial_burn_fee(&opts, &ui_config); let mut node_core = match wallet_load { - StartupWallet::Unlocked(wallet) => NodeCore::from_ledger_with_burn_fee_and_enabled( + StartupWallet::Unlocked { wallet, - ledger, - ui_config.mining_enabled, - initial_burn_per_block, - initial_burn_fee, - ), + owned_blinded_transactions, + } => { + let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( + wallet, + ledger, + ui_config.mining_enabled, + initial_burn_per_block, + initial_burn_fee, + ); + node.restore_owned_blinded_transactions(owned_blinded_transactions)?; + node + } StartupWallet::Locked { address } => NodeCore::from_locked_wallet_address( address, ledger, @@ -195,14 +202,19 @@ async fn main() -> Result<()> { } enum StartupWallet { - Unlocked(iuna::domain::Wallet), - Locked { address: String }, + Unlocked { + wallet: iuna::domain::Wallet, + owned_blinded_transactions: Vec<iuna::domain::OwnedBlindedTransaction>, + }, + Locked { + address: String, + }, } impl StartupWallet { fn address(&self) -> &str { match self { - Self::Unlocked(wallet) => wallet.address(), + Self::Unlocked { wallet, .. } => wallet.address(), Self::Locked { address } => address, } } @@ -210,7 +222,14 @@ impl StartupWallet { fn load_startup_wallet(wallet_path: &Path) -> Result<StartupWallet> { match wallet_store::load_or_create(wallet_path) { - Ok(wallet) => Ok(StartupWallet::Unlocked(wallet)), + Ok(wallet) => { + let owned_blinded_transactions = + wallet_store::load_owned_blinded_transactions(wallet_path, None)?; + Ok(StartupWallet::Unlocked { + wallet, + owned_blinded_transactions, + }) + } Err(error) => { let Some(metadata) = wallet_store::metadata(wallet_path)? else { return Err(error); @@ -866,7 +885,7 @@ mod tests { match startup { StartupWallet::Locked { address } => assert_eq!(address, wallet.address()), - StartupWallet::Unlocked(_) => panic!("encrypted wallet should start locked"), + StartupWallet::Unlocked { .. } => panic!("encrypted wallet should start locked"), } }