commit c5fb08d38133f9cb1e74de581287fb78e9da6f47
parent 900d37b4a5ca3aa3942a794e34900e5b856ebe3f
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Fri, 24 Jul 2026 13:18:27 +0200
Add PoW mine actions for issuance
Diffstat:
5 files changed, 312 insertions(+), 54 deletions(-)
diff --git a/assets/luun-ui.js b/assets/luun-ui.js
@@ -469,6 +469,18 @@ window.luunApp = function luunApp() {
}
},
+ async minePowReward() {
+ try {
+ await this.postForm(
+ "/api/mine",
+ {},
+ `Queued PoW mine action for ${this.amountLabel(this.status.chain?.mine_reward ?? 0)} LUUN`
+ );
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ }
+ },
+
automaticBurnFeeDraft() {
return this.parseLuunAmount(this.burnFeeDraft);
},
@@ -487,7 +499,7 @@ window.luunApp = function luunApp() {
latestBlockReward() {
const latest = this.latestBlock();
- return Math.max(0, Math.trunc(Number(latest?.reward ?? this.status.chain?.block_reward ?? 0)));
+ return Math.max(0, Math.trunc(Number(latest?.reward ?? 0)));
},
ticketWindow() {
@@ -745,6 +757,10 @@ window.luunApp = function luunApp() {
return block.transactions.filter((tx) => tx.kind === "transfer").length;
},
+ blockMineCount(block) {
+ return block.transactions.filter((tx) => tx.kind === "mine").length;
+ },
+
burnCountLabel(block) {
const count = this.blockBurnCount(block);
return `${count} burn${count === 1 ? "" : "s"}`;
@@ -755,6 +771,11 @@ window.luunApp = function luunApp() {
return `${count} transfer${count === 1 ? "" : "s"}`;
},
+ mineCountLabel(block) {
+ const count = this.blockMineCount(block);
+ return `${count} mine${count === 1 ? "" : "s"}`;
+ },
+
blockMinerLabel(block) {
const miner = this.short(block.miner);
return block.miner === this.status.wallet_address ? `${miner} (me)` : miner;
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -189,6 +189,7 @@ pub async fn serve(
"/api/settings/burn-per-block",
post(api_burn_per_block_form),
)
+ .route("/api/mine", post(api_mine_form))
.route("/api/transfer", post(api_transfer_form))
.route("/settings/burn-per-block", post(burn_per_block_form))
.route("/transfer", post(transfer_form))
@@ -350,6 +351,11 @@ async fn api_burn_per_block_form(
action_json(result)
}
+async fn api_mine_form(State(state): State<HttpState>) -> Json<ActionResponse> {
+ let result = mine_pow_reward(&state).await;
+ action_json(result)
+}
+
async fn burn_per_block_form(
State(state): State<HttpState>,
Form(form): Form<BurnSettingsForm>,
@@ -513,6 +519,23 @@ fn wallet_transaction_row(
"sent"
},
}),
+ Transaction::Mine {
+ output, signature, ..
+ } if output.address == wallet => Some(WalletTransactionRow {
+ kind: "mine",
+ from: "pow".to_string(),
+ to: Some(output.address.clone()),
+ amount: output.amount,
+ fee: 0,
+ inputs: Vec::new(),
+ outputs: vec![output.clone()],
+ change: Vec::new(),
+ signature: signature.clone(),
+ status,
+ block_height,
+ block_miner,
+ direction: "received",
+ }),
_ => None,
}
}
@@ -587,6 +610,19 @@ fn ui_transaction(
change: change.clone(),
signature: signature.clone(),
},
+ Transaction::Mine {
+ output, signature, ..
+ } => UiTransaction {
+ kind: "mine",
+ from: "pow".to_string(),
+ to: Some(output.address.clone()),
+ amount: output.amount,
+ fee: 0,
+ inputs: Vec::new(),
+ outputs: vec![output.clone()],
+ change: Vec::new(),
+ signature: signature.clone(),
+ },
}
}
@@ -654,6 +690,7 @@ fn index_transaction_outputs(
let created_outputs = match transaction {
Transaction::Transfer { outputs, .. } => outputs,
Transaction::Burn { change, .. } => change,
+ Transaction::Mine { output, .. } => std::slice::from_ref(output),
};
for (index, output) in created_outputs.iter().enumerate() {
outputs.insert(
@@ -763,6 +800,20 @@ async fn transfer(state: &HttpState, form: TransferForm) -> Result<()> {
}
}
+async fn mine_pow_reward(state: &HttpState) -> Result<()> {
+ let result = {
+ let mut node = state.node.lock().await;
+ let result = node.mine_pow_reward();
+ let outbox = node.drain_outbox();
+ (result, outbox)
+ };
+
+ match result.0 {
+ Ok(_) => state.gossip.broadcast(result.1).await,
+ Err(error) => Err(error),
+ }
+}
+
fn validate_transfer_form(form: TransferForm) -> Result<(String, Amount, Amount, Vec<OutPoint>)> {
let to = form.to.trim();
if to.is_empty() {
@@ -934,6 +985,8 @@ const INDEX_HTML: &str = r#"<!doctype html>
.burn-range { width: 100%; min-width: 0; accent-color: #d5f55f; }
.break-even-marker { position: absolute; top: 2px; bottom: 2px; width: 2px; transform: translateX(-1px); background: #ffd070; box-shadow: 0 0 0 1px #111316, 0 0 0 4px rgba(255, 208, 112, .18); pointer-events: none; }
.burn-slider-note { color: #a8b2b8; font-size: 12px; line-height: 1.4; }
+ .mine-action-row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; align-items: center; }
+ .mine-action-meta { color: #9eb3bc; font-size: 13px; }
.receive-address { display: grid; gap: 8px; }
.address-box { border: 1px solid #2f363c; border-radius: 8px; padding: 11px; background: #111316; }
.panel-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; }
@@ -983,6 +1036,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 8px; font-size: 12px; font-weight: 800; background: #2b3136; color: #d6dee2; }
.pill.burn { background: #332918; color: #ffd070; }
.pill.transfer { background: #17312a; color: #8de9cd; }
+ .pill.mine { background: #172a34; color: #8bdcff; }
.mempool-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.mempool-item { flex: 0 0 220px; }
.tx-modal { width: min(940px, 100%); max-height: calc(100vh - 44px); overflow: auto; border: 1px solid #3b4448; border-radius: 8px; padding: 16px; background: #181b1f; box-shadow: 0 24px 80px rgba(0, 0, 0, .46); }
@@ -1140,7 +1194,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<section x-show="tab === 'mining'">
<div class="page-title">
- <div class="muted">Automatic VDF-paced block production</div>
+ <div class="muted">PoB/VDF block production with PoW issuance actions</div>
</div>
<div class="mining-grid">
<div class="panel">
@@ -1171,6 +1225,13 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
</form>
</div>
+ <div class="panel">
+ <h3>PoW issuance</h3>
+ <div class="mine-action-row">
+ <div class="mine-action-meta">Mine action reward: LUUN <span x-text="amountLabel(status.chain?.mine_reward ?? 0)"></span> / difficulty <span x-text="status.launch_profile?.mine_difficulty_bits ?? '-'"></span> bits</div>
+ <button class="primary" type="button" @click="minePowReward">Mine coin</button>
+ </div>
+ </div>
</div>
</section>
@@ -1242,6 +1303,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="mineCountLabel(block)"></span>
</div>
<div class="block-miner" x-text="blockMinerLabel(block)"></div>
</button>
diff --git a/src/app.rs b/src/app.rs
@@ -113,6 +113,7 @@ pub struct LaunchProfileStatus {
pub profile_hash: String,
pub ticket_maturity_delay_heights: u64,
pub ticket_expiry_window_heights: u64,
+ pub mine_difficulty_bits: u32,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -340,6 +341,7 @@ impl NodeCore {
profile_hash: chain.launch_profile_hash.clone(),
ticket_maturity_delay_heights: launch_profile.ticket_maturity_delay_heights,
ticket_expiry_window_heights: launch_profile.ticket_expiry_window_heights,
+ mine_difficulty_bits: launch_profile.mine_difficulty_bits,
},
mining: MiningStatus {
automatic: true,
@@ -360,10 +362,7 @@ impl NodeCore {
let launch_profile = self.ledger.launch_profile();
let chain = self.ledger.chain();
let window = launch_profile.ticket_expiry_window_heights.max(1);
- let last_payout = chain
- .last()
- .map(|block| block.reward)
- .unwrap_or_else(|| self.ledger.status().block_reward);
+ let last_payout = chain.last().map(|block| block.reward).unwrap_or(0);
let estimated_active_burn = estimated_active_burn_for_next_block(chain, launch_profile);
let average_active_burn_rate_microluun =
u128::from(estimated_active_burn) / u128::from(window);
@@ -450,6 +449,14 @@ impl NodeCore {
Ok(tx)
}
+ pub fn mine_pow_reward(&mut self) -> Result<Transaction> {
+ let tx = self.ledger.build_mine(self.wallet.address())?;
+ if self.ledger.submit_transaction(tx.clone())? {
+ self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
+ }
+ Ok(tx)
+ }
+
pub fn receive_transaction(&mut self, tx: Transaction) -> Result<bool> {
let accepted = self.ledger.submit_transaction(tx.clone())?;
if accepted {
diff --git a/src/domain.rs b/src/domain.rs
@@ -8,9 +8,11 @@ use sha2::{Digest, Sha256};
pub type Amount = u64;
pub const MICRO_LUUN: Amount = 1_000_000;
pub const BLOCK_REWARD: Amount = 100 * MICRO_LUUN;
+pub const MINE_REWARD: Amount = MICRO_LUUN;
pub const DEFAULT_TRANSACTION_FEE: Amount = MICRO_LUUN;
pub const MAX_BLOCK_BYTES: usize = 100_000;
pub const VDF_TARGET_BLOCK_MS: u64 = 60_000;
+pub const MINE_DIFFICULTY_BITS: u32 = 12;
const MAX_PENDING_TRANSACTIONS: usize = 10_000;
const MAX_BLOCK_TRANSACTIONS: usize = 1_000;
const DEFAULT_TICKET_MATURITY_DELAY: u64 = 3;
@@ -98,6 +100,13 @@ pub enum Transaction {
fee: Amount,
signature: String,
},
+ Mine {
+ output: TxOutput,
+ anchor: String,
+ nonce: u64,
+ difficulty_bits: u32,
+ signature: String,
+ },
}
impl Transaction {
@@ -155,6 +164,7 @@ impl Transaction {
.first()
.map(|input| input.owner.as_str())
.unwrap_or(""),
+ Self::Mine { output, .. } => output.address.as_str(),
}
}
@@ -162,6 +172,7 @@ impl Transaction {
match self {
Self::Transfer { outputs, .. } => outputs.first().map(|output| output.address.as_str()),
Self::Burn { .. } => None,
+ Self::Mine { output, .. } => Some(output.address.as_str()),
}
}
@@ -171,16 +182,21 @@ impl Transaction {
outputs.first().map(|output| output.amount).unwrap_or(0)
}
Self::Burn { amount, .. } => *amount,
+ Self::Mine { output, .. } => output.amount,
}
}
pub fn fee(&self) -> Amount {
match self {
Self::Transfer { fee, .. } | Self::Burn { fee, .. } => *fee,
+ Self::Mine { .. } => 0,
}
}
pub fn total_debit(&self) -> Result<Amount> {
+ if matches!(self, Self::Mine { .. }) {
+ return Ok(0);
+ }
self.amount()
.checked_add(self.fee())
.context("transaction amount plus fee overflows")
@@ -189,6 +205,7 @@ impl Transaction {
pub fn signature(&self) -> &str {
match self {
Self::Transfer { signature, .. } | Self::Burn { signature, .. } => signature,
+ Self::Mine { signature, .. } => signature,
}
}
@@ -226,10 +243,34 @@ impl Transaction {
fee: *fee,
}
.canonical(),
+ Self::Mine {
+ output,
+ anchor,
+ nonce,
+ difficulty_bits,
+ ..
+ } => mine_payload(output, anchor, *nonce, *difficulty_bits),
}
}
fn verify_signature(&self) -> Result<()> {
+ if let Self::Mine {
+ output,
+ anchor,
+ nonce,
+ difficulty_bits,
+ signature,
+ } = self
+ {
+ let expected = mine_signature(output, anchor, *nonce, *difficulty_bits);
+ if *signature != expected {
+ bail!("mine transaction proof hash is invalid");
+ }
+ if !hash_meets_difficulty(signature, *difficulty_bits) {
+ bail!("mine transaction proof does not meet difficulty");
+ }
+ return Ok(());
+ }
if self.signature().starts_with("luun-genesis-burn:") || self.inputs_are_genesis_signed() {
return Ok(());
}
@@ -256,6 +297,7 @@ impl Transaction {
fn inputs(&self) -> &[TxInput] {
match self {
Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs,
+ Self::Mine { .. } => &[],
}
}
@@ -263,6 +305,7 @@ impl Transaction {
match self {
Self::Transfer { outputs, .. } => outputs,
Self::Burn { change, .. } => change,
+ Self::Mine { output, .. } => std::slice::from_ref(output),
}
}
@@ -399,6 +442,41 @@ fn canonical_outputs(outputs: &[TxOutput]) -> String {
.join("|")
}
+fn mine_payload(output: &TxOutput, anchor: &str, nonce: u64, difficulty_bits: u32) -> String {
+ format!(
+ "luun-mine:{}:{}:{}:{}",
+ output.address, output.amount, anchor, nonce
+ ) + &format!(":{difficulty_bits}")
+}
+
+fn mine_signature(output: &TxOutput, anchor: &str, nonce: u64, difficulty_bits: u32) -> String {
+ hex_hash(mine_payload(output, anchor, nonce, difficulty_bits))
+}
+
+fn hash_meets_difficulty(hash: &str, difficulty_bits: u32) -> bool {
+ let full_zero_nibbles = (difficulty_bits / 4) as usize;
+ let remaining_bits = difficulty_bits % 4;
+ if hash.len() < full_zero_nibbles + usize::from(remaining_bits > 0) {
+ return false;
+ }
+ if !hash.as_bytes()[..full_zero_nibbles]
+ .iter()
+ .all(|byte| *byte == b'0')
+ {
+ return false;
+ }
+ if remaining_bits == 0 {
+ return true;
+ }
+ let Some(next) = hash.as_bytes().get(full_zero_nibbles).copied() else {
+ return false;
+ };
+ let Some(value) = (next as char).to_digit(16) else {
+ return false;
+ };
+ value < (1 << (4 - remaining_bits))
+}
+
fn pending_spent_outpoints(pending: &[Transaction]) -> BTreeSet<OutPoint> {
pending
.iter()
@@ -632,7 +710,7 @@ pub struct ChainStatus {
pub tip_hash: String,
pub next_leader: Option<String>,
pub launch_profile_hash: String,
- pub block_reward: Amount,
+ pub mine_reward: Amount,
pub balances: BTreeMap<String, Amount>,
pub pending_transactions: usize,
}
@@ -651,6 +729,8 @@ pub struct LaunchProfile {
pub ticket_maturity_delay_heights: u64,
#[serde(default = "default_ticket_expiry_window_heights")]
pub ticket_expiry_window_heights: u64,
+ #[serde(default = "default_mine_difficulty_bits")]
+ pub mine_difficulty_bits: u32,
pub max_pending_transactions: usize,
pub max_block_transactions: usize,
#[serde(default = "default_max_block_bytes")]
@@ -660,9 +740,10 @@ pub struct LaunchProfile {
impl Default for LaunchProfile {
fn default() -> Self {
Self {
- profile_id: "luun-devnet-v4".to_string(),
+ profile_id: "luun-devnet-v5".to_string(),
ticket_maturity_delay_heights: DEFAULT_TICKET_MATURITY_DELAY,
ticket_expiry_window_heights: DEFAULT_TICKET_EXPIRY_WINDOW,
+ mine_difficulty_bits: MINE_DIFFICULTY_BITS,
max_pending_transactions: MAX_PENDING_TRANSACTIONS,
max_block_transactions: MAX_BLOCK_TRANSACTIONS,
max_block_bytes: MAX_BLOCK_BYTES,
@@ -678,13 +759,18 @@ fn default_ticket_expiry_window_heights() -> u64 {
DEFAULT_TICKET_EXPIRY_WINDOW
}
+fn default_mine_difficulty_bits() -> u32 {
+ MINE_DIFFICULTY_BITS
+}
+
impl LaunchProfile {
pub fn hash(&self) -> String {
hex_hash(format!(
- "luun-launch-profile:{}:{}:{}:{}:{}:{}",
+ "luun-launch-profile:{}:{}:{}:{}:{}:{}:{}",
self.profile_id,
self.ticket_maturity_delay_heights,
self.ticket_expiry_window_heights,
+ self.mine_difficulty_bits,
self.max_pending_transactions,
self.max_block_transactions,
self.max_block_bytes
@@ -768,7 +854,7 @@ pub struct Ledger {
utxos: BTreeMap<OutPoint, TxOutput>,
tickets: Vec<BurnTicket>,
pending: Vec<Transaction>,
- block_reward: Amount,
+ mine_reward: Amount,
initial_vdf_rounds: u32,
vdf_rounds: u32,
launch_profile: LaunchProfile,
@@ -813,7 +899,7 @@ impl Ledger {
utxos,
tickets,
pending: Vec::new(),
- block_reward: BLOCK_REWARD,
+ mine_reward: MINE_REWARD,
initial_vdf_rounds: vdf_rounds,
vdf_rounds,
launch_profile,
@@ -851,7 +937,7 @@ impl Ledger {
utxos,
tickets: Vec::new(),
pending: Vec::new(),
- block_reward: BLOCK_REWARD,
+ mine_reward: MINE_REWARD,
initial_vdf_rounds: vdf_rounds,
vdf_rounds,
launch_profile,
@@ -1060,7 +1146,7 @@ impl Ledger {
tip_hash: self.tip().hash.clone(),
next_leader: self.expected_leader_for_next_block(),
launch_profile_hash: self.launch_profile.hash(),
- block_reward: self.block_reward,
+ mine_reward: self.mine_reward,
balances: balances_from_utxos(&self.utxos),
pending_transactions: self.pending.len(),
}
@@ -1274,16 +1360,42 @@ impl Ledger {
Ok(transaction)
}
+ pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> {
+ let recipient = recipient.into();
+ let output = TxOutput {
+ address: recipient,
+ amount: self.mine_reward,
+ };
+ let anchor = self.tip().hash.clone();
+ let difficulty_bits = self.launch_profile.mine_difficulty_bits;
+ for nonce in 0..u64::MAX {
+ let signature = mine_signature(&output, &anchor, nonce, difficulty_bits);
+ if !hash_meets_difficulty(&signature, difficulty_bits) {
+ continue;
+ }
+ let transaction = Transaction::Mine {
+ output: output.clone(),
+ anchor: anchor.clone(),
+ nonce,
+ difficulty_bits,
+ signature,
+ };
+ if self.has_transaction(transaction.signature()) {
+ continue;
+ }
+ self.validate_new_transaction(&transaction)?;
+ return Ok(transaction);
+ }
+ bail!("could not find valid mine proof");
+ }
+
pub fn submit_transaction(&mut self, transaction: Transaction) -> Result<bool> {
- if self
- .pending
- .iter()
- .any(|tx| tx.signature() == transaction.signature())
- {
+ if self.has_transaction(transaction.signature()) {
return Ok(false);
}
transaction.verify_signature()?;
+ self.validate_transaction_terms(&transaction)?;
if transaction_inputs_spent_by(&transaction, &self.pending) {
return Ok(false);
@@ -1334,7 +1446,7 @@ impl Ledger {
prev_hash,
timestamp_ms,
miner: miner.to_string(),
- reward: reward_with_fees(self.block_reward, &transactions)?,
+ reward: fee_reward(&transactions)?,
vdf_rounds: self.vdf_rounds,
vdf_seed,
leader_ticket,
@@ -1374,9 +1486,10 @@ impl Ledger {
if !signatures.insert(tx.signature()) {
bail!("duplicate transaction in block");
}
+ self.validate_transaction_terms(tx)?;
apply_transaction(tx, &mut utxos)?;
}
- if block.reward != reward_with_fees(self.block_reward, &block.transactions)? {
+ if block.reward != fee_reward(&block.transactions)? {
bail!("block reward is invalid");
}
let mut tickets = self.tickets.clone();
@@ -1429,7 +1542,7 @@ impl Ledger {
if block.compute_hash() != block.hash {
bail!("block hash is invalid");
}
- if block.reward != reward_with_fees(self.block_reward, &block.transactions)? {
+ if block.reward != fee_reward(&block.transactions)? {
bail!("block reward is invalid");
}
if block.vdf_rounds != self.vdf_rounds {
@@ -1506,7 +1619,9 @@ impl Ledger {
let mut still_pending = Vec::new();
for tx in remaining {
- if apply_transaction(tx, &mut utxos).is_ok() {
+ if self.validate_transaction_terms(tx).is_ok()
+ && apply_transaction(tx, &mut utxos).is_ok()
+ {
valid.push(tx.clone());
progressed = true;
} else {
@@ -1620,10 +1735,32 @@ impl Ledger {
}
fn validate_new_transaction(&self, transaction: &Transaction) -> Result<()> {
+ self.validate_transaction_terms(transaction)?;
let mut utxos = self.utxos_after_valid_pending()?;
apply_transaction(transaction, &mut utxos)
}
+ fn validate_transaction_terms(&self, transaction: &Transaction) -> Result<()> {
+ if let Transaction::Mine {
+ output,
+ anchor,
+ difficulty_bits,
+ ..
+ } = transaction
+ {
+ if output.amount != self.mine_reward {
+ bail!("mine transaction reward is invalid");
+ }
+ if *difficulty_bits != self.launch_profile.mine_difficulty_bits {
+ bail!("mine transaction difficulty is invalid");
+ }
+ if !self.has_block(anchor) {
+ bail!("mine transaction anchor is not on this chain");
+ }
+ }
+ Ok(())
+ }
+
fn utxos_after_valid_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> {
let mut utxos = self.utxos.clone();
for pending in self.valid_pending_transactions() {
@@ -1936,6 +2073,17 @@ fn apply_transaction(
utxos: &mut BTreeMap<OutPoint, TxOutput>,
) -> Result<()> {
transaction.verify_signature()?;
+ if let Transaction::Mine { output, .. } = transaction {
+ ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(output))?;
+ utxos.insert(
+ OutPoint {
+ txid: transaction.signature().to_string(),
+ index: 0,
+ },
+ output.clone(),
+ );
+ return Ok(());
+ }
ensure_single_input_owner(transaction)?;
let input_total = spend_inputs(transaction, utxos)?;
let output_total = transaction
@@ -1951,7 +2099,7 @@ fn apply_transaction(
.context("transaction outputs plus fee overflow")?
.checked_add(match transaction {
Transaction::Burn { amount, .. } => *amount,
- Transaction::Transfer { .. } => 0,
+ Transaction::Transfer { .. } | Transaction::Mine { .. } => 0,
})
.context("transaction outputs plus burn overflow")?;
if input_total != required {
@@ -1970,11 +2118,9 @@ fn apply_transaction(
Ok(())
}
-fn reward_with_fees(base_reward: Amount, transactions: &[Transaction]) -> Result<Amount> {
- transactions.iter().try_fold(base_reward, |total, tx| {
- total
- .checked_add(tx.fee())
- .context("block reward plus fees overflows")
+fn fee_reward(transactions: &[Transaction]) -> Result<Amount> {
+ transactions.iter().try_fold(0_u64, |total, tx| {
+ total.checked_add(tx.fee()).context("block fees overflow")
})
}
@@ -2012,6 +2158,9 @@ fn transaction_has_missing_inputs(
}
fn ensure_single_input_owner(transaction: &Transaction) -> Result<()> {
+ if matches!(transaction, Transaction::Mine { .. }) {
+ return Ok(());
+ }
let Some(first) = transaction.inputs().first() else {
bail!("transaction has no inputs");
};
@@ -2094,7 +2243,9 @@ fn utxos_after_genesis(
for transaction in &genesis.transactions {
match transaction {
Transaction::Burn { .. } => apply_transaction(transaction, &mut utxos)?,
- Transaction::Transfer { .. } => bail!("genesis only supports burn transactions"),
+ Transaction::Transfer { .. } | Transaction::Mine { .. } => {
+ bail!("genesis only supports burn transactions")
+ }
}
}
credit_reward_output(&mut utxos, genesis)?;
@@ -2178,7 +2329,7 @@ fn genesis_miner(
.iter()
.filter_map(|transaction| match transaction {
Transaction::Burn { inputs, .. } => inputs.first().map(|input| input.owner.as_str()),
- Transaction::Transfer { .. } => None,
+ Transaction::Transfer { .. } | Transaction::Mine { .. } => None,
})
.find(|from| genesis_allocations.contains_key(*from))
.or_else(|| genesis_allocations.keys().next().map(String::as_str))
diff --git a/tests/luun.rs b/tests/luun.rs
@@ -4,7 +4,7 @@ use luun::{
adapters::chain_store::SqliteChainStore,
app::{DEFAULT_BURN_PER_BLOCK, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, PeerDirection},
domain::{
- Amount, BLOCK_REWARD, GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_LUUN,
+ Amount, BLOCK_REWARD, GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_LUUN, MINE_REWARD,
VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf,
},
};
@@ -242,7 +242,7 @@ fn transfer_and_burn_update_balances_when_block_is_applied() {
let block = ledger.mine_next_block(&alice, 1).unwrap();
ledger.apply_block(block).unwrap();
- assert_eq!(ledger.balance_of(alice.address()), luun(950));
+ assert_eq!(ledger.balance_of(alice.address()), luun(850));
assert_eq!(ledger.balance_of(bob.address()), luun(225));
}
@@ -284,22 +284,44 @@ fn block_with_forged_transaction_is_rejected() {
}
#[test]
-fn block_reward_is_fixed_at_one_hundred_luun() {
+fn mine_action_introduces_one_luun_and_block_author_gets_fees_only() {
let alice = Wallet::from_seed("alice");
let mut allocations = BTreeMap::new();
- allocations.insert(alice.address().to_string(), luun(1_000));
+ allocations.insert(alice.address().to_string(), 2 * MICRO_LUUN);
let mut ledger = Ledger::new(allocations, 10);
+ let mine = ledger.build_mine(alice.address()).unwrap();
+ assert_eq!(mine.amount(), MINE_REWARD);
+ ledger.submit_transaction(mine.clone()).unwrap();
submit_burn(&mut ledger, &alice, MICRO_LUUN);
let block = ledger.mine_next_block(&alice, 1).unwrap();
- assert_eq!(block.reward, BLOCK_REWARD);
+ assert_eq!(block.reward, 0);
ledger.apply_block(block).unwrap();
- assert_eq!(ledger.balance_of(alice.address()), luun(1_099));
+ assert_eq!(ledger.balance_of(alice.address()), 2 * MICRO_LUUN);
+ assert!(!ledger.submit_transaction(mine).unwrap());
+ assert_eq!(ledger.balance_of(alice.address()), 2 * MICRO_LUUN);
}
#[test]
-fn burn_larger_than_block_reward_is_paid_from_existing_utxos() {
+fn forged_mine_action_cannot_introduce_luun() {
+ let alice = Wallet::from_seed("forged-mine-alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), MICRO_LUUN);
+ let mut ledger = Ledger::new(allocations, 10);
+
+ let mut forged = ledger.build_mine(alice.address()).unwrap();
+ if let luun::domain::Transaction::Mine { nonce, .. } = &mut forged {
+ *nonce += 1;
+ }
+
+ let error = ledger.submit_transaction(forged).unwrap_err();
+ assert!(format!("{error:#}").contains("mine transaction proof hash is invalid"));
+ assert_eq!(ledger.balance_of(alice.address()), MICRO_LUUN);
+}
+
+#[test]
+fn burn_larger_than_mine_reward_is_paid_from_existing_utxos() {
let alice = Wallet::from_seed("large-burn-existing-utxos-alice");
let mut allocations = BTreeMap::new();
allocations.insert(alice.address().to_string(), BLOCK_REWARD + luun(50));
@@ -311,10 +333,10 @@ fn burn_larger_than_block_reward_is_paid_from_existing_utxos() {
let block = ledger.mine_next_block(&alice, 1).unwrap();
assert_eq!(block.transactions[0].amount(), burn_amount);
- assert_eq!(block.reward, BLOCK_REWARD);
+ assert_eq!(block.reward, 0);
ledger.apply_block(block).unwrap();
- assert_eq!(ledger.balance_of(alice.address()), BLOCK_REWARD + luun(25));
+ assert_eq!(ledger.balance_of(alice.address()), luun(25));
}
#[test]
@@ -350,10 +372,10 @@ fn transaction_fees_are_paid_to_the_block_miner() {
ledger.submit_transaction(tx).unwrap();
let block = ledger.mine_next_block(&alice, 1).unwrap();
- assert_eq!(block.reward, BLOCK_REWARD + luun(7));
+ assert_eq!(block.reward, luun(7));
ledger.apply_block(block).unwrap();
- assert_eq!(ledger.balance_of(alice.address()), luun(216));
+ assert_eq!(ledger.balance_of(alice.address()), luun(116));
assert_eq!(ledger.balance_of(bob.address()), luun(183));
}
@@ -407,7 +429,7 @@ fn oversized_blocks_are_rejected() {
let mut block = ledger.mine_next_block(&alice, 1).unwrap();
let oversized_transfer = transfer_fee_tx(&ledger, &bob, "x".repeat(MAX_BLOCK_BYTES), 1, 3);
block.transactions.push(oversized_transfer);
- block.reward = BLOCK_REWARD + 3;
+ block.reward = 3;
block.hash = block.compute_hash();
let error = ledger.apply_block(block).unwrap_err();
@@ -465,16 +487,13 @@ fn transfer_that_would_overflow_recipient_balance_is_rejected() {
}
#[test]
-fn block_reward_that_would_overflow_miner_balance_is_rejected() {
+fn mine_reward_that_would_overflow_recipient_balance_is_rejected() {
let alice = Wallet::from_seed("alice");
let mut allocations = BTreeMap::new();
allocations.insert(alice.address().to_string(), Amount::MAX);
- let mut ledger = Ledger::new(allocations, 10);
- submit_burn(&mut ledger, &alice, 1);
- let block = ledger.mine_next_block(&alice, 1).unwrap();
-
- let error = ledger.apply_block(block).unwrap_err();
+ let ledger = Ledger::new(allocations, 10);
+ let error = ledger.build_mine(alice.address()).unwrap_err();
assert!(format!("{error:#}").contains("balance overflow"));
}
@@ -550,7 +569,7 @@ fn automatic_mining_burns_configured_amount_once_per_height() {
assert!(first.burned.is_some());
assert!(first.block.is_some());
assert_eq!(node.ledger().chain().len(), 2);
- assert_eq!(node.ledger().balance_of(alice.address()), luun(1_075));
+ assert_eq!(node.ledger().balance_of(alice.address()), luun(975));
let second = node.automatic_mine_once(2);
assert!(second.burned.is_some());
@@ -645,10 +664,7 @@ fn automatic_mining_caps_burn_to_spendable_balance_after_fee() {
);
assert_eq!(outcome.burned.as_ref().map(|tx| tx.fee()), Some(MICRO_LUUN));
assert!(outcome.block.is_some());
- assert_eq!(
- node.ledger().balance_of(alice.address()),
- BLOCK_REWARD + MICRO_LUUN
- );
+ assert_eq!(node.ledger().balance_of(alice.address()), MICRO_LUUN);
}
#[test]
@@ -1679,7 +1695,8 @@ fn fork_choice_preflight_rejects_invalid_fork_before_vrf_scoring() {
if let Some(transaction) = invalid_snapshot.blocks[6].transactions.first_mut() {
match transaction {
luun::domain::Transaction::Burn { signature, .. }
- | luun::domain::Transaction::Transfer { signature, .. } => signature.push_str("00"),
+ | luun::domain::Transaction::Transfer { signature, .. }
+ | luun::domain::Transaction::Mine { signature, .. } => signature.push_str("00"),
}
}