commit e37d2a3371e1d96e9af5c8fb93eb4f3abe3e968a
parent 561e92d2ed62263e737608579740b3442870a8b6
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Tue, 4 Aug 2026 15:52:28 +0200
Persist owned blinded transactions
Diffstat:
| M | src/adapters/http.rs | | | 72 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- |
| M | src/adapters/wallet_store.rs | | | 228 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- |
| M | src/app.rs | | | 230 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
| M | src/domain.rs | | | 152 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ |
| M | src/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"),
}
}