commit 7ee830187b0ea85ff82df4967a3f159d15218c64
parent c9031f56d415327e380d8eca0b77be590e801b51
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Mon, 3 Aug 2026 22:19:45 +0200
Add blinded transaction commit reveal
Diffstat:
9 files changed, 1579 insertions(+), 91 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "iuna"
-version = "0.2.10"
+version = "0.2.11"
dependencies = [
"anyhow",
"axum",
diff --git a/Cargo.toml b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "iuna"
-version = "0.2.10"
+version = "0.2.11"
edition = "2024"
license = "Apache-2.0"
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -17,7 +17,8 @@ iuna uses a UTXO-style ledger. The main transaction types are:
1. **Transfer:** moves IUNA from one address to another and pays a sender-chosen fee.
2. **Burn:** destroys an amount of IUNA, pays a sender-chosen fee, and creates a future lottery ticket.
3. **Mine action:** proves SHA-256-style PoW against the current chain tip. A valid mine action mints a fixed `1 IUNA` reward to its recipient and pays a fixed `1 IUNA` fee to the block finalizer.
-4. **Burn claim:** proves that recent finalizers saw a burn that has not been included yet.
+4. **Blinded transaction envelope:** commits encrypted transaction content to a block before the finalizer can inspect whether it is a burn or transfer.
+5. **Blinded reveal:** publishes the decryption key for a previously committed envelope so nodes can validate and execute the hidden transaction.
Burn and transfer fees are chosen by the sender. Mine action reward and mine action fee are deterministic protocol values.
@@ -43,7 +44,7 @@ For each block height, eligible tickets are ranked:
The selected finalizer must prove ownership of the selected ticket, respect its rank time slot, and run the required VDF work. A block is valid only if the finalizer matches its ranked ticket, carries the correct leader proof, has a valid timestamp for its rank, includes a valid VDF output, and follows the transaction selection rules.
-Every normal block must include at least one burn transaction. Transaction fees go to the block finalizer.
+Every normal block must include at least one plaintext burn, blinded transaction envelope, or blinded reveal. Plaintext transaction fees go to the block finalizer immediately; blinded transaction fees are paid to the finalizer that committed the envelope when the payload is revealed and executed.
## VDF Timing
@@ -112,17 +113,32 @@ This keeps issuance separate from finalization. PoW miners compete to create min
The central censorship risk is simple: what if a finalizer only includes its own burns and ignores everyone else's burns?
-The current devnet protocol does not yet have a consensus-level burn inclusion fairness mechanism. Nodes gossip burn transactions through the normal mempool, blocks must include at least one burn, and finalizers earn the fees of the transactions they include. That gives finalizers a direct economic reason to include third-party burns, but it does not make censorship impossible.
+iuna now supports blinded transaction content for this path. A wallet can encrypt a normal transaction payload and gossip a `BlindedTransaction` envelope instead of the plaintext transaction. The envelope exposes only:
-This is an active protocol-design area. A production-grade solution likely needs stronger mempool or transaction ordering rules, for example a blinded mempool or commit-reveal style mechanism where finalizers cannot cheaply distinguish burns from other fee-paying transactions before committing to inclusion.
+- a commitment hash;
+- the declared fee;
+- encrypted payload size;
+- expiry height;
+- nonce, ciphertext, and plaintext payload hash.
+
+The finalizer can rank the envelope by fee per encrypted byte, but cannot see whether the encrypted payload is a transfer or a burn before committing it to a block.
+
+Reveal is a later step. A `BlindedReveal` carries only the commitment and decryption key. When a valid reveal is included, nodes decrypt the earlier payload, check the commitment and payload hash, decode the normal transaction, validate it against the current UTXO set, and execute it. If the decrypted transaction is a burn, it creates burn tickets at the reveal height, just like a plaintext burn would.
+
+Fees are paid without inflating the reveal block reward. The decrypted transaction must pay the same fee declared by the blinded envelope. When it executes, that fee is credited to the finalizer that originally included the blinded envelope, using a deterministic fee output tied to the commitment.
+
+Expiry is exclusive: a blinded envelope with expiry height `H` can be included only in blocks below height `H`, and revealed only while the current chain height is below `H`. Expired envelopes and reveals are dropped from local selection.
+
+This does not make censorship impossible. A finalizer can still ignore all blinded traffic, or censor based on network metadata. But it removes the cheap strategy of inspecting plaintext mempool transactions and excluding third-party burns while including other fee-paying transactions.
## Block Selection
When a node builds a block, it selects transactions in this order:
-1. Ensure the block has at least one burn.
-2. For recovery blocks, ensure at least one burn is from the recovery finalizer.
-3. Fill remaining space with valid transactions ordered by fee rate.
+1. Include valid blinded reveals first, so already committed encrypted payloads can execute.
+2. Ensure the block has at least one plaintext burn, blinded transaction, or blinded reveal.
+3. For recovery blocks, ensure at least one plaintext burn is from the recovery finalizer.
+4. Fill remaining space with valid plaintext transactions and blinded transactions ordered by fee rate.
Blocks are bounded by transaction count and serialized byte size. The current devnet maximum block size is `100,000` bytes.
@@ -140,6 +156,6 @@ iuna is trying to make these things true at the same time:
- New issuance should not require already owning a large stake.
- Burns should have real opportunity cost.
- Block timing should be hard to rush.
-- Finalizers should have a consensus-level reason to include burns they did not create.
+- Finalizers should have a consensus-level reason to include burn traffic they cannot inspect before committing.
The design is intentionally small and still evolving. The devnet exists to find out where these assumptions hold and where they break.
diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs
@@ -9,7 +9,9 @@ use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize;
-use crate::domain::{Amount, ChainSnapshot, Ledger, MINE_REWARD, Transaction};
+use crate::domain::{
+ Amount, ChainSnapshot, Ledger, MINE_REWARD, Transaction, revealed_blinded_transactions,
+};
const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS chain_snapshots (
@@ -297,6 +299,16 @@ fn clear_metrics_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Resu
fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>> {
let ledger = Ledger::from_persisted_snapshot(snapshot.clone())
.context("failed to rebuild ledger for metrics")?;
+ let revealed = revealed_blinded_transactions(snapshot)?.into_iter().fold(
+ std::collections::BTreeMap::<u64, Vec<Transaction>>::new(),
+ |mut by_height, revealed| {
+ by_height
+ .entry(revealed.height)
+ .or_default()
+ .push(revealed.transaction);
+ by_height
+ },
+ );
let mut circulating_supply =
snapshot
.genesis_allocations
@@ -311,6 +323,7 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
let mut previous_timestamp_ms = None;
for block in &snapshot.blocks {
+ let revealed_transactions = revealed.get(&block.height).cloned().unwrap_or_default();
let mut transfer_count = 0_u64;
let mut burn_count = 0_u64;
let mut mine_count = 0_u64;
@@ -318,7 +331,11 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
let mut mine_issued_amount = 0_u64;
let mut fees_amount = 0_u64;
- for transaction in &block.transactions {
+ for transaction in block
+ .transactions
+ .iter()
+ .chain(revealed_transactions.iter())
+ {
fees_amount = fees_amount
.checked_add(transaction.fee())
.context("block metric fees overflow")?;
@@ -339,7 +356,6 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
}
}
}
-
total_burned_amount = total_burned_amount
.checked_add(burned_amount)
.context("total burned metric overflows")?;
@@ -374,7 +390,7 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
block_time_ms,
mine_difficulty_bits: ledger.mine_difficulty_bits_at_height(block.height),
circulating_supply,
- transaction_count: block.transactions.len() as u64,
+ transaction_count: (block.transactions.len() + revealed_transactions.len()) as u64,
transfer_count,
burn_count,
mine_count,
@@ -490,6 +506,60 @@ mod tests {
}
#[test]
+ fn sqlite_chain_store_metrics_include_revealed_blinded_burns() {
+ let dir = tempdir().unwrap();
+ let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
+ let alice = Wallet::from_seed("metrics-blinded-alice");
+ let bob = Wallet::from_seed("metrics-blinded-bob");
+ let carol = Wallet::from_seed("metrics-blinded-carol");
+ let wallets = [alice.clone(), bob.clone()];
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), 10_000_000);
+ genesis.insert(bob.address().to_string(), 10_000_000);
+ genesis.insert(carol.address().to_string(), 10_000_000);
+ let mut ledger = Ledger::new_with_genesis_burns(
+ genesis,
+ vec![
+ GenesisBurn::new(alice.address(), 1_000_000),
+ GenesisBurn::new(bob.address(), 1_000_000),
+ ],
+ 1,
+ )
+ .unwrap();
+ let blinded = ledger
+ .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
+ .unwrap();
+ ledger
+ .submit_blinded_transaction(blinded.transaction)
+ .unwrap();
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let wallet = wallets
+ .iter()
+ .find(|wallet| wallet.address() == leader)
+ .unwrap();
+ let block = ledger.mine_next_block(wallet, 1).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+ ledger.submit_blinded_reveal(blinded.reveal).unwrap();
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let wallet = wallets
+ .iter()
+ .find(|wallet| wallet.address() == leader)
+ .unwrap();
+ let block = ledger.mine_next_block(wallet, 2).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+
+ store.save_with_metrics(&ledger.snapshot(), true).unwrap();
+ let metrics = store.load_metrics().unwrap();
+ let last = metrics.last().unwrap();
+ let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>();
+
+ assert_eq!(last.burn_count, 1);
+ assert_eq!(last.burned_amount, 3);
+ assert_eq!(last.fees_amount, 7);
+ assert_eq!(last.circulating_supply, supply_from_balances);
+ }
+
+ #[test]
fn sqlite_chain_store_roundtrips_vdf_round_metrics_above_legacy_u32_limit() {
let dir = tempdir().unwrap();
let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -33,8 +33,8 @@ use crate::{
FeeEstimate, NodeStatus, PeerDirection, PeerInfo, SharedNode, SharedPeerBook, StratumStatus,
},
domain::{
- Amount, Block, BurnLeaderRank, Ledger, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint,
- Transaction, TxInput, TxOutput, hex_hash,
+ Amount, Block, BurnLeaderRank, ChainSnapshot, Ledger, MINE_FINALIZER_FEE, MINE_REWARD,
+ OutPoint, Transaction, TxInput, TxOutput, hex_hash, revealed_blinded_transactions,
},
};
@@ -360,6 +360,7 @@ struct UiBlock {
leader_proof: Option<crate::domain::LeaderProof>,
burn_leader_ranks: Vec<BurnLeaderRank>,
transactions: Vec<UiTransaction>,
+ revealed_transactions: Vec<UiTransaction>,
hash: String,
}
@@ -728,13 +729,7 @@ async fn api_blocks(
)
})
.collect::<BTreeMap<_, _>>();
- Json(ui_blocks(
- blocks,
- &snapshot.genesis_allocations,
- &snapshot.blocks,
- &pending,
- &burn_leader_ranks,
- ))
+ Json(ui_blocks(blocks, &snapshot, &pending, &burn_leader_ranks))
}
async fn api_config(State(state): State<HttpState>) -> Json<UiConfig> {
@@ -755,7 +750,7 @@ async fn api_mempool(
let node = state.node.lock().await;
let snapshot = node.chain_snapshot();
let pending = node.pending_transactions();
- let outputs = known_output_index(&snapshot.genesis_allocations, &snapshot.blocks, &pending);
+ let outputs = known_output_index(&snapshot, &pending);
Json(page_items(
pending
.iter()
@@ -772,7 +767,7 @@ async fn api_wallet_transactions(
let node = state.node.lock().await;
let snapshot = node.chain_snapshot();
let pending = node.pending_transactions();
- let outputs = known_output_index(&snapshot.genesis_allocations, &snapshot.blocks, &pending);
+ let outputs = known_output_index(&snapshot, &pending);
let page_query = query.page();
let filters = WalletTransactionFilters::from_query(query);
Json(page_items(
@@ -1662,15 +1657,30 @@ fn wallet_transaction_row(
fn ui_blocks(
blocks: Vec<Block>,
- genesis_allocations: &BTreeMap<String, Amount>,
- chain: &[Block],
+ snapshot: &ChainSnapshot,
pending: &[Transaction],
burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>,
) -> Vec<UiBlock> {
- let outputs = known_output_index(genesis_allocations, chain, pending);
+ let outputs = known_output_index(snapshot, pending);
+ let revealed = revealed_blinded_transactions(snapshot)
+ .unwrap_or_default()
+ .into_iter()
+ .fold(
+ BTreeMap::<u64, Vec<Transaction>>::new(),
+ |mut by_height, revealed| {
+ by_height
+ .entry(revealed.height)
+ .or_default()
+ .push(revealed.transaction);
+ by_height
+ },
+ );
blocks
.into_iter()
- .map(|block| ui_block(block, &outputs, burn_leader_ranks))
+ .map(|block| {
+ let revealed_transactions = revealed.get(&block.height).cloned().unwrap_or_default();
+ ui_block(block, &outputs, burn_leader_ranks, &revealed_transactions)
+ })
.collect()
}
@@ -1678,11 +1688,15 @@ fn ui_block(
block: Block,
outputs: &BTreeMap<OutPoint, TxOutput>,
burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>,
+ revealed_transactions: &[Transaction],
) -> UiBlock {
let ranks = burn_leader_ranks
.get(&block.hash)
.cloned()
.unwrap_or_default();
+ let revealed_fees = revealed_transactions
+ .iter()
+ .fold(0_u64, |total, tx| total.saturating_add(tx.fee()));
UiBlock {
height: block.height,
prev_hash: block.prev_hash,
@@ -1691,7 +1705,7 @@ fn ui_block(
finalizer_mode: block.finalizer_mode,
finalizer_rank: block.finalizer_rank,
reward: block.reward,
- total_fees: block.reward,
+ total_fees: block.reward.saturating_add(revealed_fees),
vdf_rounds: block.vdf_rounds,
vdf_output: block.vdf_output,
leader_proof: block.leader_proof,
@@ -1701,6 +1715,10 @@ fn ui_block(
.iter()
.map(|tx| ui_transaction(tx, outputs))
.collect(),
+ revealed_transactions: revealed_transactions
+ .iter()
+ .map(|tx| ui_transaction(tx, outputs))
+ .collect(),
hash: block.hash,
}
}
@@ -1819,12 +1837,11 @@ fn hex_nibble(byte: u8) -> Option<u8> {
}
fn known_output_index(
- genesis_allocations: &BTreeMap<String, Amount>,
- chain: &[Block],
+ snapshot: &ChainSnapshot,
pending: &[Transaction],
) -> BTreeMap<OutPoint, TxOutput> {
let mut outputs = BTreeMap::new();
- for (address, amount) in genesis_allocations {
+ for (address, amount) in &snapshot.genesis_allocations {
if *amount == 0 {
continue;
}
@@ -1836,7 +1853,8 @@ fn known_output_index(
},
);
}
- for block in chain {
+ let revealed = revealed_blinded_transactions(snapshot).unwrap_or_default();
+ for block in &snapshot.blocks {
for transaction in &block.transactions {
index_transaction_outputs(&mut outputs, transaction);
}
@@ -1850,6 +1868,18 @@ fn known_output_index(
);
}
}
+ for revealed in revealed {
+ index_transaction_outputs(&mut outputs, &revealed.transaction);
+ if revealed.transaction.fee() > 0 {
+ outputs.insert(
+ blinded_fee_outpoint(&revealed.commitment),
+ TxOutput {
+ address: revealed.included_by,
+ amount: revealed.transaction.fee(),
+ },
+ );
+ }
+ }
for transaction in pending {
index_transaction_outputs(&mut outputs, transaction);
}
@@ -1893,6 +1923,13 @@ fn reward_outpoint(block_hash: &str) -> OutPoint {
}
}
+fn blinded_fee_outpoint(commitment: &str) -> OutPoint {
+ OutPoint {
+ txid: commitment.to_string(),
+ index: u32::MAX - 1,
+ }
+}
+
async fn replace_setup_wallet_with_generated_seed(
state: &HttpState,
headers: &HeaderMap,
@@ -2848,7 +2885,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/iuna-ui.js?v=78"></script>
+ <script defer src="/assets/iuna-ui.js?v=79"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak>
@@ -3291,6 +3328,21 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
</template>
<div class="muted" x-show="selectedBlock.transactions.length === 0">No transactions</div>
+ <template x-if="selectedBlock.revealed_transactions?.length">
+ <div class="tx-list">
+ <h3>Revealed</h3>
+ <template x-for="tx in selectedBlock.revealed_transactions" :key="tx.signature">
+ <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Revealed', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Revealed', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Revealed', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })">
+ <span class="pill" :class="tx.kind" x-text="tx.kind"></span>
+ <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div>
+ <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">IUNA <span x-text="amountLabel(tx.fee ?? 0)"></span></span></div>
+ <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(txFrom(tx))"></code></div>
+ <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(txTo(tx))"></code></div>
+ <div class="tx-field"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div>
+ </div>
+ </template>
+ </div>
+ </template>
</div>
</div>
</template>
@@ -3725,7 +3777,10 @@ mod tests {
p2p::GossipNetwork, wallet_store,
},
app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus},
- domain::{Block, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, OutPoint, Transaction, Wallet},
+ domain::{
+ Amount, Block, ChainSnapshot, LaunchProfile, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE,
+ OutPoint, Transaction, Wallet,
+ },
};
use super::{
@@ -4469,8 +4524,8 @@ mod tests {
fake_block(32, vec![carol_burn]),
];
- let outputs =
- super::known_output_index(&allocations, &chain, std::slice::from_ref(&pending_burn));
+ let snapshot = fake_snapshot(allocations, chain.clone());
+ let outputs = super::known_output_index(&snapshot, std::slice::from_ref(&pending_burn));
let rows = wallet_transaction_rows(
alice.address(),
vec![pending_burn.clone()],
@@ -4553,7 +4608,8 @@ mod tests {
let ledger = Ledger::new(BTreeMap::new(), 1);
let mine = ledger.build_mine(alice.address()).unwrap();
let chain = vec![fake_block(1, vec![mine.clone()])];
- let outputs = super::known_output_index(&BTreeMap::new(), &chain, &[]);
+ let snapshot = fake_snapshot(BTreeMap::new(), chain.clone());
+ let outputs = super::known_output_index(&snapshot, &[]);
let rows = wallet_transaction_rows(
alice.address(),
@@ -4582,7 +4638,8 @@ mod tests {
allocations.insert(alice.address().to_string(), 10);
let ledger = Ledger::new(allocations.clone(), 1);
let burn = ledger.build_burn(&alice, 3, 1).unwrap();
- let outputs = super::known_output_index(&allocations, &[], std::slice::from_ref(&burn));
+ let snapshot = fake_snapshot(allocations, Vec::new());
+ let outputs = super::known_output_index(&snapshot, std::slice::from_ref(&burn));
let default_rows = wallet_transaction_rows(
alice.address(),
@@ -4666,11 +4723,25 @@ mod tests {
vdf_rounds: 0,
vdf_output: "vdf".to_string(),
leader_proof: None,
+ blinded_transactions: Vec::new(),
+ blinded_reveals: Vec::new(),
transactions,
hash: format!("hash-{height}"),
}
}
+ fn fake_snapshot(
+ genesis_allocations: BTreeMap<String, Amount>,
+ blocks: Vec<Block>,
+ ) -> ChainSnapshot {
+ ChainSnapshot {
+ genesis_allocations,
+ vdf_rounds: 1,
+ launch_profile: LaunchProfile::default(),
+ blocks,
+ }
+ }
+
fn metric_row(
height: u64,
block_time_ms: Option<u64>,
@@ -4739,7 +4810,7 @@ mod tests {
#[test]
fn metrics_screen_includes_block_range_filter() {
- assert!(super::INDEX_HTML.contains("iuna-ui.js?v=78"));
+ assert!(super::INDEX_HTML.contains("iuna-ui.js?v=79"));
assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\""));
assert!(super::INDEX_HTML.contains("setMetricsRange(100)"));
assert!(super::INDEX_HTML.contains("setMetricsRange(1000)"));
diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs
@@ -9,7 +9,7 @@ use std::{
time::Duration,
};
-use anyhow::{Context, Result};
+use anyhow::{Context, Result, anyhow};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::Serialize;
use tokio::{
@@ -1296,6 +1296,18 @@ async fn process_envelope(
GossipEnvelope::Transactions { transactions } => {
process_transactions(network, writer, remote_addr, known_peer, transactions).await?;
}
+ GossipEnvelope::BlindedTransaction(tx) => {
+ process_blinded_transactions(network, remote_addr, known_peer, vec![tx]).await;
+ }
+ GossipEnvelope::BlindedTransactions { transactions } => {
+ process_blinded_transactions(network, remote_addr, known_peer, transactions).await;
+ }
+ GossipEnvelope::BlindedReveal(reveal) => {
+ process_blinded_reveals(network, remote_addr, known_peer, vec![reveal]).await;
+ }
+ GossipEnvelope::BlindedReveals { reveals } => {
+ process_blinded_reveals(network, remote_addr, known_peer, reveals).await;
+ }
GossipEnvelope::TransactionAck { accepted, rejected } => {
let peer = known_peer
.clone()
@@ -1458,6 +1470,62 @@ fn receive_transactions_for_ack(
(accepted, rejected)
}
+async fn process_blinded_transactions(
+ network: &GossipNetwork,
+ remote_addr: SocketAddr,
+ known_peer: &Option<String>,
+ transactions: Vec<crate::domain::BlindedTransaction>,
+) {
+ let first_error = {
+ let mut node = network.inner.node.lock().await;
+ let mut first_error = None;
+ for tx in transactions {
+ if let Err(error) = node.receive_blinded_transaction(tx) {
+ first_error.get_or_insert(error);
+ }
+ }
+ first_error
+ };
+ record_inbound_result(
+ network,
+ known_peer,
+ remote_addr,
+ first_error
+ .map(|error| Err(anyhow!(format!("{error:#}"))))
+ .unwrap_or(Ok(())),
+ )
+ .await;
+ network.forward_outbox().await;
+}
+
+async fn process_blinded_reveals(
+ network: &GossipNetwork,
+ remote_addr: SocketAddr,
+ known_peer: &Option<String>,
+ reveals: Vec<crate::domain::BlindedReveal>,
+) {
+ let first_error = {
+ let mut node = network.inner.node.lock().await;
+ let mut first_error = None;
+ for reveal in reveals {
+ if let Err(error) = node.receive_blinded_reveal(reveal) {
+ first_error.get_or_insert(error);
+ }
+ }
+ first_error
+ };
+ record_inbound_result(
+ network,
+ known_peer,
+ remote_addr,
+ first_error
+ .map(|error| Err(anyhow!(format!("{error:#}"))))
+ .unwrap_or(Ok(())),
+ )
+ .await;
+ network.forward_outbox().await;
+}
+
async fn maybe_request_catchup(
network: &GossipNetwork,
writer: &mut OwnedWriteHalf,
@@ -1821,6 +1889,10 @@ fn record_received_envelope_kind(metrics: &P2pMetricsCounters, envelope: &Gossip
}
GossipEnvelope::Transaction(_)
| GossipEnvelope::Transactions { .. }
+ | GossipEnvelope::BlindedTransaction(_)
+ | GossipEnvelope::BlindedTransactions { .. }
+ | GossipEnvelope::BlindedReveal(_)
+ | GossipEnvelope::BlindedReveals { .. }
| GossipEnvelope::Block(_)
| GossipEnvelope::Blocks { .. }
| GossipEnvelope::ChainSnapshot(_) => {
@@ -1883,6 +1955,20 @@ fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> {
TRANSACTION_BATCH_LIMIT,
)?;
}
+ GossipEnvelope::BlindedTransactions { transactions } => {
+ ensure_len(
+ "blinded transaction batch",
+ transactions.len(),
+ TRANSACTION_BATCH_LIMIT,
+ )?;
+ }
+ GossipEnvelope::BlindedReveals { reveals } => {
+ ensure_len(
+ "blinded reveal batch",
+ reveals.len(),
+ TRANSACTION_BATCH_LIMIT,
+ )?;
+ }
GossipEnvelope::Blocks { blocks } => {
ensure_len("block batch", blocks.len(), MAX_BLOCK_BATCH)?;
}
@@ -1898,6 +1984,8 @@ fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> {
GossipEnvelope::Hello(_)
| GossipEnvelope::ChainSnapshotRequest
| GossipEnvelope::Transaction(_)
+ | GossipEnvelope::BlindedTransaction(_)
+ | GossipEnvelope::BlindedReveal(_)
| GossipEnvelope::Block(_)
| GossipEnvelope::PeerAnnouncement { .. }
| GossipEnvelope::PeerVerificationChallenge { .. }
@@ -3191,6 +3279,8 @@ mod tests {
vdf_rounds: 1,
vdf_output: "vdf".to_string(),
leader_proof: None,
+ blinded_transactions: Vec::new(),
+ blinded_reveals: Vec::new(),
transactions: Vec::new(),
hash: "hash".to_string(),
};
diff --git a/src/app.rs b/src/app.rs
@@ -13,10 +13,11 @@ use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use crate::domain::{
- Amount, Block, BurnLeaderRank, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE,
- DEFAULT_TRANSACTION_FEE, Ledger, MAX_PENDING_TRANSACTIONS, MINE_FINALIZER_FEE, OutPoint,
- PreparedBlock, StratumMineShare, StratumMineTemplate, Transaction, TransactionSubmitOutcome,
- VDF_TARGET_BLOCK_MS, Wallet, hex_hash, run_vdf,
+ Amount, BlindedReveal, BlindedTransaction, Block, BuiltBlindedTransaction, BurnLeaderRank,
+ ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, Ledger,
+ MAX_PENDING_TRANSACTIONS, MINE_FINALIZER_FEE, OutPoint, PreparedBlock, StratumMineShare,
+ StratumMineTemplate, Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet,
+ hex_hash, run_vdf,
};
pub type SharedNode = Arc<Mutex<NodeCore>>;
@@ -130,6 +131,14 @@ pub enum GossipEnvelope {
Transactions {
transactions: Vec<Transaction>,
},
+ BlindedTransaction(BlindedTransaction),
+ BlindedTransactions {
+ transactions: Vec<BlindedTransaction>,
+ },
+ BlindedReveal(BlindedReveal),
+ BlindedReveals {
+ reveals: Vec<BlindedReveal>,
+ },
Block(Block),
Blocks {
blocks: Vec<Block>,
@@ -260,6 +269,7 @@ 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_reveals: BTreeMap<String, BlindedReveal>,
outbox: Vec<GossipEnvelope>,
}
@@ -345,6 +355,7 @@ impl NodeCore {
last_auto_pow_mine_anchor: None,
last_auto_pow_mine_status: None,
auto_pow_mine_cursor: None,
+ owned_blinded_reveals: BTreeMap::new(),
outbox: Vec::new(),
}
}
@@ -363,6 +374,7 @@ 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_reveals.clear();
}
pub fn ledger(&self) -> &Ledger {
@@ -433,12 +445,29 @@ impl NodeCore {
pub fn mempool_gossip(&self) -> Vec<GossipEnvelope> {
let transactions = self.ledger.pending().to_vec();
- transactions
+ let mut gossip = transactions
.chunks(TRANSACTION_BATCH_LIMIT)
.map(|chunk| GossipEnvelope::Transactions {
transactions: chunk.to_vec(),
})
- .collect()
+ .collect::<Vec<_>>();
+ gossip.extend(
+ self.ledger
+ .pending_blinded_transactions()
+ .chunks(TRANSACTION_BATCH_LIMIT)
+ .map(|chunk| GossipEnvelope::BlindedTransactions {
+ transactions: chunk.to_vec(),
+ }),
+ );
+ gossip.extend(
+ self.ledger
+ .pending_blinded_reveals()
+ .chunks(TRANSACTION_BATCH_LIMIT)
+ .map(|chunk| GossipEnvelope::BlindedReveals {
+ reveals: chunk.to_vec(),
+ }),
+ );
+ gossip
}
pub fn chain_snapshot(&self) -> ChainSnapshot {
@@ -646,6 +675,21 @@ impl NodeCore {
Ok((tx, estimate))
}
+ pub fn blinded_burn_with_fee(
+ &mut self,
+ amount: Amount,
+ fee: Amount,
+ expires_at_height: u64,
+ ) -> Result<BlindedTransaction> {
+ let built = self.ledger.build_blinded_burn(
+ self.wallet.unlocked()?,
+ amount,
+ fee,
+ expires_at_height,
+ )?;
+ self.submit_owned_blinded_transaction(built)
+ }
+
pub fn estimate_burn_fee(&self, amount: Amount, fee_per_byte: Amount) -> Result<FeeEstimate> {
self.build_burn_with_fee_rate(amount, fee_per_byte)
.map(|(_, estimate)| estimate)
@@ -690,6 +734,23 @@ impl NodeCore {
Ok(tx)
}
+ pub fn blinded_transfer_with_fee(
+ &mut self,
+ to: impl Into<String>,
+ amount: Amount,
+ fee: Amount,
+ expires_at_height: u64,
+ ) -> Result<BlindedTransaction> {
+ let built = self.ledger.build_blinded_transfer(
+ self.wallet.unlocked()?,
+ to,
+ amount,
+ fee,
+ expires_at_height,
+ )?;
+ self.submit_owned_blinded_transaction(built)
+ }
+
pub fn transfer_with_fee_rate(
&mut self,
to: impl Into<String>,
@@ -774,6 +835,49 @@ impl NodeCore {
Ok(outcome)
}
+ pub fn receive_blinded_transaction(&mut self, tx: BlindedTransaction) -> Result<()> {
+ if self.ledger.submit_blinded_transaction(tx.clone())? {
+ self.outbox.push(GossipEnvelope::BlindedTransaction(tx));
+ }
+ Ok(())
+ }
+
+ pub fn receive_blinded_reveal(&mut self, reveal: BlindedReveal) -> Result<()> {
+ if self.ledger.submit_blinded_reveal(reveal.clone())? {
+ self.outbox.push(GossipEnvelope::BlindedReveal(reveal));
+ }
+ Ok(())
+ }
+
+ fn submit_owned_blinded_transaction(
+ &mut self,
+ built: BuiltBlindedTransaction,
+ ) -> Result<BlindedTransaction> {
+ let transaction = built.transaction;
+ self.owned_blinded_reveals
+ .insert(transaction.commitment.clone(), built.reveal);
+ if self
+ .ledger
+ .submit_blinded_transaction(transaction.clone())?
+ {
+ self.outbox
+ .push(GossipEnvelope::BlindedTransaction(transaction.clone()));
+ }
+ Ok(transaction)
+ }
+
+ 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 {
+ continue;
+ };
+ if self.ledger.submit_blinded_reveal(reveal.clone())? {
+ self.outbox.push(GossipEnvelope::BlindedReveal(reveal));
+ }
+ }
+ Ok(())
+ }
+
fn build_burn_with_fee_rate(
&self,
amount: Amount,
@@ -1147,6 +1251,7 @@ impl NodeCore {
.mine_next_block(self.wallet.unlocked()?, timestamp_ms)?;
self.ledger.apply_locally_mined_block(block.clone())?;
self.outbox.push(GossipEnvelope::Block(block.clone()));
+ self.publish_owned_reveals_for_block(&block)?;
Ok(block)
}
@@ -1167,6 +1272,7 @@ impl NodeCore {
let block = work.finish_at(self.wallet.unlocked()?, vdf_output, timestamp_ms);
self.ledger.apply_locally_mined_block(block.clone())?;
self.outbox.push(GossipEnvelope::Block(block.clone()));
+ self.publish_owned_reveals_for_block(&block)?;
Ok(block)
}
@@ -1190,10 +1296,25 @@ impl NodeCore {
}
Ok(())
}
+ GossipEnvelope::BlindedTransaction(tx) => self.receive_blinded_transaction(tx),
+ GossipEnvelope::BlindedTransactions { transactions } => {
+ for tx in transactions {
+ self.receive_blinded_transaction(tx)?;
+ }
+ Ok(())
+ }
+ GossipEnvelope::BlindedReveal(reveal) => self.receive_blinded_reveal(reveal),
+ GossipEnvelope::BlindedReveals { reveals } => {
+ for reveal in reveals {
+ self.receive_blinded_reveal(reveal)?;
+ }
+ Ok(())
+ }
GossipEnvelope::Block(block) => {
let previous_height = self.ledger.height();
self.ledger.apply_block(block.clone())?;
if self.ledger.height() > previous_height {
+ self.publish_owned_reveals_for_block(&block)?;
self.outbox.push(GossipEnvelope::Block(block));
}
Ok(())
@@ -1204,6 +1325,7 @@ impl NodeCore {
let previous_height = self.ledger.height();
self.ledger.apply_block(block.clone())?;
if self.ledger.height() > previous_height {
+ self.publish_owned_reveals_for_block(&block)?;
imported.push(block);
}
}
@@ -1247,7 +1369,7 @@ impl NodeCore {
self.last_auto_pow_mine_anchor = None;
self.last_auto_pow_mine_status = None;
self.auto_pow_mine_cursor = None;
- self.enqueue_imported_blocks(previous_height);
+ self.enqueue_imported_blocks(previous_height)?;
}
Ok(())
}
@@ -1268,7 +1390,7 @@ impl NodeCore {
self.last_auto_pow_mine_anchor = None;
self.last_auto_pow_mine_status = None;
self.auto_pow_mine_cursor = None;
- self.enqueue_imported_blocks(previous_height);
+ self.enqueue_imported_blocks(previous_height)?;
Ok(true)
}
@@ -1276,16 +1398,20 @@ impl NodeCore {
std::mem::take(&mut self.outbox)
}
- fn enqueue_imported_blocks(&mut self, previous_height: u64) {
+ fn enqueue_imported_blocks(&mut self, previous_height: u64) -> Result<()> {
if self.ledger.height() <= previous_height {
- return;
+ return Ok(());
}
let blocks = self
.ledger
.blocks_from(previous_height + 1, IMPORT_REBROADCAST_LIMIT);
+ for block in &blocks {
+ self.publish_owned_reveals_for_block(block)?;
+ }
if !blocks.is_empty() {
self.outbox.push(GossipEnvelope::Blocks { blocks });
}
+ Ok(())
}
}
@@ -1860,7 +1986,7 @@ mod tests {
RECOVERY_BLOCK_DELAY_MS, Transaction, Wallet,
};
- use super::{NodeConfig, NodeCore};
+ use super::{GossipEnvelope, NodeConfig, NodeCore};
#[test]
fn same_height_verified_import_does_not_reset_auto_burn_guard() {
@@ -2106,6 +2232,83 @@ mod tests {
let minimum_burn_fee = burn.economic_size_bytes() as u64 * 3;
assert!(burn.fee() >= minimum_burn_fee);
}
+
+ #[test]
+ fn mempool_gossip_includes_blinded_transactions() {
+ let alice = Wallet::from_seed("blinded-gossip-alice");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), 10 * MICRO_IUNA);
+ let ledger = Ledger::new(genesis, 1);
+ let blinded = ledger.build_blinded_burn(&alice, MICRO_IUNA, 7, 3).unwrap();
+ let mut sender = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0);
+ let mut receiver = NodeCore::from_ledger(alice, ledger, 0);
+
+ sender
+ .receive_blinded_transaction(blinded.transaction.clone())
+ .unwrap();
+ for envelope in sender.mempool_gossip() {
+ receiver.receive(envelope).unwrap();
+ }
+
+ assert_eq!(
+ receiver.ledger().pending_blinded_transactions(),
+ std::slice::from_ref(&blinded.transaction)
+ );
+ }
+
+ #[test]
+ fn owned_blinded_transaction_reveals_after_commit_block_import() {
+ let alice = Wallet::from_seed("owned-blinded-reveal-alice");
+ let bob = Wallet::from_seed("owned-blinded-reveal-bob");
+ let carol = Wallet::from_seed("owned-blinded-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();
+ wallet_node.drain_outbox();
+ 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 commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap();
+
+ wallet_node
+ .receive(GossipEnvelope::Block(commit_block))
+ .unwrap();
+ let outbox = wallet_node.drain_outbox();
+
+ assert!(
+ wallet_node
+ .ledger()
+ .pending_blinded_reveals()
+ .iter()
+ .any(|reveal| reveal.commitment == blinded.commitment)
+ );
+ assert!(outbox.iter().any(|envelope| matches!(
+ envelope,
+ GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment
+ )));
+ }
}
#[derive(Debug, Default)]
@@ -2175,7 +2378,12 @@ impl InMemoryNetwork {
fn receive_in_memory_envelope(node: &mut NodeCore, envelope: GossipEnvelope) -> Result<()> {
let transaction_like = matches!(
envelope,
- GossipEnvelope::Transaction(_) | GossipEnvelope::Transactions { .. }
+ GossipEnvelope::Transaction(_)
+ | GossipEnvelope::Transactions { .. }
+ | GossipEnvelope::BlindedTransaction(_)
+ | GossipEnvelope::BlindedTransactions { .. }
+ | GossipEnvelope::BlindedReveal(_)
+ | GossipEnvelope::BlindedReveals { .. }
);
match node.receive(envelope) {
Ok(()) => Ok(()),
diff --git a/src/domain.rs b/src/domain.rs
@@ -4,7 +4,12 @@ use std::{
};
use anyhow::{Context, Result, anyhow, bail};
+use chacha20poly1305::{
+ ChaCha20Poly1305, Nonce,
+ aead::{Aead, KeyInit},
+};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
+use getrandom::getrandom;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -47,6 +52,8 @@ const WALLET_SEED_DOMAIN: &str = "iuna-wallet-seed";
const PUBLIC_KEY_BYTES: usize = 32;
const HASH_BYTES: usize = 32;
const SIGNATURE_BYTES: usize = 64;
+const BLINDED_KEY_BYTES: usize = 32;
+const BLINDED_NONCE_BYTES: usize = 12;
const STRATUM_MINE_HEADER_BYTES: usize = 80;
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -138,6 +145,46 @@ pub enum Transaction {
},
}
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BlindedTransaction {
+ pub commitment: String,
+ pub fee: Amount,
+ pub encrypted_size: u32,
+ pub expires_at_height: u64,
+ pub nonce: String,
+ pub ciphertext: String,
+ pub payload_hash: String,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BlindedReveal {
+ pub commitment: String,
+ pub key: String,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct BuiltBlindedTransaction {
+ pub transaction: BlindedTransaction,
+ pub reveal: BlindedReveal,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RevealedBlindedTransaction {
+ pub height: u64,
+ pub commitment: String,
+ pub included_by: String,
+ pub transaction: Transaction,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct ActiveBlindedTransaction {
+ transaction: BlindedTransaction,
+ included_height: u64,
+ included_by: String,
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MineSearchOutcome {
pub transaction: Option<Transaction>,
@@ -378,6 +425,44 @@ impl Transaction {
}
}
+impl BlindedTransaction {
+ pub fn id(&self) -> &str {
+ &self.commitment
+ }
+
+ pub fn canonical(&self) -> String {
+ format!(
+ "blinded-tx:{}:{}:{}:{}:{}:{}",
+ self.fee,
+ self.encrypted_size,
+ self.expires_at_height,
+ self.nonce,
+ self.ciphertext,
+ self.payload_hash
+ )
+ }
+
+ pub fn fee_rate_size_bytes(&self) -> usize {
+ self.encrypted_size as usize
+ }
+
+ pub fn serialized_size_bytes(&self) -> Result<usize> {
+ serde_json::to_vec(self)
+ .map(|bytes| bytes.len())
+ .context("failed to serialize blinded transaction for size check")
+ }
+}
+
+impl BlindedReveal {
+ pub fn canonical(&self) -> String {
+ format!("blinded-reveal:{}:{}", self.commitment, self.key)
+ }
+}
+
+fn canonical_blinded_block_items(blinded: &str, reveals: &str) -> String {
+ format!("blinded:{blinded}:reveals:{reveals}")
+}
+
impl TxInput {
fn without_signature(&self) -> UnsignedTxInput {
UnsignedTxInput {
@@ -722,6 +807,10 @@ pub struct Block {
pub vdf_rounds: u64,
pub vdf_output: String,
pub leader_proof: Option<LeaderProof>,
+ #[serde(default)]
+ pub blinded_transactions: Vec<BlindedTransaction>,
+ #[serde(default)]
+ pub blinded_reveals: Vec<BlindedReveal>,
pub transactions: Vec<Transaction>,
pub hash: String,
}
@@ -747,6 +836,8 @@ impl Block {
vdf_rounds: draft.vdf_rounds,
vdf_output: draft.vdf_output,
leader_proof: draft.leader_proof,
+ blinded_transactions: draft.blinded_transactions,
+ blinded_reveals: draft.blinded_reveals,
transactions: draft.transactions,
hash: String::new(),
};
@@ -779,6 +870,18 @@ impl Block {
.map(Transaction::canonical)
.collect::<Vec<_>>()
.join("|");
+ let blinded = self
+ .blinded_transactions
+ .iter()
+ .map(BlindedTransaction::canonical)
+ .collect::<Vec<_>>()
+ .join("|");
+ let reveals = self
+ .blinded_reveals
+ .iter()
+ .map(BlindedReveal::canonical)
+ .collect::<Vec<_>>()
+ .join("|");
let leader_proof = self
.leader_proof
.as_ref()
@@ -789,6 +892,21 @@ impl Block {
)
})
.unwrap_or_default();
+ if !self.blinded_transactions.is_empty() || !self.blinded_reveals.is_empty() {
+ return hex_hash(format!(
+ "block-content-v3:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}",
+ self.height,
+ self.prev_hash,
+ self.timestamp_ms,
+ self.miner,
+ self.finalizer_rank,
+ self.reward,
+ self.vdf_rounds,
+ leader_proof,
+ txs,
+ canonical_blinded_block_items(&blinded, &reveals)
+ ));
+ }
hex_hash(format!(
"{}:{}",
self.legacy_content_hash_prefix(&leader_proof),
@@ -948,6 +1066,8 @@ pub struct PreparedBlock {
vdf_rounds: u64,
vdf_seed: String,
leader_ticket: Option<BurnTicket>,
+ blinded_transactions: Vec<BlindedTransaction>,
+ blinded_reveals: Vec<BlindedReveal>,
transactions: Vec<Transaction>,
}
@@ -1010,6 +1130,8 @@ impl PreparedBlock {
vdf_rounds: self.vdf_rounds,
vdf_output,
leader_proof,
+ blinded_transactions: self.blinded_transactions,
+ blinded_reveals: self.blinded_reveals,
transactions: self.transactions,
})
}
@@ -1027,6 +1149,8 @@ struct BlockDraft {
vdf_rounds: u64,
vdf_output: String,
leader_proof: Option<LeaderProof>,
+ blinded_transactions: Vec<BlindedTransaction>,
+ blinded_reveals: Vec<BlindedReveal>,
transactions: Vec<Transaction>,
}
@@ -1181,6 +1305,13 @@ enum TransactionKind {
Burn,
}
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+struct BlockSelection {
+ transactions: Vec<Transaction>,
+ blinded_transactions: Vec<BlindedTransaction>,
+ blinded_reveals: Vec<BlindedReveal>,
+}
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransactionSubmitOutcome {
Added,
@@ -1202,6 +1333,9 @@ pub struct Ledger {
tickets: Vec<BurnTicket>,
pending: Vec<Transaction>,
orphans: Vec<Transaction>,
+ pending_blinded: Vec<BlindedTransaction>,
+ pending_reveals: Vec<BlindedReveal>,
+ active_blinded: BTreeMap<String, ActiveBlindedTransaction>,
mine_reward: Amount,
initial_vdf_rounds: u64,
vdf_rounds: u64,
@@ -1249,6 +1383,9 @@ impl Ledger {
tickets,
pending: Vec::new(),
orphans: Vec::new(),
+ pending_blinded: Vec::new(),
+ pending_reveals: Vec::new(),
+ active_blinded: BTreeMap::new(),
mine_reward: MINE_REWARD,
initial_vdf_rounds: vdf_rounds,
vdf_rounds,
@@ -1301,6 +1438,9 @@ impl Ledger {
tickets: Vec::new(),
pending: Vec::new(),
orphans: Vec::new(),
+ pending_blinded: Vec::new(),
+ pending_reveals: Vec::new(),
+ active_blinded: BTreeMap::new(),
mine_reward: MINE_REWARD,
initial_vdf_rounds: vdf_rounds,
vdf_rounds,
@@ -1474,12 +1614,16 @@ impl Ledger {
fn replace_with_better_chain(&mut self, mut candidate: Ledger, fork_point: ForkPoint) {
let mut carry_forward = self.pending.clone();
carry_forward.extend(self.orphans.clone());
+ let mut carry_forward_blinded = self.pending_blinded.clone();
+ let mut carry_forward_reveals = self.pending_reveals.clone();
for block in self
.chain
.iter()
.skip(fork_point.first_diverging_height() as usize)
{
carry_forward.extend(block.transactions.clone());
+ carry_forward_blinded.extend(block.blinded_transactions.clone());
+ carry_forward_reveals.extend(block.blinded_reveals.clone());
}
let mined_signatures = candidate
@@ -1488,12 +1632,34 @@ impl Ledger {
.flat_map(|block| block.transactions.iter())
.map(|tx| tx.signature().to_string())
.collect::<BTreeSet<_>>();
+ let mined_blinded_commitments = candidate
+ .chain
+ .iter()
+ .flat_map(|block| block.blinded_transactions.iter())
+ .map(|transaction| transaction.commitment.clone())
+ .collect::<BTreeSet<_>>();
+ let mined_reveal_commitments = candidate
+ .chain
+ .iter()
+ .flat_map(|block| block.blinded_reveals.iter())
+ .map(|reveal| reveal.commitment.clone())
+ .collect::<BTreeSet<_>>();
for transaction in carry_forward {
if !mined_signatures.contains(transaction.signature()) {
let _ = candidate.submit_transaction(transaction);
}
}
+ for transaction in carry_forward_blinded {
+ if !mined_blinded_commitments.contains(&transaction.commitment) {
+ let _ = candidate.submit_blinded_transaction(transaction);
+ }
+ }
+ for reveal in carry_forward_reveals {
+ if !mined_reveal_commitments.contains(&reveal.commitment) {
+ let _ = candidate.submit_blinded_reveal(reveal);
+ }
+ }
*self = candidate;
}
@@ -1516,7 +1682,9 @@ impl Ledger {
mine_reward: self.mine_reward,
current_mine_difficulty_bits: self.current_mine_difficulty_bits(),
balances: balances_from_utxos(&self.utxos),
- pending_transactions: self.pending.len(),
+ pending_transactions: self.pending.len()
+ + self.pending_blinded.len()
+ + self.pending_reveals.len(),
}
}
@@ -1614,6 +1782,14 @@ impl Ledger {
&self.pending
}
+ pub fn pending_blinded_transactions(&self) -> &[BlindedTransaction] {
+ &self.pending_blinded
+ }
+
+ pub fn pending_blinded_reveals(&self) -> &[BlindedReveal] {
+ &self.pending_reveals
+ }
+
pub fn orphan_transactions(&self) -> &[Transaction] {
&self.orphans
}
@@ -1635,6 +1811,31 @@ impl Ledger {
self.transaction_by_signature(signature).is_some()
}
+ pub fn has_blinded_transaction(&self, commitment: &str) -> bool {
+ self.pending_blinded
+ .iter()
+ .any(|transaction| transaction.commitment == commitment)
+ || self.active_blinded.contains_key(commitment)
+ || self.chain.iter().any(|block| {
+ block
+ .blinded_transactions
+ .iter()
+ .any(|tx| tx.commitment == commitment)
+ })
+ }
+
+ pub fn has_blinded_reveal(&self, commitment: &str) -> bool {
+ self.pending_reveals
+ .iter()
+ .any(|reveal| reveal.commitment == commitment)
+ || self.chain.iter().any(|block| {
+ block
+ .blinded_reveals
+ .iter()
+ .any(|reveal| reveal.commitment == commitment)
+ })
+ }
+
pub fn vdf_rounds(&self) -> u64 {
self.vdf_rounds
}
@@ -1789,6 +1990,77 @@ impl Ledger {
Ok(transaction)
}
+ pub fn build_blinded_burn(
+ &self,
+ wallet: &Wallet,
+ amount: Amount,
+ fee: Amount,
+ expires_at_height: u64,
+ ) -> Result<BuiltBlindedTransaction> {
+ let transaction = self.build_burn(wallet, amount, fee)?;
+ self.blind_transaction(transaction, fee, expires_at_height)
+ }
+
+ pub fn build_blinded_transfer(
+ &self,
+ wallet: &Wallet,
+ to: impl Into<String>,
+ amount: Amount,
+ fee: Amount,
+ expires_at_height: u64,
+ ) -> Result<BuiltBlindedTransaction> {
+ let transaction = self.build_transfer(wallet, to, amount, fee)?;
+ self.blind_transaction(transaction, fee, expires_at_height)
+ }
+
+ fn blind_transaction(
+ &self,
+ transaction: Transaction,
+ fee: Amount,
+ expires_at_height: u64,
+ ) -> Result<BuiltBlindedTransaction> {
+ if expires_at_height <= self.height() {
+ bail!("blinded transaction expiry must be in the future");
+ }
+ if fee != transaction.fee() {
+ bail!("blinded transaction fee must match plaintext transaction fee");
+ }
+ let plaintext = serde_json::to_vec(&transaction)
+ .context("failed to serialize transaction for blinded payload")?;
+ let payload_hash = hex_hash(&plaintext);
+ let mut key = [0_u8; BLINDED_KEY_BYTES];
+ let mut nonce = [0_u8; BLINDED_NONCE_BYTES];
+ getrandom(&mut key)
+ .map_err(|error| anyhow!("failed to generate blinded transaction key: {error}"))?;
+ getrandom(&mut nonce)
+ .map_err(|error| anyhow!("failed to generate blinded transaction nonce: {error}"))?;
+ let ciphertext = encrypt_blinded_payload(&key, &nonce, fee, expires_at_height, &plaintext)?;
+ let encrypted_size = u32::try_from(ciphertext.len())
+ .context("blinded transaction ciphertext is too large")?;
+ let transaction = BlindedTransaction {
+ commitment: String::new(),
+ fee,
+ encrypted_size,
+ expires_at_height,
+ nonce: hex_encode(nonce),
+ ciphertext: hex_encode(&ciphertext),
+ payload_hash,
+ };
+ let commitment = blinded_transaction_commitment(&transaction)?;
+ let transaction = BlindedTransaction {
+ commitment: commitment.clone(),
+ ..transaction
+ };
+ self.validate_blinded_transaction(&transaction)?;
+ Ok(BuiltBlindedTransaction {
+ transaction,
+ reveal: BlindedReveal {
+ commitment,
+ key: hex_encode(key),
+ },
+ })
+ }
+
pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> {
let recipient = recipient.into();
validate_address(&recipient, "mine recipient")?;
@@ -1903,6 +2175,30 @@ impl Ledger {
Ok(self.submit_transaction_with_outcome(transaction)?.added())
}
+ pub fn submit_blinded_transaction(&mut self, transaction: BlindedTransaction) -> Result<bool> {
+ if self.has_blinded_transaction(&transaction.commitment) {
+ return Ok(false);
+ }
+ self.validate_blinded_transaction(&transaction)?;
+ if self.pending_blinded.len() >= MAX_PENDING_TRANSACTIONS {
+ bail!("blinded mempool is full");
+ }
+ self.pending_blinded.push(transaction);
+ Ok(true)
+ }
+
+ pub fn submit_blinded_reveal(&mut self, reveal: BlindedReveal) -> Result<bool> {
+ if self.has_blinded_reveal(&reveal.commitment) {
+ return Ok(false);
+ }
+ self.validate_blinded_reveal_terms(&reveal)?;
+ if self.pending_reveals.len() >= MAX_PENDING_TRANSACTIONS {
+ bail!("blinded reveal pool is full");
+ }
+ self.pending_reveals.push(reveal);
+ Ok(true)
+ }
+
pub fn submit_transaction_with_outcome(
&mut self,
transaction: Transaction,
@@ -1961,8 +2257,8 @@ impl Ledger {
bail!("no selected leader for block {height}");
}
- let transactions = self.select_block_transactions()?;
- ensure_block_has_burn(&transactions)?;
+ let selection = self.select_block_transactions()?;
+ ensure_block_has_burn_or_blinded(&selection)?;
let tip = self.tip();
let prev_hash = tip.hash.clone();
@@ -1974,12 +2270,14 @@ impl Ledger {
timestamp_ms,
miner: miner.to_string(),
finalizer_mode: FinalizerMode::Ticket,
- reward: fee_reward(&transactions)?,
+ reward: fee_reward(&selection.transactions)?,
vdf_rounds: self.vdf_rounds_for_finalizer_rank(finalizer_rank)?,
vdf_seed,
finalizer_rank,
leader_ticket: Some(leader_ticket),
- transactions,
+ blinded_transactions: selection.blinded_transactions,
+ blinded_reveals: selection.blinded_reveals,
+ transactions: selection.transactions,
})
}
@@ -2000,9 +2298,9 @@ impl Ledger {
bail!("recovery block is not available before timestamp {min_timestamp}");
}
- let transactions = self.select_recovery_block_transactions(miner)?;
- ensure_block_has_burn(&transactions)?;
- ensure_block_has_burn_from(&transactions, miner)?;
+ let selection = self.select_recovery_block_transactions(miner)?;
+ ensure_block_has_burn(&selection.transactions)?;
+ ensure_block_has_burn_from(&selection.transactions, miner)?;
let tip = self.tip();
let prev_hash = tip.hash.clone();
@@ -2015,11 +2313,13 @@ impl Ledger {
miner: miner.to_string(),
finalizer_mode: FinalizerMode::Recovery,
finalizer_rank: 0,
- reward: fee_reward(&transactions)?,
+ reward: fee_reward(&selection.transactions)?,
vdf_rounds: self.recovery_vdf_rounds()?,
vdf_seed,
leader_ticket: None,
- transactions,
+ blinded_transactions: selection.blinded_transactions,
+ blinded_reveals: selection.blinded_reveals,
+ transactions: selection.transactions,
})
}
@@ -2068,6 +2368,7 @@ impl Ledger {
let mut utxos = self.utxos.clone();
let mut signatures = BTreeSet::new();
+ let mut revealed_transactions = Vec::new();
for tx in &block.transactions {
if !signatures.insert(tx.signature()) {
bail!("duplicate transaction in block");
@@ -2075,6 +2376,20 @@ impl Ledger {
self.validate_transaction_terms(tx)?;
apply_transaction(tx, &mut utxos)?;
}
+ let mut revealed_commitments = BTreeSet::new();
+ for reveal in &block.blinded_reveals {
+ if !revealed_commitments.insert(reveal.commitment.clone()) {
+ bail!("duplicate blinded reveal in block");
+ }
+ let active = self
+ .active_blinded
+ .get(&reveal.commitment)
+ .context("blinded reveal does not reference an active blinded transaction")?;
+ let tx = self.decrypt_active_blinded(active, reveal)?;
+ apply_transaction(&tx, &mut utxos)?;
+ credit_blinded_fee_output(&mut utxos, active, &tx)?;
+ revealed_transactions.push(tx);
+ }
if block.reward != fee_reward(&block.transactions)? {
bail!("block reward is invalid");
}
@@ -2082,15 +2397,47 @@ impl Ledger {
apply_finalizer_ticket_effects(&block, &mut tickets)?;
credit_reward_output(&mut utxos, &block)?;
tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?);
+ tickets.extend(tickets_created_by_transactions(
+ block.height,
+ &revealed_transactions,
+ &self.launch_profile,
+ )?);
let mined_signatures = block
.transactions
.iter()
.map(|tx| tx.signature().to_string())
.collect::<BTreeSet<_>>();
+ let included_blinded = block
+ .blinded_transactions
+ .iter()
+ .map(|transaction| transaction.commitment.clone())
+ .collect::<BTreeSet<_>>();
+ let revealed_blinded = block
+ .blinded_reveals
+ .iter()
+ .map(|reveal| reveal.commitment.clone())
+ .collect::<BTreeSet<_>>();
self.utxos = utxos;
self.tickets = tickets;
self.chain.push(block);
+ let new_height = self.height();
+ let tip_miner = self.tip().miner.clone();
+ let tip_blinded_transactions = self.tip().blinded_transactions.clone();
+ self.active_blinded.retain(|commitment, active| {
+ !revealed_blinded.contains(commitment)
+ && new_height < active.transaction.expires_at_height
+ });
+ for transaction in tip_blinded_transactions {
+ self.active_blinded.insert(
+ transaction.commitment.clone(),
+ ActiveBlindedTransaction {
+ transaction,
+ included_height: new_height,
+ included_by: tip_miner.clone(),
+ },
+ );
+ }
let available = self.utxos.clone();
let pending = std::mem::take(&mut self.pending);
self.pending = pending
@@ -2109,6 +2456,23 @@ impl Ledger {
&& self.validate_transaction_terms(tx).is_ok()
})
.collect();
+ let pending_blinded = std::mem::take(&mut self.pending_blinded);
+ self.pending_blinded = pending_blinded
+ .into_iter()
+ .filter(|transaction| {
+ !included_blinded.contains(&transaction.commitment)
+ && new_height < transaction.expires_at_height
+ && self.validate_blinded_transaction(transaction).is_ok()
+ })
+ .collect();
+ let pending_reveals = std::mem::take(&mut self.pending_reveals);
+ self.pending_reveals = pending_reveals
+ .into_iter()
+ .filter(|reveal| {
+ !revealed_blinded.contains(&reveal.commitment)
+ && self.pending_reveal_transaction(reveal).is_ok()
+ })
+ .collect();
self.promote_orphan_transactions()?;
self.vdf_rounds = self.next_vdf_rounds_after_tip();
Ok(())
@@ -2172,10 +2536,21 @@ impl Ledger {
if block.transactions.len() > self.launch_profile.max_block_transactions {
bail!("block has too many transactions");
}
+ let block_item_count = block.transactions.len()
+ + block.blinded_transactions.len()
+ + block.blinded_reveals.len();
+ if block_item_count > self.launch_profile.max_block_transactions {
+ bail!("block has too many transaction items");
+ }
if block.serialized_size_bytes()? > self.launch_profile.max_block_bytes {
bail!("block exceeds max block size");
}
- ensure_block_has_burn(&block.transactions)?;
+ ensure_block_has_burn_or_blinded(&BlockSelection {
+ transactions: block.transactions.clone(),
+ blinded_transactions: block.blinded_transactions.clone(),
+ blinded_reveals: block.blinded_reveals.clone(),
+ })?;
+ validate_block_blinded_items(block, self)?;
match block.finalizer_mode {
FinalizerMode::Ticket => {
let selected_ticket = self
@@ -2289,21 +2664,25 @@ impl Ledger {
valid
}
- fn select_block_transactions(&self) -> Result<Vec<Transaction>> {
+ fn select_block_transactions(&self) -> Result<BlockSelection> {
self.select_block_transactions_with_required_burn_owner(None)
}
- fn select_recovery_block_transactions(&self, miner: &str) -> Result<Vec<Transaction>> {
+ fn select_recovery_block_transactions(&self, miner: &str) -> Result<BlockSelection> {
self.select_block_transactions_with_required_burn_owner(Some(miner))
}
fn select_block_transactions_with_required_burn_owner(
&self,
required_burn_owner: Option<&str>,
- ) -> Result<Vec<Transaction>> {
+ ) -> Result<BlockSelection> {
let mut utxos = self.utxos.clone();
let mut remaining = self.valid_pending_transactions();
+ let mut remaining_blinded = self.valid_pending_blinded_transactions();
+ let mut remaining_reveals = self.valid_pending_blinded_reveals();
let mut selected = Vec::new();
+ let mut selected_blinded = Vec::new();
+ let mut selected_reveals = Vec::new();
let needs_first_burn = !selected.iter().any(Transaction::is_burn);
let needs_owner_burn = required_burn_owner.is_some_and(|owner| {
@@ -2319,9 +2698,15 @@ impl Ledger {
};
if let Some(index) = first_burn_index {
let tx = remaining.remove(index);
- let mut candidate = selected.clone();
- candidate.push(tx.clone());
- if estimated_block_size_bytes(&candidate)? <= self.launch_profile.max_block_bytes {
+ let mut candidate = BlockSelection {
+ transactions: selected.clone(),
+ blinded_transactions: selected_blinded.clone(),
+ blinded_reveals: selected_reveals.clone(),
+ };
+ candidate.transactions.push(tx.clone());
+ if estimated_block_selection_size_bytes(&candidate)?
+ <= self.launch_profile.max_block_bytes
+ {
apply_transaction(&tx, &mut utxos)?;
selected.push(tx);
}
@@ -2329,18 +2714,73 @@ impl Ledger {
}
while selected.len() < self.launch_profile.max_block_transactions {
- let Some(index) = best_selectable_transaction_index(&remaining, &utxos, None) else {
+ let selected_count = selected.len() + selected_blinded.len() + selected_reveals.len();
+ if selected_count >= self.launch_profile.max_block_transactions {
+ break;
+ }
+
+ let best_plain = best_selectable_transaction_index(&remaining, &utxos, None)
+ .map(|index| SelectableItem::Plain(index, fee_rate_key(&remaining[index])));
+ let best_blinded = best_selectable_blinded_index(&remaining_blinded).map(|index| {
+ SelectableItem::Blinded(index, blinded_fee_rate_key(&remaining_blinded[index]))
+ });
+ let best_reveal =
+ best_selectable_reveal_index(&remaining_reveals).map(SelectableItem::Reveal);
+ let Some(item) = best_selectable_item(best_plain, best_blinded, best_reveal) else {
break;
};
- let tx = remaining.remove(index);
- let mut candidate = selected.clone();
- candidate.push(tx.clone());
- if estimated_block_size_bytes(&candidate)? <= self.launch_profile.max_block_bytes {
- apply_transaction(&tx, &mut utxos)?;
- selected.push(tx);
+
+ match item {
+ SelectableItem::Plain(index, _) => {
+ let tx = remaining.remove(index);
+ let mut candidate = BlockSelection {
+ transactions: selected.clone(),
+ blinded_transactions: selected_blinded.clone(),
+ blinded_reveals: selected_reveals.clone(),
+ };
+ candidate.transactions.push(tx.clone());
+ if estimated_block_selection_size_bytes(&candidate)?
+ <= self.launch_profile.max_block_bytes
+ {
+ apply_transaction(&tx, &mut utxos)?;
+ selected.push(tx);
+ }
+ }
+ SelectableItem::Blinded(index, _) => {
+ let transaction = remaining_blinded.remove(index);
+ let mut candidate = BlockSelection {
+ transactions: selected.clone(),
+ blinded_transactions: selected_blinded.clone(),
+ blinded_reveals: selected_reveals.clone(),
+ };
+ candidate.blinded_transactions.push(transaction.clone());
+ if estimated_block_selection_size_bytes(&candidate)?
+ <= self.launch_profile.max_block_bytes
+ {
+ selected_blinded.push(transaction);
+ }
+ }
+ SelectableItem::Reveal(index) => {
+ let reveal = remaining_reveals.remove(index);
+ let mut candidate = BlockSelection {
+ transactions: selected.clone(),
+ blinded_transactions: selected_blinded.clone(),
+ blinded_reveals: selected_reveals.clone(),
+ };
+ candidate.blinded_reveals.push(reveal.clone());
+ if estimated_block_selection_size_bytes(&candidate)?
+ <= self.launch_profile.max_block_bytes
+ {
+ selected_reveals.push(reveal);
+ }
+ }
}
}
- Ok(selected)
+ Ok(BlockSelection {
+ transactions: selected,
+ blinded_transactions: selected_blinded,
+ blinded_reveals: selected_reveals,
+ })
}
fn select_inputs(
@@ -2496,6 +2936,83 @@ impl Ledger {
Ok(())
}
+ fn validate_blinded_transaction(&self, transaction: &BlindedTransaction) -> Result<()> {
+ validate_hash(&transaction.commitment, "blinded transaction commitment")?;
+ validate_hash(
+ &transaction.payload_hash,
+ "blinded transaction payload hash",
+ )?;
+ decode_hex_array::<BLINDED_NONCE_BYTES>(&transaction.nonce)
+ .context("invalid blinded transaction nonce")?;
+ let ciphertext = decode_hex(&transaction.ciphertext)
+ .context("invalid blinded transaction ciphertext")?;
+ if ciphertext.is_empty() {
+ bail!("blinded transaction ciphertext is empty");
+ }
+ if ciphertext.len() != transaction.encrypted_size as usize {
+ bail!("blinded transaction encrypted size is invalid");
+ }
+ if transaction.expires_at_height <= self.height() {
+ bail!("blinded transaction is expired");
+ }
+ let expected = blinded_transaction_commitment(transaction)?;
+ if transaction.commitment != expected {
+ bail!("blinded transaction commitment is invalid");
+ }
+ Ok(())
+ }
+
+ fn validate_blinded_reveal_terms(&self, reveal: &BlindedReveal) -> Result<()> {
+ validate_hash(&reveal.commitment, "blinded reveal commitment")?;
+ decode_hex_array::<BLINDED_KEY_BYTES>(&reveal.key).context("invalid blinded reveal key")?;
+ Ok(())
+ }
+
+ fn valid_pending_blinded_transactions(&self) -> Vec<BlindedTransaction> {
+ let next_height = self.height().saturating_add(1);
+ self.pending_blinded
+ .iter()
+ .filter(|transaction| {
+ transaction.expires_at_height > next_height
+ && self.validate_blinded_transaction(transaction).is_ok()
+ })
+ .cloned()
+ .collect()
+ }
+
+ fn valid_pending_blinded_reveals(&self) -> Vec<BlindedReveal> {
+ self.pending_reveals
+ .iter()
+ .filter(|reveal| self.pending_reveal_transaction(reveal).is_ok())
+ .cloned()
+ .collect()
+ }
+
+ fn pending_reveal_transaction(&self, reveal: &BlindedReveal) -> Result<Transaction> {
+ self.validate_blinded_reveal_terms(reveal)?;
+ let active = self
+ .active_blinded
+ .get(&reveal.commitment)
+ .context("blinded reveal does not reference an active blinded transaction")?;
+ self.decrypt_active_blinded(active, reveal)
+ }
+
+ fn decrypt_active_blinded(
+ &self,
+ active: &ActiveBlindedTransaction,
+ reveal: &BlindedReveal,
+ ) -> Result<Transaction> {
+ if self.height() >= active.transaction.expires_at_height {
+ bail!("blinded transaction reveal is expired");
+ }
+ let transaction = decrypt_blinded_transaction(&active.transaction, reveal)?;
+ if transaction.fee() != active.transaction.fee {
+ bail!("blinded transaction reveal fee does not match envelope");
+ }
+ self.validate_transaction_terms(&transaction)?;
+ Ok(transaction)
+ }
+
fn utxos_after_valid_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> {
let mut utxos = self.utxos.clone();
for pending in self.valid_pending_transactions() {
@@ -2681,11 +3198,19 @@ fn base_vdf_rounds_for_finalizer_rank(vdf_rounds: u64, rank: u32) -> u64 {
}
fn tickets_created_by_block(block: &Block, profile: &LaunchProfile) -> Result<Vec<BurnTicket>> {
+ tickets_created_by_transactions(block.height, &block.transactions, profile)
+}
+
+fn tickets_created_by_transactions(
+ block_height: u64,
+ transactions: &[Transaction],
+ profile: &LaunchProfile,
+) -> Result<Vec<BurnTicket>> {
if profile.ticket_expiry_window_heights == 0 {
bail!("ticket expiry window must be at least one height");
}
let mut tickets = Vec::new();
- for tx in &block.transactions {
+ for tx in transactions {
let Transaction::Burn {
inputs,
amount,
@@ -2701,13 +3226,12 @@ fn tickets_created_by_block(block: &Block, profile: &LaunchProfile) -> Result<Ve
if *amount == 0 {
continue;
}
- let target_height = block
- .height
+ let target_height = block_height
.checked_add(profile.ticket_maturity_delay_heights)
- .with_context(|| format!("ticket target height overflow at block {}", block.height))?;
+ .with_context(|| format!("ticket target height overflow at block {block_height}"))?;
let eligible_until_height = target_height
.checked_add(profile.ticket_expiry_window_heights - 1)
- .with_context(|| format!("ticket expiry height overflow at block {}", block.height))?;
+ .with_context(|| format!("ticket expiry height overflow at block {block_height}"))?;
tickets.push(BurnTicket {
id: signature.clone(),
owner,
@@ -2881,6 +3405,16 @@ fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> {
Ok(())
}
+fn ensure_block_has_burn_or_blinded(selection: &BlockSelection) -> Result<()> {
+ if !selection.transactions.iter().any(Transaction::is_burn)
+ && selection.blinded_transactions.is_empty()
+ && selection.blinded_reveals.is_empty()
+ {
+ bail!("block must include at least one burn transaction or blinded transaction");
+ }
+ Ok(())
+}
+
fn ensure_block_has_burn_from(transactions: &[Transaction], miner: &str) -> Result<()> {
if !transactions
.iter()
@@ -2913,6 +3447,67 @@ fn fee_rate_key(transaction: &Transaction) -> u128 {
u128::from(transaction.fee()) * 1_000_000 / size as u128
}
+fn blinded_fee_rate_key(transaction: &BlindedTransaction) -> u128 {
+ let size = transaction.fee_rate_size_bytes();
+ if size == 0 {
+ return 0;
+ }
+ u128::from(transaction.fee) * 1_000_000 / size as u128
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum SelectableItem {
+ Plain(usize, u128),
+ Blinded(usize, u128),
+ Reveal(usize),
+}
+
+fn best_selectable_item(
+ plain: Option<SelectableItem>,
+ blinded: Option<SelectableItem>,
+ reveal: Option<SelectableItem>,
+) -> Option<SelectableItem> {
+ if reveal.is_some() {
+ return reveal;
+ }
+ match (plain, blinded) {
+ (
+ Some(SelectableItem::Plain(_, plain_rate)),
+ Some(SelectableItem::Blinded(_, blind_rate)),
+ ) => {
+ if blind_rate > plain_rate {
+ blinded
+ } else {
+ plain
+ }
+ }
+ (Some(item), None) | (None, Some(item)) => Some(item),
+ (None, None) => None,
+ _ => None,
+ }
+}
+
+fn best_selectable_blinded_index(transactions: &[BlindedTransaction]) -> Option<usize> {
+ transactions
+ .iter()
+ .enumerate()
+ .max_by(|(_, left), (_, right)| {
+ blinded_fee_rate_key(left)
+ .cmp(&blinded_fee_rate_key(right))
+ .then_with(|| left.fee.cmp(&right.fee))
+ .then_with(|| right.commitment.cmp(&left.commitment))
+ })
+ .map(|(index, _)| index)
+}
+
+fn best_selectable_reveal_index(reveals: &[BlindedReveal]) -> Option<usize> {
+ reveals
+ .iter()
+ .enumerate()
+ .min_by(|(_, left), (_, right)| left.commitment.cmp(&right.commitment))
+ .map(|(index, _)| index)
+}
+
fn best_selectable_transaction_index(
transactions: &[Transaction],
utxos: &BTreeMap<OutPoint, TxOutput>,
@@ -3150,7 +3745,7 @@ fn compact_len(mut value: u128) -> usize {
bytes
}
-fn estimated_block_size_bytes(transactions: &[Transaction]) -> Result<usize> {
+fn estimated_block_selection_size_bytes(selection: &BlockSelection) -> Result<usize> {
let block = Block {
height: u64::MAX,
prev_hash: "f".repeat(64),
@@ -3166,7 +3761,9 @@ fn estimated_block_size_bytes(transactions: &[Transaction]) -> Result<usize> {
public_key: "f".repeat(64),
signature: "f".repeat(128),
}),
- transactions: transactions.to_vec(),
+ blinded_transactions: selection.blinded_transactions.clone(),
+ blinded_reveals: selection.blinded_reveals.clone(),
+ transactions: selection.transactions.clone(),
hash: "f".repeat(64),
};
block.serialized_size_bytes()
@@ -3283,6 +3880,139 @@ fn apply_transaction(
Ok(())
}
+fn validate_block_blinded_items(block: &Block, ledger: &Ledger) -> Result<()> {
+ let mut commitments = BTreeSet::new();
+ for transaction in &block.blinded_transactions {
+ if !commitments.insert(transaction.commitment.clone()) {
+ bail!("duplicate blinded transaction in block");
+ }
+ ledger.validate_blinded_transaction(transaction)?;
+ if transaction.expires_at_height <= block.height {
+ bail!("blinded transaction is expired for block height");
+ }
+ if ledger.active_blinded.contains_key(&transaction.commitment) {
+ bail!("blinded transaction is already active");
+ }
+ if ledger.chain.iter().any(|block| {
+ block
+ .blinded_transactions
+ .iter()
+ .any(|existing| existing.commitment == transaction.commitment)
+ }) {
+ bail!("blinded transaction is already on chain");
+ }
+ }
+
+ let mut reveals = BTreeSet::new();
+ for reveal in &block.blinded_reveals {
+ if !reveals.insert(reveal.commitment.clone()) {
+ bail!("duplicate blinded reveal in block");
+ }
+ if ledger.chain.iter().any(|block| {
+ block
+ .blinded_reveals
+ .iter()
+ .any(|existing| existing.commitment == reveal.commitment)
+ }) {
+ bail!("blinded reveal is already on chain");
+ }
+ ledger.pending_reveal_transaction(reveal)?;
+ }
+ Ok(())
+}
+
+fn decrypt_blinded_transaction(
+ transaction: &BlindedTransaction,
+ reveal: &BlindedReveal,
+) -> Result<Transaction> {
+ if reveal.commitment != transaction.commitment {
+ bail!("blinded reveal commitment does not match transaction");
+ }
+ let key =
+ decode_hex_array::<BLINDED_KEY_BYTES>(&reveal.key).context("invalid blinded reveal key")?;
+ let nonce = decode_hex_array::<BLINDED_NONCE_BYTES>(&transaction.nonce)
+ .context("invalid blinded transaction nonce")?;
+ let ciphertext =
+ decode_hex(&transaction.ciphertext).context("invalid blinded transaction ciphertext")?;
+ let plaintext = decrypt_blinded_payload(
+ &key,
+ &nonce,
+ transaction.fee,
+ transaction.expires_at_height,
+ &ciphertext,
+ )?;
+ if hex_hash(&plaintext) != transaction.payload_hash {
+ bail!("blinded transaction payload hash is invalid");
+ }
+ serde_json::from_slice(&plaintext).context("failed to decode blinded transaction payload")
+}
+
+fn encrypt_blinded_payload(
+ key: &[u8; BLINDED_KEY_BYTES],
+ nonce: &[u8; BLINDED_NONCE_BYTES],
+ fee: Amount,
+ expires_at_height: u64,
+ plaintext: &[u8],
+) -> Result<Vec<u8>> {
+ let cipher = ChaCha20Poly1305::new(key.into());
+ cipher
+ .encrypt(
+ Nonce::from_slice(nonce),
+ chacha20poly1305::aead::Payload {
+ msg: plaintext,
+ aad: blinded_payload_aad(fee, expires_at_height).as_bytes(),
+ },
+ )
+ .map_err(|_| anyhow!("failed to encrypt blinded transaction payload"))
+}
+
+fn decrypt_blinded_payload(
+ key: &[u8; BLINDED_KEY_BYTES],
+ nonce: &[u8; BLINDED_NONCE_BYTES],
+ fee: Amount,
+ expires_at_height: u64,
+ ciphertext: &[u8],
+) -> Result<Vec<u8>> {
+ let cipher = ChaCha20Poly1305::new(key.into());
+ cipher
+ .decrypt(
+ Nonce::from_slice(nonce),
+ chacha20poly1305::aead::Payload {
+ msg: ciphertext,
+ aad: blinded_payload_aad(fee, expires_at_height).as_bytes(),
+ },
+ )
+ .map_err(|_| anyhow!("failed to decrypt blinded transaction payload"))
+}
+
+fn blinded_payload_aad(fee: Amount, expires_at_height: u64) -> String {
+ format!("iuna-blinded-payload-v1:{fee}:{expires_at_height}")
+}
+
+fn blinded_transaction_commitment(transaction: &BlindedTransaction) -> Result<String> {
+ let mut without_commitment = transaction.clone();
+ without_commitment.commitment.clear();
+ Ok(hex_hash(without_commitment.canonical()))
+}
+
+fn credit_blinded_fee_output(
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+ active: &ActiveBlindedTransaction,
+ transaction: &Transaction,
+) -> Result<()> {
+ let fee = transaction.fee();
+ if fee == 0 {
+ return Ok(());
+ }
+ let output = TxOutput {
+ address: active.included_by.clone(),
+ amount: fee,
+ };
+ ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?;
+ utxos.insert(blinded_fee_outpoint(&active.transaction.commitment), output);
+ Ok(())
+}
+
fn fee_reward(transactions: &[Transaction]) -> Result<Amount> {
transactions.iter().try_fold(0_u64, |total, tx| {
total.checked_add(tx.fee()).context("block fees overflow")
@@ -3395,6 +4125,8 @@ fn build_genesis_block(
vdf_rounds: 0,
vdf_output,
leader_proof: None,
+ blinded_transactions: Vec::new(),
+ blinded_reveals: Vec::new(),
transactions,
hash: String::new(),
};
@@ -3463,6 +4195,13 @@ fn reward_outpoint(block_hash: &str) -> OutPoint {
}
}
+fn blinded_fee_outpoint(commitment: &str) -> OutPoint {
+ OutPoint {
+ txid: commitment.to_string(),
+ index: u32::MAX - 1,
+ }
+}
+
fn validate_genesis_block(block: &Block) -> Result<()> {
if block.height != 0 {
bail!("genesis block height must be 0");
@@ -3485,6 +4224,9 @@ fn validate_genesis_block(block: &Block) -> Result<()> {
if block.leader_proof.is_some() {
bail!("genesis block must not carry a leader proof");
}
+ if !block.blinded_transactions.is_empty() || !block.blinded_reveals.is_empty() {
+ bail!("genesis block must not carry blinded transactions");
+ }
if block.compute_hash() != block.hash {
bail!("genesis block hash is invalid");
}
@@ -3545,6 +4287,51 @@ pub fn verify_vdf(seed: &str, rounds: u64, solution: &str) -> bool {
verified == y
}
+pub fn revealed_blinded_transactions(
+ snapshot: &ChainSnapshot,
+) -> Result<Vec<RevealedBlindedTransaction>> {
+ let mut active = BTreeMap::<String, ActiveBlindedTransaction>::new();
+ let mut revealed = Vec::new();
+ for block in &snapshot.blocks {
+ for reveal in &block.blinded_reveals {
+ let active_transaction = active.get(&reveal.commitment).with_context(|| {
+ format!(
+ "block {} reveals unknown blinded transaction {}",
+ block.height, reveal.commitment
+ )
+ })?;
+ let transaction = decrypt_blinded_transaction(&active_transaction.transaction, reveal)?;
+ if transaction.fee() != active_transaction.transaction.fee {
+ bail!(
+ "block {} blinded reveal fee does not match envelope",
+ block.height
+ );
+ }
+ revealed.push(RevealedBlindedTransaction {
+ height: block.height,
+ commitment: reveal.commitment.clone(),
+ included_by: active_transaction.included_by.clone(),
+ transaction,
+ });
+ active.remove(&reveal.commitment);
+ }
+ active.retain(|_, active_transaction| {
+ block.height < active_transaction.transaction.expires_at_height
+ });
+ for transaction in &block.blinded_transactions {
+ active.insert(
+ transaction.commitment.clone(),
+ ActiveBlindedTransaction {
+ transaction: transaction.clone(),
+ included_height: block.height,
+ included_by: block.miner.clone(),
+ },
+ );
+ }
+ }
+ Ok(revealed)
+}
+
fn vdf_seed_element(seed: &str) -> u128 {
let digest = Sha256::digest(format!("iuna-vdf-seed:{seed}").as_bytes());
let mut bytes = [0_u8; 16];
@@ -3824,6 +4611,8 @@ mod tests {
vdf_rounds: 1,
vdf_output: String::new(),
leader_proof: None,
+ blinded_transactions: Vec::new(),
+ blinded_reveals: Vec::new(),
transactions: Vec::new(),
hash: String::new(),
}
@@ -3859,6 +4648,45 @@ mod tests {
.unwrap_or_else(|| panic!("missing wallet for address {address}"))
}
+ fn ledger_with_finalizers(
+ finalizers: &[Wallet],
+ extra_allocations: &[(&Wallet, Amount)],
+ ) -> Ledger {
+ let mut allocations = BTreeMap::new();
+ for wallet in finalizers {
+ allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
+ }
+ for (wallet, amount) in extra_allocations {
+ allocations.insert(wallet.address().to_string(), *amount);
+ }
+ Ledger::new_with_genesis_burns(
+ allocations,
+ finalizers
+ .iter()
+ .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA))
+ .collect(),
+ 1,
+ )
+ .unwrap()
+ }
+
+ fn mine_preverified_as_next_leader(
+ ledger: &mut Ledger,
+ wallets: &[Wallet],
+ timestamp_ms: u64,
+ ) -> Block {
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let wallet = wallet_for_address(wallets, &leader);
+ let prepared = ledger
+ .prepare_next_block(wallet.address(), timestamp_ms)
+ .unwrap();
+ let block = prepared.finish(wallet, "preverified-vdf".to_string());
+ ledger
+ .apply_preverified_block_at(block.clone(), u64::MAX)
+ .unwrap();
+ block
+ }
+
fn transfer_with_extra_zero_outputs(
ledger: &Ledger,
wallet: &Wallet,
@@ -4646,6 +5474,8 @@ mod tests {
public_key: "alice".to_string(),
signature: "signature".to_string(),
}),
+ blinded_transactions: Vec::new(),
+ blinded_reveals: Vec::new(),
transactions: Vec::new(),
});
@@ -4731,6 +5561,202 @@ mod tests {
}
#[test]
+ fn blinded_burn_commits_ciphertext_and_reveal_executes_later() {
+ let alice = Wallet::from_seed("blinded-burn-finalizer-alice");
+ let bob = Wallet::from_seed("blinded-burn-finalizer-bob");
+ let carol = Wallet::from_seed("blinded-burn-carol");
+ let finalizers = [alice.clone(), bob.clone()];
+ let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
+ let fee = 7;
+ let burn_amount = 3;
+ let before_carol = ledger.balance_of(carol.address());
+
+ let blinded = ledger
+ .build_blinded_burn(&carol, burn_amount, fee, ledger.height() + 4)
+ .unwrap();
+ assert!(!blinded.transaction.ciphertext.contains("burn"));
+ assert!(!blinded.transaction.ciphertext.contains(carol.address()));
+ ledger
+ .submit_blinded_transaction(blinded.transaction.clone())
+ .unwrap();
+
+ let commit_block = mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
+ let inclusion_finalizer = commit_block.miner.clone();
+ assert!(commit_block.transactions.is_empty());
+ assert_eq!(commit_block.blinded_transactions, vec![blinded.transaction]);
+ assert_eq!(commit_block.reward, 0);
+ let before_inclusion_finalizer = ledger.balance_of(&inclusion_finalizer);
+
+ ledger.submit_blinded_reveal(blinded.reveal).unwrap();
+ let reveal_block = mine_preverified_as_next_leader(&mut ledger, &finalizers, 2);
+
+ assert_eq!(reveal_block.blinded_reveals.len(), 1);
+ assert_eq!(
+ ledger.balance_of(carol.address()),
+ before_carol - burn_amount - fee
+ );
+ assert_eq!(
+ ledger.balance_of(&inclusion_finalizer),
+ before_inclusion_finalizer + fee
+ );
+ }
+
+ #[test]
+ fn blinded_reveal_with_wrong_key_is_rejected_in_block() {
+ let alice = Wallet::from_seed("blinded-wrong-key-finalizer-alice");
+ let bob = Wallet::from_seed("blinded-wrong-key-finalizer-bob");
+ let carol = Wallet::from_seed("blinded-wrong-key-carol");
+ let finalizers = [alice.clone(), bob.clone()];
+ let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
+ let blinded = ledger
+ .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
+ .unwrap();
+ ledger
+ .submit_blinded_transaction(blinded.transaction.clone())
+ .unwrap();
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
+
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let wallet = wallet_for_address(&finalizers, &leader);
+ let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap();
+ ledger.submit_transaction(filler_burn).unwrap();
+ let mut prepared = ledger
+ .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1)
+ .unwrap();
+ prepared.blinded_reveals.push(BlindedReveal {
+ commitment: blinded.transaction.commitment,
+ key: "00".repeat(BLINDED_KEY_BYTES),
+ });
+ let block = prepared.finish(wallet, "preverified-vdf".to_string());
+
+ let error = ledger
+ .apply_preverified_block_at(block, u64::MAX)
+ .unwrap_err();
+
+ assert!(format!("{error:#}").contains("failed to decrypt blinded transaction payload"));
+ }
+
+ #[test]
+ fn expired_blinded_reveal_is_not_selected() {
+ let alice = Wallet::from_seed("blinded-expire-finalizer-alice");
+ let bob = Wallet::from_seed("blinded-expire-finalizer-bob");
+ let carol = Wallet::from_seed("blinded-expire-carol");
+ let finalizers = [alice.clone(), bob.clone()];
+ let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
+ let blinded = ledger
+ .build_blinded_burn(&carol, 3, 7, ledger.height() + 2)
+ .unwrap();
+ ledger
+ .submit_blinded_transaction(blinded.transaction.clone())
+ .unwrap();
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
+
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let wallet = wallet_for_address(&finalizers, &leader);
+ let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap();
+ ledger.submit_transaction(filler_burn).unwrap();
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, 2);
+
+ ledger.submit_blinded_reveal(blinded.reveal).unwrap();
+ assert!(ledger.valid_pending_blinded_reveals().is_empty());
+ }
+
+ #[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");
+ let carol = Wallet::from_seed("blinded-next-expire-carol");
+ let finalizers = [alice.clone(), bob.clone()];
+ let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
+ let blinded = ledger
+ .build_blinded_burn(&carol, 3, 7, ledger.height() + 1)
+ .unwrap();
+ ledger
+ .submit_blinded_transaction(blinded.transaction)
+ .unwrap();
+
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let wallet = wallet_for_address(&finalizers, &leader);
+ let error = ledger.prepare_next_block(wallet.address(), 1).unwrap_err();
+
+ assert!(format!("{error:#}").contains("block must include at least one burn transaction"));
+ }
+
+ #[test]
+ fn revealed_blinded_transaction_cannot_be_included_again() {
+ let alice = Wallet::from_seed("blinded-duplicate-finalizer-alice");
+ let bob = Wallet::from_seed("blinded-duplicate-finalizer-bob");
+ let carol = Wallet::from_seed("blinded-duplicate-carol");
+ let finalizers = [alice.clone(), bob.clone()];
+ let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
+ let blinded = ledger
+ .build_blinded_burn(&carol, 3, 7, ledger.height() + 6)
+ .unwrap();
+ ledger
+ .submit_blinded_transaction(blinded.transaction.clone())
+ .unwrap();
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
+ ledger
+ .submit_blinded_reveal(blinded.reveal.clone())
+ .unwrap();
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, 2);
+
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let wallet = wallet_for_address(&finalizers, &leader);
+ let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap();
+ ledger.submit_transaction(filler_burn).unwrap();
+ let mut prepared = ledger
+ .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1)
+ .unwrap();
+ prepared
+ .blinded_transactions
+ .push(blinded.transaction.clone());
+ let block = prepared.finish(wallet, "preverified-vdf".to_string());
+
+ let error = ledger
+ .apply_preverified_block_at(block, u64::MAX)
+ .unwrap_err();
+
+ assert!(format!("{error:#}").contains("blinded transaction is already on chain"));
+ }
+
+ #[test]
+ fn abandoned_fork_blinded_transactions_return_to_mempool() {
+ let alice = Wallet::from_seed("blinded-reorg-finalizer-alice");
+ let bob = Wallet::from_seed("blinded-reorg-finalizer-bob");
+ let carol = Wallet::from_seed("blinded-reorg-carol");
+ let finalizers = [alice.clone(), bob.clone()];
+ let mut local = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
+ let mut remote = local.clone();
+ let blinded = local
+ .build_blinded_burn(&carol, 3, 7, local.height() + 8)
+ .unwrap();
+ local
+ .submit_blinded_transaction(blinded.transaction.clone())
+ .unwrap();
+ mine_preverified_as_next_leader(&mut local, &finalizers, 1);
+
+ for timestamp_ms in [1, 2] {
+ let leader = remote.expected_leader_for_next_block().unwrap();
+ let wallet = wallet_for_address(&finalizers, &leader);
+ let burn = remote.build_burn(wallet, 1, 0).unwrap();
+ remote.submit_transaction(burn).unwrap();
+ mine_preverified_as_next_leader(&mut remote, &finalizers, timestamp_ms);
+ }
+
+ assert!(
+ local
+ .extend_from_preverified_snapshot_at(remote.snapshot(), u64::MAX)
+ .unwrap()
+ );
+ assert!(local.has_blinded_transaction(&blinded.transaction.commitment));
+ assert_eq!(
+ local.pending_blinded_transactions(),
+ std::slice::from_ref(&blinded.transaction)
+ );
+ }
+
+ #[test]
fn block_selection_includes_mine_action_after_required_block_burn() {
let alice = Wallet::from_seed("mine-fixed-reward-select-alice");
let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -1696,7 +1696,7 @@ window.iunaApp = function iunaApp() {
},
blockBurned(block) {
- return block.transactions
+ return this.blockTransactions(block)
.filter((tx) => tx.kind === "burn")
.reduce((sum, tx) => sum + this.txAmount(tx), 0);
},
@@ -1704,7 +1704,7 @@ window.iunaApp = function iunaApp() {
blockTotalFees(block) {
const explicitTotal = block?.totalFees ?? block?.total_fees ?? block?.reward;
if (explicitTotal !== null && explicitTotal !== undefined) return Number(explicitTotal) || 0;
- return (block?.transactions || []).reduce((sum, tx) => sum + Number(tx.fee || 0), 0);
+ return this.blockTransactions(block).reduce((sum, tx) => sum + Number(tx.fee || 0), 0);
},
recentBlockFeeAverage(count) {
@@ -1714,15 +1714,22 @@ window.iunaApp = function iunaApp() {
},
blockBurnCount(block) {
- return block.transactions.filter((tx) => tx.kind === "burn").length;
+ return this.blockTransactions(block).filter((tx) => tx.kind === "burn").length;
},
blockTransferCount(block) {
- return block.transactions.filter((tx) => tx.kind === "transfer").length;
+ return this.blockTransactions(block).filter((tx) => tx.kind === "transfer").length;
},
blockMineCount(block) {
- return block.transactions.filter((tx) => tx.kind === "mine").length;
+ return this.blockTransactions(block).filter((tx) => tx.kind === "mine").length;
+ },
+
+ blockTransactions(block) {
+ return [
+ ...(block?.transactions || []),
+ ...(block?.revealedTransactions || block?.revealed_transactions || []),
+ ];
},
burnCountLabel(block) {