commit 53ad2392b94be3b9d1cfc784abdd731499f7e433
parent 83d1053d89773920e9c3820fd586dc0d6d7aaad5
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 5 Aug 2026 14:45:09 +0200
Refine blinded mempool protocol
Diffstat:
12 files changed, 1188 insertions(+), 124 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "iuna"
-version = "0.2.21"
+version = "0.2.22"
dependencies = [
"anyhow",
"axum",
diff --git a/Cargo.toml b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "iuna"
-version = "0.2.21"
+version = "0.2.22"
edition = "2024"
license = "Apache-2.0"
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -50,7 +50,7 @@ Every normal block must include at least one plaintext burn. A blinded transacti
This mandatory burn is a liveness rule for the ticket pool, not a fairness rule for ticket distribution. It guarantees that normal block production keeps creating future tickets. Fairness against self-serving finalizers comes from blinded third-party burns.
-Wallet-created transactions are not gossiped as plaintext. Their fees are paid when the blinded payload is revealed and executed: `35%` goes to the finalizer that originally committed the envelope, `35%` goes to the reveal-block finalizer, and `10%` goes to each included signed reveal-list maker. Missing reveal-list shares and rounding dust are burned. The locally produced plaintext burn required for block liveness is part of the block reward like other plaintext block items.
+Wallet-created transfers and burns are not gossiped as plaintext. Their blinded envelopes expose and lock UTXO inputs before reveal, so declared fees are backed by spendable coins. Mine actions are public mempool items, because they do not reveal burn or transfer intent and must be possible without owning coins. When a blinded payload is revealed and executed, `35%` of its fee goes to the finalizer that originally committed the envelope, `35%` goes to the reveal-block finalizer, and `10%` goes to each included signed reveal-list maker. Missing reveal-list shares and rounding dust are burned. The locally produced plaintext burn required for block liveness is part of the block reward like other plaintext block items.
## VDF Timing
@@ -119,21 +119,24 @@ 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?
-Normal mempool traffic uses blinded transaction content. A wallet encrypts a normal transaction payload and gossips a `BlindedTransaction` envelope. The plaintext payload is not exposed before reveal. The envelope exposes only:
+The blinded mempool protects transfers and third-party burns by making them indistinguishable before inclusion. It does not try to hide mine actions. Mine actions are public because they do not reveal burn intent, do not spend existing coins, and must remain available to participants with no IUNA balance.
+
+Transfer and burn mempool traffic uses blinded transaction content. A wallet encrypts a transfer or burn payload and gossips a `BlindedTransaction` envelope. The plaintext payload is not exposed before reveal. The envelope exposes only:
- a commitment hash;
+- the visible UTXO inputs that lock the fee and transaction spend;
- the declared fee;
- encrypted payload size;
- expiry height;
- nonce, ciphertext, and plaintext payload hash.
-The finalizer can rank the envelope by fee per visible envelope byte, but cannot see whether the encrypted payload is a transfer or a burn before committing it to a block.
+The visible inputs are signed for the blinded envelope itself and are not repeated inside the encrypted payload. The encrypted payload contains only the hidden transfer outputs or burn amount/change plus the transaction signature. When an envelope is included in a block, the visible inputs are locked immediately and cannot be spent by other pending transactions. The finalizer can rank the envelope by fee per visible envelope byte, but cannot see whether the encrypted payload is a transfer or a burn before committing it to a block. Mine actions are public and are not valid inside blinded envelopes.
Reveal is a later step. A `BlindedReveal` carries only the commitment and decryption key. Reveals are not included as loose block items. They are carried in signed reveal bundles.
For each next block height, nodes compute a reveal committee from the burn leader ranking. The last three ranked eligible tickets form the three reveal-bundle slots. A committee member can sign one bundle for its slot, height, and parent hash. A bundle is at most `10,000` bytes and lists valid pending reveals ordered by visible fee rate. Empty bundles are not gossiped.
-A block has an envelope section and one compact reveal-bundle section. The envelope section contains the finalizer's plaintext burn, other plaintext block items, and blinded transaction envelopes.
+A block has an envelope section and one compact reveal-bundle section. The envelope section contains the finalizer's plaintext burn, public mine actions, and blinded transaction envelopes.
The compact reveal-bundle section stores:
@@ -151,11 +154,11 @@ The block VDF seed is bound to the reveal bundle hashes:
If a slot has no included bundle, it contributes a fixed default hash for that slot. This means the finalizer must choose the reveal-bundle set before doing the VDF work. A finalizer can still claim that a bundle arrived too late, but it cannot secretly swap or remove a timely bundle after computing the VDF without changing the seed.
-When a valid bundled reveal executes, 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 once. If the reveal bitmask says multiple committee bundles contained the same reveal, the reveal is still executed only once. If the decrypted transaction is a burn, it creates burn tickets at the reveal height, not the earlier envelope-commit height.
+When a valid bundled reveal executes, nodes decrypt the earlier payload, check the commitment and payload hash, and decode the transfer or burn. The decrypted transaction inputs must match the visible inputs locked by the envelope, and the transaction executes against that locked value. If the reveal bitmask says multiple committee bundles contained the same reveal, the reveal is still executed only once. If the decrypted transaction is a burn, it creates burn tickets at the reveal height, not the earlier envelope-commit height.
Fees are paid without inflating the reveal block reward. The decrypted transaction must pay the same fee declared by the blinded envelope. `35%` goes to the envelope committer, `35%` goes to the reveal-block finalizer, and `10%` goes to each included signed reveal-list maker. Missing reveal-list shares and rounding dust are burned instead of redistributed.
-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`. The expiry height must be within `20` blocks of the node's current chain height when the envelope is accepted or selected. Expired envelopes and reveals are dropped from local selection.
+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`. The expiry height must be within `20` blocks of the node's current chain height when the envelope is accepted or selected. If an envelope expires unrevealed, its declared fee is burned and any remaining locked value returns as deterministic change to the owner of the first visible input. Expired local 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.
@@ -164,11 +167,12 @@ This does not make censorship impossible. A finalizer can still ignore all blind
The P2P mempool gossips only:
- blinded transaction envelopes;
+- public mine actions;
- blinded reveal keys;
- signed reveal bundles;
- block inventory and blocks.
-It does not gossip plaintext transfers, burns, or mine actions. Wallet-created transfers, burns, and mine actions enter the network as blinded envelopes first, and are only decoded after a reveal. The one plaintext burn required for every normal block is produced locally by the finalizer and appears in the block itself.
+It does not gossip plaintext transfers or burns. Wallet-created transfers and burns enter the network as blinded envelopes first, and are only decoded after a reveal. Mine actions are gossiped as public transactions. The one plaintext burn required for every normal block is produced locally by the finalizer and appears in the block itself.
## Block Selection
@@ -177,7 +181,7 @@ When a node builds a block, it selects transactions in this order:
1. Collect valid signed reveal bundles for the next height.
2. Ensure the envelope has at least one plaintext burn from local block construction.
3. For recovery blocks, ensure at least one plaintext burn is from the recovery finalizer.
-4. Fill remaining envelope space with valid blinded transaction envelopes ordered by fee rate.
+4. Fill remaining envelope space with valid public mine actions and blinded transaction envelopes ordered by fee rate.
5. Bind the VDF seed to the three reveal-bundle slot hashes, using default hashes for missing slots.
Blocks are bounded by transaction count and serialized byte size. The devnet maximum block size is `100,000` bytes.
diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs
@@ -473,6 +473,7 @@ fn encode_blinded_transaction(
transaction: &BlindedTransaction,
) -> Result<()> {
writer.hex(&transaction.commitment)?;
+ encode_inputs(writer, &transaction.inputs)?;
writer.varint(transaction.fee);
writer.varint(u64::from(transaction.encrypted_size));
writer.varint(transaction.expires_at_height);
@@ -485,6 +486,7 @@ fn encode_blinded_transaction(
fn decode_blinded_transaction(reader: &mut CompactReader<'_>) -> Result<BlindedTransaction> {
Ok(BlindedTransaction {
commitment: reader.hex()?,
+ inputs: decode_inputs(reader)?,
fee: reader.varint()?,
encrypted_size: reader.u32()?,
expires_at_height: reader.varint()?,
@@ -903,6 +905,7 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
let mut total_burned_amount = 0_u64;
let mut rows = Vec::with_capacity(snapshot.blocks.len());
let mut previous_timestamp_ms = None;
+ let mut active_blinded = std::collections::BTreeMap::<String, BlindedTransaction>::new();
for block in &snapshot.blocks {
let revealed_transactions = revealed.get(&block.height).cloned().unwrap_or_default();
@@ -930,7 +933,6 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
mine_count += 1;
mine_issued_amount = mine_issued_amount
.checked_add(MINE_REWARD)
- .and_then(|amount| amount.checked_add(transaction.fee()))
.context("block metric mine issuance overflow")?;
}
}
@@ -941,11 +943,15 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
.checked_add(transaction.fee())
.context("block metric fees overflow")?;
let committer_fee = blinded_fee_share(transaction.fee(), BLINDED_COMMITTER_FEE_BPS);
- let reveal_finalizer_fee =
- blinded_fee_share(transaction.fee(), BLINDED_REVEAL_FINALIZER_FEE_BPS);
+ let included_reveal_bundle_count = block.included_reveal_bundle_count();
+ let reveal_finalizer_fee = if included_reveal_bundle_count == 0 {
+ 0
+ } else {
+ blinded_fee_share(transaction.fee(), BLINDED_REVEAL_FINALIZER_FEE_BPS)
+ };
let reveal_bundle_signer_fees =
blinded_fee_share(transaction.fee(), BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS)
- .saturating_mul(block.included_reveal_bundle_count() as u64);
+ .saturating_mul(included_reveal_bundle_count as u64);
let distributed_fee = committer_fee
.saturating_add(reveal_finalizer_fee)
.saturating_add(reveal_bundle_signer_fees);
@@ -969,6 +975,41 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
}
}
}
+ let revealed_commitments = block
+ .all_blinded_reveals()
+ .into_iter()
+ .map(|reveal| reveal.commitment.clone())
+ .collect::<std::collections::BTreeSet<_>>();
+ let mut expired_blinded_fee_values = Vec::new();
+ active_blinded.retain(|commitment, transaction| {
+ if revealed_commitments.contains(commitment) {
+ return false;
+ }
+ if block.height >= transaction.expires_at_height {
+ if !transaction.inputs.is_empty() {
+ expired_blinded_fee_values.push(transaction.fee);
+ }
+ return false;
+ }
+ true
+ });
+ let expired_blinded_fees =
+ expired_blinded_fee_values
+ .into_iter()
+ .try_fold(0_u64, |total, fee| {
+ total
+ .checked_add(fee)
+ .context("block metric expiry fees overflow")
+ })?;
+ fees_amount = fees_amount
+ .checked_add(expired_blinded_fees)
+ .context("block metric expiry fees overflow")?;
+ burned_fee_amount = burned_fee_amount
+ .checked_add(expired_blinded_fees)
+ .context("block metric expired burned fees overflow")?;
+ for transaction in &block.blinded_transactions {
+ active_blinded.insert(transaction.commitment.clone(), transaction.clone());
+ }
total_burned_amount = total_burned_amount
.checked_add(burned_amount)
.and_then(|amount| amount.checked_add(burned_fee_amount))
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -829,7 +829,11 @@ async fn api_mempool(
.iter()
.map(|tx| ui_transaction(tx, &outputs))
.collect::<Vec<_>>();
- items.extend(pending_blinded.iter().map(ui_blinded_transaction));
+ items.extend(
+ pending_blinded
+ .iter()
+ .map(|transaction| ui_blinded_transaction(transaction, &outputs)),
+ );
items.extend(pending_reveals.iter().map(ui_blinded_reveal));
Json(page_items(items, query))
}
@@ -1824,7 +1828,7 @@ fn ui_block(
block
.blinded_transactions
.iter()
- .map(ui_blinded_transaction),
+ .map(|transaction| ui_blinded_transaction(transaction, outputs)),
);
transactions.extend(
revealed_transactions
@@ -1969,14 +1973,21 @@ fn ui_transaction(
}
}
-fn ui_blinded_transaction(transaction: &BlindedTransaction) -> UiTransaction {
+fn ui_blinded_transaction(
+ transaction: &BlindedTransaction,
+ outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>,
+) -> UiTransaction {
UiTransaction {
kind: "blinded",
- from: "encrypted".to_string(),
+ from: transaction
+ .inputs
+ .first()
+ .map(|input| input.owner.clone())
+ .unwrap_or_else(|| "encrypted".to_string()),
to: None,
amount: 0,
fee: transaction.fee,
- inputs: Vec::new(),
+ inputs: ui_inputs(&transaction.inputs, outputs_by_outpoint),
outputs: Vec::new(),
change: Vec::new(),
signature: transaction.commitment.clone(),
@@ -2078,6 +2089,12 @@ fn known_output_index(
.iter()
.map(|block| (block.height, block))
.collect::<BTreeMap<_, _>>();
+ let blinded_by_commitment = snapshot
+ .blocks
+ .iter()
+ .flat_map(|block| block.blinded_transactions.iter())
+ .map(|transaction| (transaction.commitment.clone(), transaction.clone()))
+ .collect::<BTreeMap<_, _>>();
for block in &snapshot.blocks {
for transaction in &block.transactions {
index_transaction_outputs(&mut outputs, transaction);
@@ -2095,6 +2112,11 @@ fn known_output_index(
for revealed in revealed {
index_transaction_outputs(&mut outputs, &revealed.transaction);
let fee = revealed.transaction.fee();
+ if matches!(revealed.transaction, Transaction::Mine { .. }) {
+ if let Some(commit) = blinded_by_commitment.get(&revealed.commitment) {
+ index_blinded_collateral_change(&mut outputs, commit, fee);
+ }
+ }
if fee > 0 {
let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS);
if committer_fee > 0 {
@@ -2107,7 +2129,11 @@ fn known_output_index(
);
}
if let Some(block) = blocks_by_height.get(&revealed.height) {
- let reveal_finalizer_fee = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS);
+ let reveal_finalizer_fee = if block.included_reveal_bundle_count() == 0 {
+ 0
+ } else {
+ blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS)
+ };
if reveal_finalizer_fee > 0 {
outputs.insert(
blinded_executor_fee_outpoint(&revealed.commitment),
@@ -2136,12 +2162,92 @@ fn known_output_index(
}
}
}
+ index_expired_blinded_outputs(&mut outputs, snapshot);
for transaction in pending {
index_transaction_outputs(&mut outputs, transaction);
}
outputs
}
+fn index_blinded_collateral_change(
+ outputs: &mut BTreeMap<OutPoint, TxOutput>,
+ transaction: &BlindedTransaction,
+ fee: Amount,
+) {
+ let Some(first_input) = transaction.inputs.first() else {
+ return;
+ };
+ let locked_total = transaction.inputs.iter().fold(0_u64, |total, input| {
+ total.saturating_add(
+ outputs
+ .get(&input.outpoint)
+ .map(|output| output.amount)
+ .unwrap_or_default(),
+ )
+ });
+ if fee >= locked_total {
+ return;
+ }
+ outputs.insert(
+ blinded_expiry_change_outpoint(&transaction.commitment),
+ TxOutput {
+ address: first_input.owner.clone(),
+ amount: locked_total - fee,
+ },
+ );
+}
+
+fn index_expired_blinded_outputs(
+ outputs: &mut BTreeMap<OutPoint, TxOutput>,
+ snapshot: &ChainSnapshot,
+) {
+ let mut active = BTreeMap::<String, (BlindedTransaction, Amount)>::new();
+ for block in &snapshot.blocks {
+ let revealed = block
+ .all_blinded_reveals()
+ .into_iter()
+ .map(|reveal| reveal.commitment.clone())
+ .collect::<BTreeSet<_>>();
+ active.retain(|commitment, (transaction, locked_total)| {
+ if revealed.contains(commitment) {
+ return false;
+ }
+ if block.height >= transaction.expires_at_height {
+ if let Some(first_input) = transaction.inputs.first() {
+ if transaction.fee <= *locked_total {
+ let change = *locked_total - transaction.fee;
+ if change > 0 {
+ outputs.insert(
+ blinded_expiry_change_outpoint(commitment),
+ TxOutput {
+ address: first_input.owner.clone(),
+ amount: change,
+ },
+ );
+ }
+ }
+ }
+ return false;
+ }
+ true
+ });
+ for transaction in &block.blinded_transactions {
+ let locked_total = transaction.inputs.iter().fold(0_u64, |total, input| {
+ total.saturating_add(
+ outputs
+ .get(&input.outpoint)
+ .map(|output| output.amount)
+ .unwrap_or_default(),
+ )
+ });
+ active.insert(
+ transaction.commitment.clone(),
+ (transaction.clone(), locked_total),
+ );
+ }
+ }
+}
+
fn index_transaction_outputs(
outputs: &mut BTreeMap<OutPoint, TxOutput>,
transaction: &Transaction,
@@ -2200,6 +2306,13 @@ fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutP
}
}
+fn blinded_expiry_change_outpoint(commitment: &str) -> OutPoint {
+ OutPoint {
+ txid: commitment.to_string(),
+ index: 0,
+ }
+}
+
fn blinded_fee_share(fee: Amount, bps: u64) -> Amount {
((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount
}
@@ -3177,7 +3290,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/iuna-ui.js?v=82"></script>
+ <script defer src="/assets/iuna-ui.js?v=84"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak>
@@ -3319,7 +3432,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<span class="pill" :class="tx.kind" x-text="tx.direction"></span>
<div class="wallet-tx-main">
<div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(tx.amount)"></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">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div>
<div class="tx-field"><span class="tx-label">Status</span><span class="tx-value text" x-text="txTitle(tx)"></span></div>
<div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(tx.from)"></code></div>
<div class="tx-field" x-show="tx.to"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(tx.to)"></code></div>
@@ -3555,6 +3668,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="block-meta">
<span x-text="burnCountLabel(block)"></span>
<span x-text="transferCountLabel(block)"></span>
+ <span x-text="commitCountLabel(block)"></span>
<span x-text="mineCountLabel(block)"></span>
</div>
<div class="block-miner" x-text="blockFinalizerLabel(block)"></div>
@@ -3600,7 +3714,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })">
<span class="pill" :class="txPillClass(tx)" x-text="txPillLabel(tx)"></span>
<div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><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">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div>
<div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><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" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div>
@@ -3620,7 +3734,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })">
<span class="pill" :class="txPillClass(tx)" x-text="txPillLabel(tx)"></span>
<div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><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">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div>
<div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><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" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div>
@@ -3644,7 +3758,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="mempool-item" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Mempool' })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Mempool' })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Mempool' })">
<span class="pill" :class="tx.kind" x-text="tx.kind"></span>
<div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><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">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div>
<div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><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" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div>
@@ -3895,7 +4009,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="tx-modal-summary">
<div class="tx-field"><span class="tx-label">Source</span><span class="tx-value text" x-text="selectedTransactionLabel()"></span></div>
<div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(selectedTransaction?.tx || {}))"></span></span></div>
- <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">IUNA <span x-text="amountLabel(selectedTransaction?.tx?.fee ?? 0)"></span></span></div>
+ <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(selectedTransaction?.tx)"></span></div>
<div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="txFrom(selectedTransaction?.tx || {})"></code></div>
<div class="tx-field" x-show="txTo(selectedTransaction?.tx || {})"><span class="tx-label">To</span><code class="tx-value hash" x-text="txTo(selectedTransaction?.tx || {})"></code></div>
<div class="tx-field" x-show="isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="selectedTransaction?.tx?.commitment || selectedTransaction?.tx?.signature || '-'"></code></div>
@@ -4074,7 +4188,7 @@ mod tests {
domain::{
Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, LaunchProfile, Ledger,
MICRO_IUNA, MINE_FINALIZER_FEE, MaskedBlindedReveal, OutPoint, RevealBundleSection,
- RevealBundleSignature, Transaction, Wallet,
+ RevealBundleSignature, Transaction, TxInput, TxOutput, Wallet,
},
};
@@ -4097,8 +4211,18 @@ mod tests {
#[test]
fn mempool_ui_items_can_represent_blinded_transactions_and_reveals() {
let commitment = "a".repeat(64);
+ let owner = "c".repeat(64);
+ let input_outpoint = OutPoint {
+ txid: "d".repeat(64),
+ index: 0,
+ };
let blinded = BlindedTransaction {
commitment: commitment.clone(),
+ inputs: vec![TxInput {
+ outpoint: input_outpoint.clone(),
+ owner: owner.clone(),
+ signature: "e".repeat(128),
+ }],
fee: 7,
encrypted_size: 123,
expires_at_height: 42,
@@ -4110,11 +4234,21 @@ mod tests {
commitment: commitment.clone(),
key: "22".repeat(32),
};
+ let outputs = BTreeMap::from([(
+ input_outpoint.clone(),
+ TxOutput {
+ address: owner.clone(),
+ amount: 99,
+ },
+ )]);
- let blinded_row = super::ui_blinded_transaction(&blinded);
+ let blinded_row = super::ui_blinded_transaction(&blinded, &outputs);
let reveal_row = super::ui_blinded_reveal(&reveal);
assert_eq!(blinded_row.kind, "blinded");
+ assert_eq!(blinded_row.from, owner);
+ assert_eq!(blinded_row.inputs.len(), 1);
+ assert_eq!(blinded_row.inputs[0].amount, Some(99));
assert_eq!(blinded_row.signature, commitment);
assert_eq!(blinded_row.encrypted_size, Some(123));
assert_eq!(blinded_row.expires_at_height, Some(42));
@@ -4132,7 +4266,7 @@ mod tests {
let ledger = Ledger::new(allocations.clone(), 1);
let transfer = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap();
let built = ledger
- .build_blinded_transaction(transfer.clone(), 20)
+ .build_blinded_transaction(&alice, transfer.clone(), 20)
.unwrap();
let mut block = fake_block(7, Vec::new());
block.blinded_transactions = vec![built.transaction.clone()];
@@ -4168,7 +4302,7 @@ mod tests {
let ledger = Ledger::new(allocations.clone(), 1);
let transfer = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap();
let built = ledger
- .build_blinded_transaction(transfer.clone(), 20)
+ .build_blinded_transaction(&alice, transfer.clone(), 20)
.unwrap();
let mut commit_block = fake_block(7, Vec::new());
commit_block.blinded_transactions = vec![built.transaction.clone()];
@@ -4215,12 +4349,14 @@ mod tests {
let app_js = include_str!("../../www/assets/iuna-ui.js");
assert!(super::INDEX_HTML.contains("txPillLabel(tx)"));
+ assert!(super::INDEX_HTML.contains("commitCountLabel(block)"));
assert!(super::INDEX_HTML.contains(".pill.blinded"));
assert!(super::INDEX_HTML.contains(".pill.reveal, .pill.revealed"));
assert!(super::INDEX_HTML.contains("Commitment"));
assert!(!super::INDEX_HTML.contains("<h3>Revealed</h3>"));
assert!(app_js.contains("tx?.revealed ? \"revealed\""));
assert!(app_js.contains("transactions.some((tx) => tx?.revealed)"));
+ assert!(app_js.contains("blockCommitCount(block)"));
}
#[test]
@@ -5032,26 +5168,16 @@ mod tests {
}
#[test]
- fn wallet_transactions_include_revealed_blinded_mine_actions() {
+ fn wallet_transactions_include_public_mine_actions() {
let alice = Wallet::from_seed("wallet-revealed-mine-alice");
- let ledger = Ledger::new(BTreeMap::new(), 1);
+ let ledger = Ledger::new(
+ BTreeMap::from([(alice.address().to_string(), 2 * MICRO_IUNA)]),
+ 1,
+ );
let mine = ledger.build_mine(alice.address()).unwrap();
- let built = ledger.build_blinded_transaction(mine.clone(), 20).unwrap();
- let mut commit_block = fake_block(7, Vec::new());
- commit_block.blinded_transactions = vec![built.transaction];
- let mut reveal_block = fake_block(8, Vec::new());
- reveal_block.reveal_bundle_section = RevealBundleSection {
- signatures: vec![RevealBundleSignature {
- slot: 0,
- member: reveal_block.miner.clone(),
- signature: "11".repeat(64),
- }],
- reveals: vec![MaskedBlindedReveal {
- reveal: built.reveal,
- bundle_mask: 1,
- }],
- };
- let chain = vec![commit_block.clone(), reveal_block.clone()];
+ let mut mine_block = fake_block(8, vec![mine.clone()]);
+ mine_block.reward = mine.fee();
+ let chain = vec![mine_block.clone()];
let snapshot = fake_snapshot(BTreeMap::new(), chain.clone());
let revealed_by_height = super::revealed_transactions_by_height(&snapshot);
let outputs = super::known_output_index(&snapshot, &[]);
@@ -5290,7 +5416,7 @@ mod tests {
#[test]
fn metrics_screen_includes_block_range_filter() {
- assert!(super::INDEX_HTML.contains("iuna-ui.js?v=82"));
+ assert!(super::INDEX_HTML.contains("iuna-ui.js?v=84"));
assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\""));
assert!(super::INDEX_HTML.contains("setMetricsRange(100)"));
assert!(super::INDEX_HTML.contains("setMetricsRange(1000)"));
@@ -5298,6 +5424,16 @@ mod tests {
}
#[test]
+ fn reveal_mempool_items_show_unknown_fee_label() {
+ let app_js = include_str!("../../www/assets/iuna-ui.js");
+ assert!(app_js.contains("txFeeLabel(tx)"));
+ assert!(app_js.contains("tx?.kind === \"reveal\""));
+ assert!(app_js.contains("unknown until reveal"));
+ assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(tx)\""));
+ assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(selectedTransaction?.tx)\""));
+ }
+
+ #[test]
fn initial_setup_includes_node_mode_choices() {
assert!(super::INDEX_HTML.contains("aria-label=\"Initial node mode\""));
assert!(super::INDEX_HTML.contains("selectSetupNodeMode('wallet')"));
diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs
@@ -1037,6 +1037,12 @@ async fn process_envelope(
GossipEnvelope::BlindedTransactions { transactions } => {
process_blinded_transactions(network, remote_addr, known_peer, transactions).await;
}
+ GossipEnvelope::MineAction(tx) => {
+ process_mine_actions(network, remote_addr, known_peer, vec![tx]).await;
+ }
+ GossipEnvelope::MineActions { transactions } => {
+ process_mine_actions(network, remote_addr, known_peer, transactions).await;
+ }
GossipEnvelope::BlindedReveal(reveal) => {
process_blinded_reveals(network, remote_addr, known_peer, vec![reveal]).await;
}
@@ -1146,6 +1152,34 @@ async fn process_blinded_transactions(
network.forward_outbox().await;
}
+async fn process_mine_actions(
+ network: &GossipNetwork,
+ remote_addr: SocketAddr,
+ known_peer: &Option<String>,
+ transactions: Vec<crate::domain::Transaction>,
+) {
+ 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_mine_action(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,
@@ -1444,6 +1478,12 @@ fn record_received_envelope_kind(metrics: &P2pMetricsCounters, envelope: &Gossip
transactions.len() as u64,
);
}
+ GossipEnvelope::MineAction(_) => {
+ P2pMetricsCounters::inc(&metrics.data_envelopes_received);
+ }
+ GossipEnvelope::MineActions { .. } => {
+ P2pMetricsCounters::inc(&metrics.data_envelopes_received);
+ }
GossipEnvelope::BlindedReveal(_) => {
P2pMetricsCounters::inc(&metrics.data_envelopes_received);
P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received);
@@ -1517,6 +1557,13 @@ fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> {
TRANSACTION_BATCH_LIMIT,
)?;
}
+ GossipEnvelope::MineActions { transactions } => {
+ ensure_len(
+ "mine action batch",
+ transactions.len(),
+ TRANSACTION_BATCH_LIMIT,
+ )?;
+ }
GossipEnvelope::BlindedReveals { reveals } => {
ensure_len(
"blinded reveal batch",
@@ -1544,6 +1591,7 @@ fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> {
| GossipEnvelope::ChainSnapshotRequest
| GossipEnvelope::PeerStatus { .. }
| GossipEnvelope::BlindedTransaction(_)
+ | GossipEnvelope::MineAction(_)
| GossipEnvelope::BlindedReveal(_)
| GossipEnvelope::RevealBundle(_)
| GossipEnvelope::Block(_)
@@ -2656,6 +2704,7 @@ mod tests {
let metrics = super::P2pMetricsCounters::default();
let blinded_tx = BlindedTransaction {
commitment: "commitment".to_string(),
+ inputs: Vec::new(),
fee: 3,
encrypted_size: 128,
expires_at_height: 20,
@@ -2978,7 +3027,10 @@ mod tests {
let expected_commitment = {
let mut node = node.lock().await;
let tx = node.ledger().build_burn(&alice, 1, 0).unwrap();
- let built = node.ledger().build_blinded_transaction(tx, 20).unwrap();
+ let built = node
+ .ledger()
+ .build_blinded_transaction(&alice, tx, 20)
+ .unwrap();
let commitment = built.transaction.commitment.clone();
node.receive_blinded_transaction(built.transaction).unwrap();
node.drain_outbox();
diff --git a/src/adapters/stratum.rs b/src/adapters/stratum.rs
@@ -407,8 +407,8 @@ mod tests {
.await;
assert_eq!(read_id(&mut lines, 3).await["result"], json!(true));
let node = node.lock().await;
- assert!(node.ledger().pending().is_empty());
- assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
+ assert_eq!(node.ledger().pending().len(), 1);
+ assert!(node.ledger().pending_blinded_transactions().is_empty());
}
#[test]
diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs
@@ -834,6 +834,7 @@ mod tests {
OwnedBlindedTransaction {
transaction: BlindedTransaction {
commitment: "d".repeat(64),
+ inputs: Vec::new(),
fee: 1,
encrypted_size: 42,
expires_at_height: 10,
diff --git a/src/app.rs b/src/app.rs
@@ -117,6 +117,10 @@ pub enum GossipEnvelope {
BlindedTransactions {
transactions: Vec<BlindedTransaction>,
},
+ MineAction(Transaction),
+ MineActions {
+ transactions: Vec<Transaction>,
+ },
BlindedReveal(BlindedReveal),
BlindedReveals {
reveals: Vec<BlindedReveal>,
@@ -497,6 +501,18 @@ impl NodeCore {
pub fn mempool_gossip(&mut self) -> Vec<GossipEnvelope> {
let _ = self.publish_reveal_bundle_for_next_block();
let mut gossip = Vec::new();
+ let mine_actions = self
+ .ledger
+ .pending()
+ .iter()
+ .filter(|transaction| matches!(transaction, Transaction::Mine { .. }))
+ .cloned()
+ .collect::<Vec<_>>();
+ gossip.extend(mine_actions.chunks(TRANSACTION_BATCH_LIMIT).map(|chunk| {
+ GossipEnvelope::MineActions {
+ transactions: chunk.to_vec(),
+ }
+ }));
gossip.extend(
self.ledger
.pending_blinded_transactions()
@@ -796,7 +812,7 @@ impl NodeCore {
pub fn mine_pow_reward(&mut self) -> Result<Transaction> {
let (tx, _) = self.build_mine_estimate()?;
- self.submit_transaction_as_owned_blinded(tx)
+ self.submit_public_mine_action(tx)
}
pub fn estimate_mine_fee(&self, _fee_per_byte: Amount) -> Result<FeeEstimate> {
@@ -835,7 +851,7 @@ impl NodeCore {
if tx.to() != Some(recipient.as_str()) {
bail!("submitted mine recipient does not match worker");
}
- self.submit_transaction_as_owned_blinded(tx)
+ self.submit_public_mine_action(tx)
}
pub fn receive_transaction(&mut self, tx: Transaction) -> Result<TransactionSubmitOutcome> {
@@ -843,6 +859,20 @@ impl NodeCore {
Ok(outcome)
}
+ pub fn receive_mine_action(&mut self, tx: Transaction) -> Result<()> {
+ if !matches!(tx, Transaction::Mine { .. }) {
+ bail!("only mine actions may be gossiped as plaintext");
+ }
+ if self
+ .ledger
+ .submit_transaction_with_outcome(tx.clone())?
+ .added()
+ {
+ self.outbox.push(GossipEnvelope::MineAction(tx));
+ }
+ Ok(())
+ }
+
pub fn receive_blinded_transaction(&mut self, tx: BlindedTransaction) -> Result<()> {
if self.ledger.submit_blinded_transaction(tx.clone())? {
self.outbox.push(GossipEnvelope::BlindedTransaction(tx));
@@ -851,10 +881,19 @@ impl NodeCore {
}
pub fn receive_blinded_reveal(&mut self, reveal: BlindedReveal) -> Result<()> {
+ self.receive_blinded_reveal_without_bundle_publish(reveal)?;
+ Ok(())
+ }
+
+ fn receive_blinded_reveal_without_bundle_publish(
+ &mut self,
+ reveal: BlindedReveal,
+ ) -> Result<bool> {
if self.ledger.submit_blinded_reveal(reveal.clone())? {
self.outbox.push(GossipEnvelope::BlindedReveal(reveal));
+ return Ok(true);
}
- Ok(())
+ Ok(false)
}
pub fn receive_reveal_bundle(&mut self, bundle: RevealBundle) -> Result<()> {
@@ -951,8 +990,23 @@ impl NodeCore {
Ok(transaction)
}
+ fn submit_public_mine_action(&mut self, tx: Transaction) -> Result<Transaction> {
+ if !matches!(tx, Transaction::Mine { .. }) {
+ bail!("only mine actions may be submitted as public mempool transactions");
+ }
+ if self
+ .ledger
+ .submit_transaction_with_outcome(tx.clone())?
+ .added()
+ {
+ self.outbox.push(GossipEnvelope::MineAction(tx.clone()));
+ }
+ Ok(tx)
+ }
+
fn submit_transaction_as_owned_blinded(&mut self, tx: Transaction) -> Result<Transaction> {
let built = self.ledger.build_blinded_transaction(
+ self.wallet.unlocked()?,
tx.clone(),
self.default_blinded_transaction_expiry_height(),
)?;
@@ -1045,7 +1099,7 @@ impl NodeCore {
let expires_at_height = self.default_blinded_transaction_expiry_height();
converge_fee_by_byte(fee_per_byte, |fee| {
let tx = ledger.build_burn(self.wallet.unlocked()?, amount, fee)?;
- ledger.build_blinded_transaction(tx, expires_at_height)
+ ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height)
})
}
@@ -1083,7 +1137,7 @@ impl NodeCore {
outpoints,
)
}?;
- ledger.build_blinded_transaction(tx, expires_at_height)
+ ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height)
})
}
@@ -1361,7 +1415,7 @@ impl NodeCore {
));
return Ok(None);
};
- self.submit_transaction_as_owned_blinded(tx.clone())?;
+ self.submit_public_mine_action(tx.clone())?;
self.last_auto_pow_mine_anchor = Some(anchor);
self.last_auto_pow_mine_status = Some(format!(
"queued mine action after {searched} PoW nonce attempts for the current tip"
@@ -1517,10 +1571,21 @@ impl NodeCore {
}
Ok(())
}
+ GossipEnvelope::MineAction(tx) => self.receive_mine_action(tx),
+ GossipEnvelope::MineActions { transactions } => {
+ for tx in transactions {
+ self.receive_mine_action(tx)?;
+ }
+ Ok(())
+ }
GossipEnvelope::BlindedReveal(reveal) => self.receive_blinded_reveal(reveal),
GossipEnvelope::BlindedReveals { reveals } => {
+ let mut added = false;
for reveal in reveals {
- self.receive_blinded_reveal(reveal)?;
+ added |= self.receive_blinded_reveal_without_bundle_publish(reveal)?;
+ }
+ if added {
+ self.publish_reveal_bundle_for_next_block()?;
}
Ok(())
}
@@ -2098,6 +2163,13 @@ mod tests {
use super::{GossipEnvelope, NodeConfig, NodeCore};
+ fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet {
+ wallets
+ .iter()
+ .find(|wallet| wallet.address() == address)
+ .unwrap_or_else(|| panic!("missing wallet for address {address}"))
+ }
+
#[test]
fn same_height_verified_import_does_not_reset_auto_burn_guard() {
let alice = Wallet::from_seed("same-height-import-alice");
@@ -2165,8 +2237,12 @@ mod tests {
*difficulty_bits,
node.ledger().current_mine_difficulty_bits()
);
- assert!(node.ledger().pending().is_empty());
- assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
+ assert_eq!(node.ledger().pending().len(), 1);
+ assert!(node.ledger().pending_blinded_transactions().is_empty());
+ assert!(node
+ .drain_outbox()
+ .iter()
+ .any(|envelope| matches!(envelope, GossipEnvelope::MineAction(tx) if tx.signature() == first_mine.signature())));
assert!(
node.status()
.mining
@@ -2184,8 +2260,8 @@ mod tests {
second.pow_mined.as_ref().unwrap().signature(),
first_mine.signature()
);
- assert!(node.ledger().pending().is_empty());
- assert_eq!(node.ledger().pending_blinded_transactions().len(), 2);
+ assert_eq!(node.ledger().pending().len(), 2);
+ assert!(node.ledger().pending_blinded_transactions().is_empty());
}
#[test]
@@ -2332,7 +2408,12 @@ mod tests {
let bob = Wallet::from_seed("fee-rate-bob");
let mut genesis = BTreeMap::new();
genesis.insert(alice.address().to_string(), 10 * MICRO_IUNA);
- let ledger = crate::domain::Ledger::new(genesis, 1);
+ let ledger = crate::domain::Ledger::new_with_genesis_burns(
+ genesis,
+ vec![GenesisBurn::new(alice.address(), MICRO_IUNA)],
+ 1,
+ )
+ .unwrap();
let mut node = NodeCore::from_ledger(alice, ledger, 0);
let (transfer, transfer_estimate) = node
@@ -2380,6 +2461,100 @@ mod tests {
}
#[test]
+ fn mempool_gossip_includes_public_mine_actions() {
+ let alice = Wallet::from_seed("mine-gossip-alice");
+ let ledger = Ledger::new(BTreeMap::new(), 1);
+ let mine = ledger.build_mine(alice.address()).unwrap();
+ let mut sender = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0);
+ let mut receiver = NodeCore::from_ledger(alice, ledger, 0);
+
+ sender.submit_public_mine_action(mine.clone()).unwrap();
+ for envelope in sender.mempool_gossip() {
+ receiver.receive(envelope).unwrap();
+ }
+
+ assert_eq!(receiver.ledger().pending(), std::slice::from_ref(&mine));
+ assert!(receiver.ledger().pending_blinded_transactions().is_empty());
+ }
+
+ #[test]
+ fn receiving_blinded_reveal_batch_publishes_complete_committee_bundle() {
+ let alice = Wallet::from_seed("immediate-bundle-alice");
+ let bob = Wallet::from_seed("immediate-bundle-bob");
+ let carol = Wallet::from_seed("immediate-bundle-carol");
+ let dave = Wallet::from_seed("immediate-bundle-dave");
+ 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);
+ allocations.insert(dave.address().to_string(), 10 * MICRO_IUNA);
+ let mut ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ finalizers
+ .iter()
+ .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA))
+ .collect(),
+ 1,
+ )
+ .unwrap();
+ let first = ledger
+ .build_blinded_burn(&carol, 3, 100, ledger.height() + 4)
+ .unwrap();
+ let second = ledger
+ .build_blinded_burn(&dave, 4, 100, ledger.height() + 4)
+ .unwrap();
+ ledger
+ .submit_blinded_transaction(first.transaction.clone())
+ .unwrap();
+ ledger
+ .submit_blinded_transaction(second.transaction.clone())
+ .unwrap();
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let leader_wallet = wallet_for_address(&finalizers, &leader);
+ let burn = ledger.build_burn(leader_wallet, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let commit_block = ledger.mine_next_block(leader_wallet, 1).unwrap();
+ ledger.apply_locally_mined_block(commit_block).unwrap();
+
+ let committee = ledger.reveal_committee_for_next_block();
+ let committee_wallet = committee
+ .iter()
+ .filter_map(|member| {
+ finalizers
+ .iter()
+ .find(|wallet| wallet.address() == member.owner)
+ })
+ .next()
+ .expect("test finalizer should be in reveal committee");
+ let mut committee_node = NodeCore::from_ledger(committee_wallet.clone(), ledger, 0);
+
+ committee_node
+ .receive(GossipEnvelope::BlindedReveals {
+ reveals: vec![first.reveal.clone(), second.reveal.clone()],
+ })
+ .unwrap();
+ let outbox = committee_node.drain_outbox();
+
+ assert!(outbox.iter().any(|envelope| matches!(
+ envelope,
+ GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == first.reveal.commitment
+ )));
+ assert!(outbox.iter().any(|envelope| matches!(
+ envelope,
+ GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == second.reveal.commitment
+ )));
+ assert!(outbox.iter().any(|envelope| matches!(
+ envelope,
+ GossipEnvelope::RevealBundle(bundle)
+ if bundle.member == committee_wallet.address()
+ && bundle.reveals.len() == 2
+ && bundle.reveals.iter().any(|reveal| reveal.commitment == first.reveal.commitment)
+ && bundle.reveals.iter().any(|reveal| reveal.commitment == second.reveal.commitment)
+ )));
+ }
+
+ #[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");
@@ -2668,6 +2843,24 @@ impl InMemoryNetwork {
}
}
+ pub fn gossip_mempools_once(&mut self) -> Result<()> {
+ let mut outbound = Vec::new();
+ for (id, node) in &mut self.nodes {
+ for envelope in node.mempool_gossip() {
+ outbound.push((id.clone(), envelope));
+ }
+ }
+
+ for (from, envelope) in outbound {
+ for (id, node) in &mut self.nodes {
+ if *id != from {
+ receive_in_memory_envelope(node, envelope.clone())?;
+ }
+ }
+ }
+ Ok(())
+ }
+
pub fn sync_node_from_peer(&mut self, from: &str, to: &str, limit: usize) -> Result<bool> {
let from_height = self
.nodes
@@ -2696,6 +2889,8 @@ fn receive_in_memory_envelope(node: &mut NodeCore, envelope: GossipEnvelope) ->
envelope,
GossipEnvelope::BlindedTransaction(_)
| GossipEnvelope::BlindedTransactions { .. }
+ | GossipEnvelope::MineAction(_)
+ | GossipEnvelope::MineActions { .. }
| GossipEnvelope::BlindedReveal(_)
| GossipEnvelope::BlindedReveals { .. }
);
diff --git a/src/domain.rs b/src/domain.rs
@@ -165,9 +165,25 @@ pub enum Transaction {
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+enum BlindedTransactionPayload {
+ Transfer {
+ outputs: Vec<TxOutput>,
+ signature: String,
+ },
+ Burn {
+ change: Vec<TxOutput>,
+ amount: Amount,
+ signature: String,
+ },
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlindedTransaction {
pub commitment: String,
+ #[serde(default)]
+ pub inputs: Vec<TxInput>,
pub fee: Amount,
pub encrypted_size: u32,
pub expires_at_height: u64,
@@ -209,6 +225,7 @@ pub struct RevealedBlindedTransaction {
#[derive(Clone, Debug, Eq, PartialEq)]
struct ActiveBlindedTransaction {
transaction: BlindedTransaction,
+ locked_outputs: Vec<TxOutput>,
included_height: u64,
included_by: String,
}
@@ -460,7 +477,8 @@ impl BlindedTransaction {
pub fn canonical(&self) -> String {
format!(
- "blinded-tx:{}:{}:{}:{}:{}:{}",
+ "blinded-tx:{}:{}:{}:{}:{}:{}:{}",
+ canonical_signed_inputs(&self.inputs),
self.fee,
self.encrypted_size,
self.expires_at_height,
@@ -760,6 +778,17 @@ fn unsigned_inputs(inputs: &[TxInput]) -> Vec<UnsignedTxInput> {
inputs.iter().map(TxInput::without_signature).collect()
}
+fn signed_blinded_inputs(inputs: &[UnsignedTxInput], signature: &str) -> Vec<TxInput> {
+ inputs
+ .iter()
+ .map(|input| TxInput {
+ outpoint: input.outpoint.clone(),
+ owner: input.owner.clone(),
+ signature: signature.to_string(),
+ })
+ .collect()
+}
+
fn canonical_inputs(inputs: &[UnsignedTxInput]) -> String {
inputs
.iter()
@@ -773,6 +802,19 @@ fn canonical_inputs(inputs: &[UnsignedTxInput]) -> String {
.join("|")
}
+fn canonical_signed_inputs(inputs: &[TxInput]) -> String {
+ inputs
+ .iter()
+ .map(|input| {
+ format!(
+ "{}:{}:{}:{}",
+ input.outpoint.txid, input.outpoint.index, input.owner, input.signature
+ )
+ })
+ .collect::<Vec<_>>()
+ .join("|")
+}
+
fn canonical_outputs(outputs: &[TxOutput]) -> String {
outputs
.iter()
@@ -975,6 +1017,30 @@ fn transaction_inputs_spent_by(transaction: &Transaction, pending: &[Transaction
.any(|input| spent.contains(&input.outpoint))
}
+fn transaction_inputs_spent_by_inputs(inputs: &[TxInput], pending: &[Transaction]) -> bool {
+ let spent = pending_spent_outpoints(pending);
+ inputs.iter().any(|input| spent.contains(&input.outpoint))
+}
+
+fn blinded_transaction_inputs_spent_by(
+ transaction: &BlindedTransaction,
+ pending: &[BlindedTransaction],
+) -> bool {
+ let spent = pending
+ .iter()
+ .flat_map(|transaction| {
+ transaction
+ .inputs
+ .iter()
+ .map(|input| input.outpoint.clone())
+ })
+ .collect::<BTreeSet<_>>();
+ transaction
+ .inputs
+ .iter()
+ .any(|input| spent.contains(&input.outpoint))
+}
+
fn transaction_inputs_available(
transaction: &Transaction,
utxos: &BTreeMap<OutPoint, TxOutput>,
@@ -1929,7 +1995,10 @@ impl Ledger {
)
})?;
let transaction = decrypt_blinded_transaction(&active.transaction, reveal)?;
- if transaction.fee() != active.transaction.fee {
+ if matches!(transaction, Transaction::Mine { .. }) {
+ bail!("mine actions are public and cannot be blinded");
+ }
+ if blinded_envelope_fee_for_transaction(&transaction) != active.transaction.fee {
bail!(
"block {} blinded reveal fee does not match envelope",
block.height
@@ -1949,6 +2018,7 @@ impl Ledger {
transaction.commitment.clone(),
ActiveBlindedTransaction {
transaction: transaction.clone(),
+ locked_outputs: Vec::new(),
included_height: block.height,
included_by: block.miner.clone(),
},
@@ -2522,7 +2592,7 @@ impl Ledger {
expires_at_height: u64,
) -> Result<BuiltBlindedTransaction> {
let transaction = self.build_burn(wallet, amount, fee)?;
- self.blind_transaction(transaction, fee, expires_at_height)
+ self.blind_transaction(wallet, transaction, fee, expires_at_height)
}
pub fn build_blinded_transfer(
@@ -2534,20 +2604,25 @@ impl Ledger {
expires_at_height: u64,
) -> Result<BuiltBlindedTransaction> {
let transaction = self.build_transfer(wallet, to, amount, fee)?;
- self.blind_transaction(transaction, fee, expires_at_height)
+ self.blind_transaction(wallet, transaction, fee, expires_at_height)
}
pub fn build_blinded_transaction(
&self,
+ wallet: &Wallet,
transaction: Transaction,
expires_at_height: u64,
) -> Result<BuiltBlindedTransaction> {
- let fee = transaction.fee();
- self.blind_transaction(transaction, fee, expires_at_height)
+ if matches!(transaction, Transaction::Mine { .. }) {
+ bail!("mine actions are public and cannot be blinded");
+ }
+ let fee = blinded_envelope_fee_for_transaction(&transaction);
+ self.blind_transaction(wallet, transaction, fee, expires_at_height)
}
fn blind_transaction(
&self,
+ wallet: &Wallet,
transaction: Transaction,
fee: Amount,
expires_at_height: u64,
@@ -2562,12 +2637,25 @@ impl Ledger {
{
bail!("blinded transaction expiry is too far in the future");
}
- if fee != transaction.fee() {
+ if fee != blinded_envelope_fee_for_transaction(&transaction) {
bail!("blinded transaction fee must match plaintext transaction fee");
}
- let plaintext = serde_json::to_vec(&transaction)
+ let unsigned_inputs = if transaction.inputs().is_empty() && transaction.fee() > 0 {
+ self.select_inputs(wallet.address(), transaction.fee())?.0
+ } else {
+ unsigned_inputs(transaction.inputs())
+ };
+ if unsigned_inputs
+ .iter()
+ .any(|input| input.owner != wallet.address())
+ {
+ bail!("blinded transaction inputs must be owned by the signing wallet");
+ }
+ let blinded_payload = blinded_payload_from_transaction(&transaction)?;
+ let plaintext = serde_json::to_vec(&blinded_payload)
.context("failed to serialize transaction for blinded payload")?;
let payload_hash = hex_hash(&plaintext);
+ let unsigned_commit_inputs = signed_blinded_inputs(&unsigned_inputs, "");
let payload = transaction;
let mut key = [0_u8; BLINDED_KEY_BYTES];
let mut nonce = [0_u8; BLINDED_NONCE_BYTES];
@@ -2575,11 +2663,19 @@ impl Ledger {
.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 ciphertext = encrypt_blinded_payload(
+ &key,
+ &nonce,
+ &unsigned_commit_inputs,
+ 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(),
+ inputs: unsigned_commit_inputs,
fee,
encrypted_size,
expires_at_height,
@@ -2587,6 +2683,11 @@ impl Ledger {
ciphertext: hex_encode(&ciphertext),
payload_hash,
};
+ let signature = wallet.sign_payload(&blinded_transaction_signing_payload(&transaction));
+ let transaction = BlindedTransaction {
+ inputs: signed_blinded_inputs(&unsigned_inputs, &signature),
+ ..transaction
+ };
let commitment = blinded_transaction_commitment(&transaction)?;
let transaction = BlindedTransaction {
commitment: commitment.clone(),
@@ -2722,6 +2823,14 @@ impl Ledger {
return Ok(false);
}
self.validate_blinded_transaction(&transaction)?;
+ if blinded_transaction_inputs_spent_by(&transaction, &self.pending_blinded)
+ || transaction_inputs_spent_by_inputs(&transaction.inputs, &self.pending)
+ || transaction_inputs_spent_by_inputs(&transaction.inputs, &self.orphans)
+ {
+ bail!("blinded transaction conflicts with pending inputs");
+ }
+ let mut utxos = self.utxos_after_valid_pending_and_blinded()?;
+ spend_blinded_inputs(&transaction, &mut utxos)?;
if self.pending_blinded.len() >= MAX_PENDING_TRANSACTIONS {
bail!("blinded mempool is full");
}
@@ -2763,7 +2872,7 @@ impl Ledger {
bail!("mempool is full");
}
- let mut utxos = self.utxos_after_valid_pending()?;
+ let mut utxos = self.utxos_after_valid_pending_and_blinded()?;
if transaction_has_missing_inputs(&transaction, &utxos) {
if self.orphans.len() >= MAX_ORPHAN_TRANSACTIONS {
bail!("orphan transaction pool is full");
@@ -2951,31 +3060,29 @@ impl Ledger {
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)?;
+ .context("blinded reveal does not reference an active blinded transaction")?
+ .clone();
+ let tx = self.decrypt_active_blinded(&active, reveal)?;
+ self.apply_revealed_blinded_transaction(&active, &tx, &mut utxos)?;
credit_blinded_fee_outputs(
&mut utxos,
- active,
+ &active,
&block.miner,
&tx,
&block.reveal_bundle_section.signatures,
)?;
revealed_transactions.push(tx);
}
+ for (commitment, active) in &self.active_blinded {
+ if !revealed_commitments.contains(commitment)
+ && block.height >= active.transaction.expires_at_height
+ {
+ credit_expired_blinded_outputs(&mut utxos, active)?;
+ }
+ }
if block.reward != fee_reward(&block.transactions)? {
bail!("block reward is invalid");
}
- let mut tickets = self.tickets.clone();
- 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()
@@ -2991,25 +3098,38 @@ impl Ledger {
.into_iter()
.map(|reveal| reveal.commitment.clone())
.collect::<BTreeSet<_>>();
+ let mut new_active_blinded = Vec::new();
+ for transaction in &block.blinded_transactions {
+ let locked_outputs = spend_blinded_inputs(transaction, &mut utxos)?;
+ new_active_blinded.push((
+ transaction.commitment.clone(),
+ ActiveBlindedTransaction {
+ transaction: transaction.clone(),
+ locked_outputs,
+ included_height: block.height,
+ included_by: block.miner.clone(),
+ },
+ ));
+ }
+ let mut tickets = self.tickets.clone();
+ apply_finalizer_ticket_effects(&block, &mut tickets)?;
+ tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?);
+ tickets.extend(tickets_created_by_transactions(
+ block.height,
+ &revealed_transactions,
+ &self.launch_profile,
+ )?);
+ credit_reward_output(&mut utxos, &block)?;
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(),
- },
- );
+ for (commitment, active) in new_active_blinded {
+ self.active_blinded.insert(commitment, active);
}
let available = self.utxos.clone();
let pending = std::mem::take(&mut self.pending);
@@ -3292,9 +3412,10 @@ impl Ledger {
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_blinded =
+ best_selectable_blinded_index(&remaining_blinded, &utxos).map(|index| {
+ SelectableItem::Blinded(index, blinded_fee_rate_key(&remaining_blinded[index]))
+ });
let Some(item) = best_selectable_item(best_plain, best_blinded) else {
break;
};
@@ -3328,6 +3449,7 @@ impl Ledger {
required_burn_owner.is_some(),
)? <= self.launch_profile.max_block_bytes
{
+ spend_blinded_inputs(&transaction, &mut utxos)?;
selected_blinded.push(transaction);
}
}
@@ -3414,7 +3536,7 @@ impl Ledger {
return Ok(());
}
let mut promoted_index = None;
- let mut utxos = self.utxos_after_valid_pending()?;
+ let mut utxos = self.utxos_after_valid_pending_and_blinded()?;
for (index, transaction) in self.orphans.iter().enumerate() {
if transaction_inputs_spent_by(transaction, &self.pending) {
continue;
@@ -3518,6 +3640,13 @@ impl Ledger {
{
bail!("blinded transaction expiry is too far in the future");
}
+ validate_transaction_inputs(&transaction.inputs)?;
+ if transaction.inputs.is_empty() && transaction.fee > 0 {
+ bail!("blinded transaction with a fee must lock visible inputs");
+ }
+ if !transaction.inputs.is_empty() {
+ verify_blinded_input_signatures(transaction)?;
+ }
let expected = blinded_transaction_commitment(transaction)?;
if transaction.commitment != expected {
bail!("blinded transaction commitment is invalid");
@@ -3582,13 +3711,61 @@ impl Ledger {
bail!("blinded transaction reveal is expired");
}
let transaction = decrypt_blinded_transaction(&active.transaction, reveal)?;
- if transaction.fee() != active.transaction.fee {
+ if matches!(transaction, Transaction::Mine { .. }) {
+ bail!("mine actions are public and cannot be blinded");
+ }
+ if blinded_envelope_fee_for_transaction(&transaction) != active.transaction.fee {
bail!("blinded transaction reveal fee does not match envelope");
}
+ if !blinded_reveal_inputs_match(active, &transaction) {
+ bail!("blinded transaction reveal inputs do not match envelope");
+ }
self.validate_transaction_terms(&transaction)?;
Ok(transaction)
}
+ fn apply_revealed_blinded_transaction(
+ &self,
+ active: &ActiveBlindedTransaction,
+ transaction: &Transaction,
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+ ) -> Result<()> {
+ if matches!(transaction, Transaction::Mine { .. }) {
+ bail!("mine actions are public and cannot be blinded");
+ }
+ transaction.verify_signature()?;
+ ensure_single_input_owner(transaction)?;
+ let input_total = blinded_locked_output_total(active)?;
+ let outputs = transaction.outputs();
+ let output_total = outputs.iter().try_fold(0_u64, |total, output| {
+ total
+ .checked_add(output.amount)
+ .context("transaction outputs overflow")
+ })?;
+ let required = output_total
+ .checked_add(transaction.fee())
+ .context("transaction outputs plus fee overflow")?
+ .checked_add(match transaction {
+ Transaction::Burn { amount, .. } => *amount,
+ Transaction::Transfer { .. } | Transaction::Mine { .. } => 0,
+ })
+ .context("transaction outputs plus burn overflow")?;
+ if input_total != required {
+ bail!("blinded transaction inputs do not balance outputs, burn, and fee");
+ }
+ ensure_outputs_do_not_overflow(utxos, &outputs)?;
+ for (index, output) in outputs.iter().enumerate() {
+ utxos.insert(
+ OutPoint {
+ txid: transaction.signature().to_string(),
+ index: index as u32,
+ },
+ output.clone(),
+ );
+ }
+ Ok(())
+ }
+
fn utxos_after_valid_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> {
let mut utxos = self.utxos.clone();
for pending in self.valid_pending_transactions() {
@@ -3597,6 +3774,14 @@ impl Ledger {
Ok(utxos)
}
+ fn utxos_after_valid_pending_and_blinded(&self) -> Result<BTreeMap<OutPoint, TxOutput>> {
+ let mut utxos = self.utxos_after_valid_pending()?;
+ for pending in self.valid_pending_blinded_transactions() {
+ spend_blinded_inputs(&pending, &mut utxos)?;
+ }
+ Ok(utxos)
+ }
+
fn utxos_after_spendable_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> {
let mut utxos = self.utxos.clone();
for pending in self.valid_pending_transactions() {
@@ -3608,6 +3793,12 @@ impl Ledger {
utxos = candidate;
}
}
+ for pending in self.valid_pending_blinded_transactions() {
+ let mut candidate = utxos.clone();
+ if spend_blinded_inputs(&pending, &mut candidate).is_ok() {
+ utxos = candidate;
+ }
+ }
Ok(utxos)
}
@@ -4048,10 +4239,17 @@ fn best_selectable_item(
}
}
-fn best_selectable_blinded_index(transactions: &[BlindedTransaction]) -> Option<usize> {
+fn best_selectable_blinded_index(
+ transactions: &[BlindedTransaction],
+ utxos: &BTreeMap<OutPoint, TxOutput>,
+) -> Option<usize> {
transactions
.iter()
.enumerate()
+ .filter(|(_, transaction)| {
+ let mut utxos = utxos.clone();
+ spend_blinded_inputs(transaction, &mut utxos).is_ok()
+ })
.max_by(|(_, left), (_, right)| {
blinded_fee_rate_key(left)
.cmp(&blinded_fee_rate_key(right))
@@ -4553,6 +4751,7 @@ fn decrypt_blinded_transaction(
let plaintext = decrypt_blinded_payload(
&key,
&nonce,
+ &signed_blinded_inputs(&unsigned_inputs(&transaction.inputs), ""),
transaction.fee,
transaction.expires_at_height,
&ciphertext,
@@ -4560,12 +4759,71 @@ fn decrypt_blinded_transaction(
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")
+ let payload = serde_json::from_slice(&plaintext)
+ .context("failed to decode blinded transaction payload")?;
+ transaction_from_blinded_payload(payload, &transaction.inputs, transaction.fee)
+}
+
+fn blinded_payload_from_transaction(
+ transaction: &Transaction,
+) -> Result<BlindedTransactionPayload> {
+ match transaction {
+ Transaction::Transfer {
+ outputs, signature, ..
+ } => Ok(BlindedTransactionPayload::Transfer {
+ outputs: outputs.clone(),
+ signature: signature.clone(),
+ }),
+ Transaction::Burn {
+ change,
+ amount,
+ signature,
+ ..
+ } => Ok(BlindedTransactionPayload::Burn {
+ change: change.clone(),
+ amount: *amount,
+ signature: signature.clone(),
+ }),
+ Transaction::Mine { .. } => bail!("mine actions are public and cannot be blinded"),
+ }
+}
+
+fn transaction_from_blinded_payload(
+ payload: BlindedTransactionPayload,
+ envelope_inputs: &[TxInput],
+ fee: Amount,
+) -> Result<Transaction> {
+ match payload {
+ BlindedTransactionPayload::Transfer { outputs, signature } => {
+ let inputs = signed_blinded_inputs(&unsigned_inputs(envelope_inputs), &signature);
+ Ok(Transaction::Transfer {
+ inputs,
+ outputs,
+ fee,
+ signature,
+ })
+ }
+ BlindedTransactionPayload::Burn {
+ change,
+ amount,
+ signature,
+ } => {
+ let inputs = signed_blinded_inputs(&unsigned_inputs(envelope_inputs), &signature);
+ Ok(Transaction::Burn {
+ inputs,
+ change,
+ amount,
+ fee,
+ signature,
+ })
+ }
+ }
}
fn encrypt_blinded_payload(
key: &[u8; BLINDED_KEY_BYTES],
nonce: &[u8; BLINDED_NONCE_BYTES],
+ inputs: &[TxInput],
fee: Amount,
expires_at_height: u64,
plaintext: &[u8],
@@ -4576,7 +4834,7 @@ fn encrypt_blinded_payload(
Nonce::from_slice(nonce),
chacha20poly1305::aead::Payload {
msg: plaintext,
- aad: blinded_payload_aad(fee, expires_at_height).as_bytes(),
+ aad: blinded_payload_aad(inputs, fee, expires_at_height).as_bytes(),
},
)
.map_err(|_| anyhow!("failed to encrypt blinded transaction payload"))
@@ -4585,6 +4843,7 @@ fn encrypt_blinded_payload(
fn decrypt_blinded_payload(
key: &[u8; BLINDED_KEY_BYTES],
nonce: &[u8; BLINDED_NONCE_BYTES],
+ inputs: &[TxInput],
fee: Amount,
expires_at_height: u64,
ciphertext: &[u8],
@@ -4595,14 +4854,17 @@ fn decrypt_blinded_payload(
Nonce::from_slice(nonce),
chacha20poly1305::aead::Payload {
msg: ciphertext,
- aad: blinded_payload_aad(fee, expires_at_height).as_bytes(),
+ aad: blinded_payload_aad(inputs, 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_payload_aad(inputs: &[TxInput], fee: Amount, expires_at_height: u64) -> String {
+ format!(
+ "iuna-blinded-payload-v3:{}:{fee}:{expires_at_height}",
+ canonical_inputs(&unsigned_inputs(inputs))
+ )
}
fn blinded_transaction_commitment(transaction: &BlindedTransaction) -> Result<String> {
@@ -4611,6 +4873,51 @@ fn blinded_transaction_commitment(transaction: &BlindedTransaction) -> Result<St
Ok(hex_hash(without_commitment.canonical()))
}
+fn blinded_transaction_signing_payload(transaction: &BlindedTransaction) -> String {
+ format!(
+ "blinded-tx-inputs:{}:{}:{}:{}:{}:{}:{}",
+ canonical_inputs(&unsigned_inputs(&transaction.inputs)),
+ transaction.fee,
+ transaction.encrypted_size,
+ transaction.expires_at_height,
+ transaction.nonce,
+ transaction.ciphertext,
+ transaction.payload_hash
+ )
+}
+
+fn verify_blinded_input_signatures(transaction: &BlindedTransaction) -> Result<()> {
+ if transaction.inputs.is_empty() {
+ return Ok(());
+ }
+ ensure_single_input_owner_for_inputs(&transaction.inputs)?;
+ let signature = transaction.inputs[0].signature.clone();
+ if !transaction
+ .inputs
+ .iter()
+ .all(|input| input.signature == signature)
+ {
+ bail!("blinded transaction input signature mismatch");
+ }
+ let mut unsigned = transaction.clone();
+ for input in &mut unsigned.inputs {
+ input.signature.clear();
+ }
+ let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(&transaction.inputs[0].owner)
+ .context("invalid blinded transaction input owner")?;
+ let signature = decode_hex_array::<SIGNATURE_BYTES>(&signature)
+ .context("invalid blinded input signature")?;
+ let verifying_key =
+ VerifyingKey::from_bytes(&public_key).context("invalid blinded input public key")?;
+ let signature = Signature::from_bytes(&signature);
+ verifying_key
+ .verify(
+ blinded_transaction_signing_payload(&unsigned).as_bytes(),
+ &signature,
+ )
+ .context("blinded transaction input signature is invalid")
+}
+
fn credit_blinded_fee_outputs(
utxos: &mut BTreeMap<OutPoint, TxOutput>,
active: &ActiveBlindedTransaction,
@@ -4623,7 +4930,11 @@ fn credit_blinded_fee_outputs(
return Ok(());
}
let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS);
- let reveal_finalizer_fee = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS);
+ let reveal_finalizer_fee = if reveal_bundle_signatures.is_empty() {
+ 0
+ } else {
+ blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS)
+ };
let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS);
let mut outputs = Vec::new();
if committer_fee > 0 {
@@ -4673,6 +4984,13 @@ fn blinded_fee_share(fee: Amount, bps: u64) -> Amount {
((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount
}
+fn blinded_envelope_fee_for_transaction(transaction: &Transaction) -> Amount {
+ match transaction {
+ Transaction::Mine { .. } => 0,
+ Transaction::Transfer { .. } | Transaction::Burn { .. } => transaction.fee(),
+ }
+}
+
fn fee_reward(transactions: &[Transaction]) -> Result<Amount> {
transactions.iter().try_fold(0_u64, |total, tx| {
total.checked_add(tx.fee()).context("block fees overflow")
@@ -4702,6 +5020,104 @@ fn spend_inputs(
Ok(total)
}
+fn spend_blinded_inputs(
+ transaction: &BlindedTransaction,
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+) -> Result<Vec<TxOutput>> {
+ verify_blinded_input_signatures(transaction)?;
+ if transaction.inputs.is_empty() {
+ return Ok(Vec::new());
+ }
+ let mut seen = BTreeSet::new();
+ let mut locked = Vec::new();
+ for input in &transaction.inputs {
+ if !seen.insert(input.outpoint.clone()) {
+ bail!("duplicate input in blinded transaction");
+ }
+ let output = utxos.remove(&input.outpoint).with_context(|| {
+ format!(
+ "blinded transaction spends missing output {}",
+ input.outpoint.id()
+ )
+ })?;
+ if output.address != input.owner {
+ bail!("blinded transaction input owner does not match spent output");
+ }
+ locked.push(output);
+ }
+ let locked_total = locked.iter().try_fold(0_u64, |total, output| {
+ total
+ .checked_add(output.amount)
+ .context("blinded transaction locked input total overflows")
+ })?;
+ if transaction.fee > locked_total {
+ bail!("blinded transaction fee exceeds locked inputs");
+ }
+ Ok(locked)
+}
+
+fn blinded_locked_output_total(active: &ActiveBlindedTransaction) -> Result<Amount> {
+ active
+ .locked_outputs
+ .iter()
+ .try_fold(0_u64, |total, output| {
+ total
+ .checked_add(output.amount)
+ .context("blinded transaction locked input total overflows")
+ })
+}
+
+fn blinded_reveal_inputs_match(
+ active: &ActiveBlindedTransaction,
+ transaction: &Transaction,
+) -> bool {
+ let visible = active
+ .transaction
+ .inputs
+ .iter()
+ .map(TxInput::without_signature)
+ .collect::<Vec<_>>();
+ let revealed = transaction
+ .inputs()
+ .iter()
+ .map(TxInput::without_signature)
+ .collect::<Vec<_>>();
+ visible == revealed
+}
+
+fn credit_expired_blinded_outputs(
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+ active: &ActiveBlindedTransaction,
+) -> Result<()> {
+ let Some(first_input) = active.transaction.inputs.first() else {
+ return Ok(());
+ };
+ let input_total = blinded_locked_output_total(active)?;
+ if active.transaction.fee > input_total {
+ bail!("blinded transaction fee exceeds locked inputs");
+ }
+ let change = input_total - active.transaction.fee;
+ let mut outputs = Vec::new();
+ if change > 0 {
+ outputs.push((
+ blinded_expiry_change_outpoint(&active.transaction.commitment),
+ TxOutput {
+ address: first_input.owner.clone(),
+ amount: change,
+ },
+ ));
+ }
+ let tx_outputs = outputs
+ .iter()
+ .map(|(_, output)| output.clone())
+ .collect::<Vec<_>>();
+ ensure_outputs_do_not_overflow(utxos, &tx_outputs)?;
+ for (outpoint, output) in outputs {
+ utxos.insert(outpoint, output);
+ }
+ Ok(())
+}
+
fn transaction_has_missing_inputs(
transaction: &Transaction,
utxos: &BTreeMap<OutPoint, TxOutput>,
@@ -4716,14 +5132,14 @@ fn ensure_single_input_owner(transaction: &Transaction) -> Result<()> {
if matches!(transaction, Transaction::Mine { .. }) {
return Ok(());
}
- let Some(first) = transaction.inputs().first() else {
+ ensure_single_input_owner_for_inputs(transaction.inputs())
+}
+
+fn ensure_single_input_owner_for_inputs(inputs: &[TxInput]) -> Result<()> {
+ let Some(first) = inputs.first() else {
bail!("transaction has no inputs");
};
- if transaction
- .inputs()
- .iter()
- .any(|input| input.owner != first.owner)
- {
+ if inputs.iter().any(|input| input.owner != first.owner) {
bail!("transaction inputs must have one owner");
}
Ok(())
@@ -4876,6 +5292,13 @@ fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutP
}
}
+fn blinded_expiry_change_outpoint(commitment: &str) -> OutPoint {
+ OutPoint {
+ txid: commitment.to_string(),
+ index: 0,
+ }
+}
+
fn validate_genesis_block(block: &Block) -> Result<()> {
if block.height != 0 {
bail!("genesis block height must be 0");
@@ -4975,7 +5398,12 @@ pub fn revealed_blinded_transactions(
)
})?;
let transaction = decrypt_blinded_transaction(&active_transaction.transaction, reveal)?;
- if transaction.fee() != active_transaction.transaction.fee {
+ if matches!(transaction, Transaction::Mine { .. }) {
+ bail!("block {} blinded reveal is a mine action", block.height);
+ }
+ if blinded_envelope_fee_for_transaction(&transaction)
+ != active_transaction.transaction.fee
+ {
bail!(
"block {} blinded reveal fee does not match envelope",
block.height
@@ -4997,6 +5425,7 @@ pub fn revealed_blinded_transactions(
transaction.commitment.clone(),
ActiveBlindedTransaction {
transaction: transaction.clone(),
+ locked_outputs: Vec::new(),
included_height: block.height,
included_by: block.miner.clone(),
},
@@ -5577,6 +6006,7 @@ mod tests {
let active = ActiveBlindedTransaction {
transaction: BlindedTransaction {
commitment: commitment.clone(),
+ inputs: Vec::new(),
fee: 1,
encrypted_size: 1,
expires_at_height: 2,
@@ -5584,6 +6014,7 @@ mod tests {
ciphertext: "03".to_string(),
payload_hash: "04".repeat(32),
},
+ locked_outputs: Vec::new(),
included_height: 1,
included_by: committer.address().to_string(),
};
@@ -5603,6 +6034,47 @@ mod tests {
}
#[test]
+ fn blinded_fee_split_pays_no_reveal_finalizer_without_signed_reveal_lists() {
+ let committer = Wallet::from_seed("blinded-no-list-committer");
+ let executor = Wallet::from_seed("blinded-no-list-executor");
+ let commitment = "06".repeat(32);
+ let active = ActiveBlindedTransaction {
+ transaction: BlindedTransaction {
+ commitment: commitment.clone(),
+ inputs: Vec::new(),
+ fee: 100,
+ encrypted_size: 1,
+ expires_at_height: 2,
+ nonce: "02".repeat(BLINDED_NONCE_BYTES),
+ ciphertext: "03".to_string(),
+ payload_hash: "04".repeat(32),
+ },
+ locked_outputs: Vec::new(),
+ included_height: 1,
+ included_by: committer.address().to_string(),
+ };
+ let transaction = Transaction::Transfer {
+ inputs: Vec::new(),
+ outputs: Vec::new(),
+ fee: 100,
+ signature: String::new(),
+ };
+ let mut utxos = BTreeMap::new();
+
+ credit_blinded_fee_outputs(&mut utxos, &active, executor.address(), &transaction, &[])
+ .unwrap();
+
+ assert_eq!(
+ utxos.get(&blinded_committer_fee_outpoint(&commitment)),
+ Some(&TxOutput {
+ address: committer.address().to_string(),
+ amount: 35,
+ })
+ );
+ assert!(!utxos.contains_key(&blinded_executor_fee_outpoint(&commitment)));
+ }
+
+ #[test]
fn blinded_fee_split_pays_committer_executor_and_reveal_bundle_signers() {
let committer = Wallet::from_seed("blinded-scale-committer");
let executor = Wallet::from_seed("blinded-scale-executor");
@@ -5612,6 +6084,7 @@ mod tests {
let active = ActiveBlindedTransaction {
transaction: BlindedTransaction {
commitment: commitment.clone(),
+ inputs: Vec::new(),
fee: 7,
encrypted_size: 1,
expires_at_height: 2,
@@ -5619,6 +6092,7 @@ mod tests {
ciphertext: "03".to_string(),
payload_hash: "04".repeat(32),
},
+ locked_outputs: Vec::new(),
included_height: 1,
included_by: committer.address().to_string(),
};
@@ -6519,6 +6993,136 @@ mod tests {
}
#[test]
+ fn blinded_utxo_commit_exposes_and_locks_inputs_until_reveal_or_expiry() {
+ let alice = Wallet::from_seed("blinded-lock-alice");
+ let bob = Wallet::from_seed("blinded-lock-bob");
+ let mut ledger = ledger_with_wallet_utxos(&alice, &[10]);
+ let transfer = ledger.build_transfer(&alice, bob.address(), 3, 2).unwrap();
+ let visible_inputs = transfer.inputs().to_vec();
+
+ let blinded = ledger
+ .build_blinded_transaction(&alice, transfer, ledger.height() + 4)
+ .unwrap();
+
+ assert_eq!(blinded.transaction.inputs.len(), visible_inputs.len());
+ assert_eq!(
+ unsigned_inputs(&blinded.transaction.inputs),
+ unsigned_inputs(&visible_inputs)
+ );
+ ledger
+ .submit_blinded_transaction(blinded.transaction)
+ .unwrap();
+ let error = ledger
+ .build_transfer(&alice, bob.address(), 1, 0)
+ .unwrap_err();
+ assert!(format!("{error:#}").contains("insufficient funds"));
+ }
+
+ #[test]
+ fn blinded_payload_omits_visible_inputs_and_reconstructs_transaction_on_reveal() {
+ let alice = Wallet::from_seed("blinded-compact-payload-alice");
+ let bob = Wallet::from_seed("blinded-compact-payload-bob");
+ let ledger = ledger_with_wallet_utxos(&alice, &[10]);
+ let transfer = ledger.build_transfer(&alice, bob.address(), 3, 2).unwrap();
+ let full_transaction_bytes = serde_json::to_vec(&transfer).unwrap().len();
+ let blinded = ledger
+ .build_blinded_transaction(&alice, transfer.clone(), ledger.height() + 4)
+ .unwrap();
+ let key = decode_hex_array::<BLINDED_KEY_BYTES>(&blinded.reveal.key).unwrap();
+ let nonce = decode_hex_array::<BLINDED_NONCE_BYTES>(&blinded.transaction.nonce).unwrap();
+ let ciphertext = decode_hex(&blinded.transaction.ciphertext).unwrap();
+
+ let plaintext = decrypt_blinded_payload(
+ &key,
+ &nonce,
+ &signed_blinded_inputs(&unsigned_inputs(&blinded.transaction.inputs), ""),
+ blinded.transaction.fee,
+ blinded.transaction.expires_at_height,
+ &ciphertext,
+ )
+ .unwrap();
+ let payload: serde_json::Value = serde_json::from_slice(&plaintext).unwrap();
+ let revealed = decrypt_blinded_transaction(&blinded.transaction, &blinded.reveal).unwrap();
+
+ assert_eq!(
+ payload.get("kind").and_then(|kind| kind.as_str()),
+ Some("transfer")
+ );
+ assert!(payload.get("inputs").is_none());
+ assert!(plaintext.len() < full_transaction_bytes);
+ assert_eq!(revealed, transfer);
+ }
+
+ #[test]
+ fn unrevealed_blinded_utxo_commit_burns_fee_and_returns_change() {
+ let alice = Wallet::from_seed("blinded-expiry-alice");
+ let bob = Wallet::from_seed("blinded-expiry-bob");
+ let carol = Wallet::from_seed("blinded-expiry-carol");
+ let finalizers = [alice.clone(), bob.clone()];
+ let carol_balance = 10 * MICRO_IUNA;
+ let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, carol_balance)]);
+ let fee = 100;
+ let blinded = ledger
+ .build_blinded_burn(&carol, 3, fee, ledger.height() + 2)
+ .unwrap();
+ let commitment = blinded.transaction.commitment.clone();
+ ledger
+ .submit_blinded_transaction(blinded.transaction)
+ .unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
+
+ assert_eq!(ledger.balance_of(carol.address()), 0);
+ queue_next_leader_burn(&mut ledger, &finalizers);
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, 2);
+
+ assert_eq!(
+ ledger
+ .utxos
+ .get(&blinded_committer_fee_outpoint(&commitment)),
+ None
+ );
+ assert_eq!(
+ ledger
+ .utxos
+ .get(&blinded_expiry_change_outpoint(&commitment)),
+ Some(&TxOutput {
+ address: carol.address().to_string(),
+ amount: carol_balance - fee,
+ })
+ );
+ assert_eq!(ledger.balance_of(carol.address()), carol_balance - fee);
+ }
+
+ #[test]
+ fn fee_bearing_blinded_commit_without_inputs_is_rejected() {
+ let alice = Wallet::from_seed("blinded-no-input-fee-alice");
+ let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
+ let mut blinded = ledger
+ .build_blinded_burn(&alice, 1, 1, ledger.height() + 4)
+ .unwrap()
+ .transaction;
+ blinded.inputs.clear();
+ blinded.commitment = blinded_transaction_commitment(&blinded).unwrap();
+
+ let error = ledger.submit_blinded_transaction(blinded).unwrap_err();
+
+ assert!(format!("{error:#}").contains("must lock visible inputs"));
+ }
+
+ #[test]
+ fn mine_actions_cannot_be_blinded() {
+ let alice = Wallet::from_seed("blinded-mine-collateral-alice");
+ let ledger = ledger_with_finalizers(&[alice.clone()], &[]);
+ let mine = ledger.build_mine(alice.address()).unwrap();
+ let error = ledger
+ .build_blinded_transaction(&alice, mine, ledger.height() + 4)
+ .unwrap_err();
+
+ assert!(format!("{error:#}").contains("mine actions are public"));
+ }
+
+ #[test]
fn reveal_bundle_hashes_are_bound_to_next_block_vdf_seed() {
let alice = Wallet::from_seed("bundle-seed-alice");
let bob = Wallet::from_seed("bundle-seed-bob");
diff --git a/tests/iuna.rs b/tests/iuna.rs
@@ -706,6 +706,13 @@ fn automatic_mining_caps_burn_to_spendable_balance_after_fee() {
let alice = Wallet::from_seed("auto-burn-cap-alice");
let mut allocations = BTreeMap::new();
allocations.insert(alice.address().to_string(), BLOCK_REWARD);
+ let planning_node = NodeCore::new(NodeConfig {
+ wallet: alice.clone(),
+ genesis_allocations: allocations.clone(),
+ vdf_rounds: 10,
+ burn_per_block: BLOCK_REWARD + iuna(50),
+ burn_fee: DEFAULT_FEE_PER_BYTE,
+ });
let mut node = NodeCore::new(NodeConfig {
wallet: alice.clone(),
genesis_allocations: allocations,
@@ -718,7 +725,10 @@ fn automatic_mining_caps_burn_to_spendable_balance_after_fee() {
let burned = outcome.burned.as_ref().unwrap();
let unspent = BLOCK_REWARD - burned.amount() - burned.fee();
- assert!(unspent <= DEFAULT_FEE_PER_BYTE);
+ let next_amount = burned.amount() + 1;
+ if let Ok(next_estimate) = planning_node.estimate_burn_fee(next_amount, DEFAULT_FEE_PER_BYTE) {
+ assert!(next_amount + next_estimate.fee > BLOCK_REWARD);
+ }
assert!(burned.fee() > burned.economic_size_bytes() as u64 * DEFAULT_FEE_PER_BYTE);
assert!(outcome.block.is_some());
assert_eq!(
@@ -872,11 +882,16 @@ fn pow_only_node_gossips_mine_action_to_pob_only_finalizer() {
alice_node.receive(envelope).unwrap();
}
- assert!(alice_node.ledger().pending().is_empty());
- assert_eq!(alice_node.ledger().pending_blinded_transactions().len(), 1);
+ assert_eq!(alice_node.ledger().pending().len(), 1);
+ assert!(
+ alice_node
+ .ledger()
+ .pending_blinded_transactions()
+ .is_empty()
+ );
assert_eq!(
- alice_node.ledger().pending_blinded_transactions()[0].fee,
- mine.fee()
+ alice_node.ledger().pending()[0].signature(),
+ mine.signature()
);
}
@@ -1786,7 +1801,7 @@ fn mempool_gossip_splits_blinded_batches_at_receiver_limit() {
let tx = alice_node.ledger().build_burn(wallet, 1, 0).unwrap();
let built = alice_node
.ledger()
- .build_blinded_transaction(tx, 20)
+ .build_blinded_transaction(wallet, tx, 20)
.unwrap();
alice_node
.receive_blinded_transaction(built.transaction)
@@ -1950,6 +1965,8 @@ fn multiple_peers_can_contribute_blinded_burns_to_lottery_ranks() {
4
);
queue_plaintext_burn(network.node_mut("finalizer").unwrap(), &finalizer, 1);
+ network.gossip_mempools_once().unwrap();
+ network.deliver_until_idle().unwrap();
let reveal_block = network
.node_mut("finalizer")
.unwrap()
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -1552,6 +1552,11 @@ window.iunaApp = function iunaApp() {
return tx?.kind === "blinded" || tx?.kind === "reveal";
},
+ txFeeLabel(tx) {
+ if (tx?.kind === "reveal") return "unknown until reveal";
+ return `IUNA ${this.amountLabel(tx?.fee ?? 0)}`;
+ },
+
txPillLabel(tx) {
return tx?.revealed ? "revealed" : (tx?.kind || "-");
},
@@ -1733,6 +1738,10 @@ window.iunaApp = function iunaApp() {
return this.blockTransactions(block).filter((tx) => tx.kind === "transfer").length;
},
+ blockCommitCount(block) {
+ return this.blockTransactions(block).filter((tx) => tx.kind === "blinded").length;
+ },
+
blockMineCount(block) {
return this.blockTransactions(block).filter((tx) => tx.kind === "mine").length;
},
@@ -1756,6 +1765,11 @@ window.iunaApp = function iunaApp() {
return `${count} transfer${count === 1 ? "" : "s"}`;
},
+ commitCountLabel(block) {
+ const count = this.blockCommitCount(block);
+ return `${count} commit${count === 1 ? "" : "s"}`;
+ },
+
mineCountLabel(block) {
const count = this.blockMineCount(block);
return `${count} mine${count === 1 ? "" : "s"}`;