commit 1589d6d13a0eac8931fb58aa5d9f50f972837bb2
parent 5c536d06b5ebbcf61aa38e99ae59ecfeb0e3ee9f
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sat, 25 Jul 2026 22:33:36 +0200
Add Stratum mining support
Diffstat:
10 files changed, 1059 insertions(+), 69 deletions(-)
diff --git a/README.md b/README.md
@@ -2,7 +2,7 @@
Luun is a tiny L1 prototype built from the node first, then explained as it grows.
-The current devnet assumes friendly nodes. It has one binary that acts as wallet, node, miner, HTTP management UI, and P2P TCP listener. The core ledger is separated from the adapters so a whole network can be tested in memory without opening sockets.
+The current devnet assumes friendly nodes. It has one binary that acts as wallet, node, block finalizer, PoW miner, HTTP management UI, and P2P TCP listener. The core ledger is separated from the adapters so a whole network can be tested in memory without opening sockets.
## Run
@@ -24,7 +24,7 @@ cargo run -- --http 127.0.0.1:18661 --p2p 127.0.0.1:9444
For fast local development, set `LUUN_DEV_SKIP_SEED_VERIFY=1` before starting the node to show a setup-only skip button for the recovery phrase check.
-Mining is automatic. There is no "mine block" button and no exact sleep. Each node can burn its configured amount once per chain height. Those burns become one-shot leader tickets after the launch profile's maturity delay. The current devnet uses a 3-block maturity delay and a 3-block eligibility window: a burn included at height `h` can win heights `h + 3` through `h + 5`, then expires if it was not selected. Only the selected ticket owner builds the next block, signs a leader proof, performs the VDF work, and gossips the finished block. The VDF is the clock.
+Block finalization is automatic. There is no "mine block" button and no exact sleep. Each node can burn its configured amount once per chain height. Those burns become one-shot leader tickets after the launch profile's maturity delay. The current devnet uses a 3-block maturity delay and a 3-block eligibility window: a burn included at height `h` can win heights `h + 3` through `h + 5`, then expires if it was not selected. Only the selected ticket owner builds the next block, signs a leader proof, performs the VDF work, and gossips the finished block. The VDF is the clock.
Genesis leaves the starter wallet with 100 spendable LUUN after the 1-LUUN bootstrap burn creates launch tickets for blocks 1, 2, and 3, and the genesis block pays its 100-LUUN reward. `--genesis` starts the automatic burn rate at 100 LUUN per block, so the starter can create the block 1 burn that becomes eligible at block 4.
@@ -42,7 +42,7 @@ For a second local node joining Alice's chain:
cargo run -- --data-dir .luun-bob --http 127.0.0.1:18662 --p2p 127.0.0.1:9445 --join 127.0.0.1:9444
```
-`--join` fetches a chain snapshot from the peer before mining starts and announces this node's P2P listener back to that peer, so newly mined blocks can flow back without restarting the first node. If the peer cannot provide a snapshot, the node exits instead of silently starting a separate chain. Additional peers can be added from the P2P screen.
+`--join` fetches a chain snapshot from the peer before finalization starts and announces this node's P2P listener back to that peer, so newly finalized blocks can flow back without restarting the first node. If the peer cannot provide a snapshot, the node exits instead of silently starting a separate chain. Additional peers can be added from the P2P screen.
If `<data-dir>/chain.sqlite3` already exists, the node resumes that chain first. That makes restarts boring in the good way: `--genesis` will not create a new genesis over an existing local chain, and `--join` remains useful for reconnecting to peers without replacing local state. Pass `--chain-db path/to/chain.sqlite3` to override the database path.
@@ -73,15 +73,25 @@ cargo run -- --genesis --p2p 0.0.0.0:9444 --http 127.0.0.1:18661
cargo run -- --data-dir .luun-friend --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join your-host:9444
```
-Friends who join after you start will adopt your genesis and current chain. The starter wallet begins with 100 spendable LUUN after the bootstrap burn and genesis reward, and `--genesis` starts it with a 100-LUUN automatic burn rate. After the starter mines additional block rewards, send friends LUUN from the UI; then they can choose a burn amount and compete for future blocks. Every joining node starts with a 0-LUUN automatic burn unless it is configured otherwise.
+Friends who join after you start will adopt your genesis and current chain. The starter wallet begins with 100 spendable LUUN after the bootstrap burn and genesis reward, and `--genesis` starts it with a 100-LUUN automatic burn rate. After the starter finalizes additional blocks, send friends LUUN from the UI; then they can choose a burn amount and compete for future blocks. Every joining node starts with a 0-LUUN automatic burn unless it is configured otherwise.
+
+## Bitaxe / Stratum
+
+Run a Stratum V1 listener when you want SHA-256 ASIC miners such as a Bitaxe to create Luun PoW mine actions:
+
+```sh
+cargo run -- --data-dir .luun --stratum 0.0.0.0:3333
+```
+
+Point the miner at the node's Stratum host and port, and use your Luun wallet address as the worker username. The usual `address.worker` format is also accepted; Luun pays the address before the dot. Accepted ASIC shares become Luun mine actions in the node mempool and are gossiped to peers. The Stratum job commits to the current chain tip, recipient address, mine reward, finalizer fee, and Luun mine difficulty.
The genesis block bootstraps the chain with a 1-LUUN burn from the starter wallet. Genesis turns that burn into launch tickets for blocks 1 through 3 so the chain can move until normal burn tickets mature. Burns included after genesis create one-shot tickets through a deterministic weighted lottery: a burn of `X` among total eligible burn weight `Y` has `X / Y` chance for that block. The selected leader creates the next block content, signs a proof for the selected ticket, and runs a hash-chain VDF before gossiping the block.
-Every non-genesis block must consume the selected eligible ticket, include at least one burn transaction, and fit under the 100kB serialized block limit. The VDF seed is bound to the parent hash and child height; the block hash separately commits to the miner, timestamp, miner payout, rounds, previous hash, leader proof, VDF output, and transactions.
+Every non-genesis block must consume the selected eligible ticket, include at least one burn transaction, and fit under the 100kB serialized block limit. The VDF seed is bound to the parent hash and child height; the block hash separately commits to the finalizer, timestamp, finalizer payout, rounds, previous hash, leader proof, VDF output, and transactions.
The protocol targets 60-second blocks by retargeting the expected VDF rounds after each block. It uses a rolling average of recent block intervals and only moves the next round count by about 10% per block, so short bursts do not make the delay swing wildly. Every node derives the same next-round count from the validated chain.
-The base block reward is fixed at 100 LUUN, and miners collect transaction fees on top. Transfers, burns, and PoW mine actions all set fees as LUUN per serialized byte; the node calculates the total fee from the final transaction size before submitting it. The automatic burn setting has a burn amount and a fee rate; the burn amount is the ticket weight, while the resulting fee is paid to the miner that includes it. The miner includes the best valid burn for liveness, then fills the remaining block space by fee-rate while respecting nonce and balance validity. The default burn is 0 LUUN per block with a 0.000001-LUUN-per-byte fee rate, so new nodes can join before they own LUUN. Genesis starters begin at 100 LUUN per block; after another wallet has LUUN, raise its burn from the Mining screen.
+The base block finalizer reward is the block's total transaction fees. Transfers, burns, and PoW mine actions all set fees as LUUN per serialized byte; the node calculates the total fee from the final transaction size before submitting it. The automatic burn setting has a burn amount and a fee rate; the burn amount is the ticket weight, while the resulting fee is paid to the finalizer that includes it. The finalizer includes the best valid burn for liveness, then fills the remaining block space by fee-rate while respecting nonce and balance validity. PoW mine actions introduce new LUUN separately. The default burn is 0 LUUN per block with a 0.000001-LUUN-per-byte fee rate, so new nodes can join before they own LUUN. Genesis starters begin at 100 LUUN per block; after another wallet has LUUN, raise its burn from the Mining screen.
The measured VDF round count is only the initial delay. After the first blocks, the protocol steers rounds toward the 60-second target.
@@ -104,7 +114,7 @@ Luun stores the latest validated `ChainSnapshot` in SQLite at `<data-dir>/chain.
## Architecture
- `src/domain.rs`: wallet, fee-paying transactions, balances, genesis burn bootstrap, 100-LUUN base rewards, 100kB blocks, rolling-window leader tickets, leader proofs, fork choice, launch profile, and VDF checks.
-- `src/app.rs`: node use cases, automatic VDF-paced mining, peer bookkeeping, and an in-memory network harness.
+- `src/app.rs`: node use cases, automatic VDF-paced finalization, peer bookkeeping, and an in-memory network harness.
- `src/adapters/http.rs`: HTTP management UI and status endpoint.
- `src/adapters/p2p.rs`: line-delimited JSON gossip, block-range catch-up, and chain snapshots over one TCP port.
- `src/adapters/chain_store.rs`: SQLite chain snapshot persistence.
diff --git a/assets/luun-ui.js b/assets/luun-ui.js
@@ -652,8 +652,8 @@ window.luunApp = function luunApp() {
"/api/settings/burn-per-block",
{ enabled: this.miningEnabled, amount, fee_per_byte: fee },
this.miningEnabled
- ? `Mining on: ${this.amountLabel(amount)} LUUN per block with ${this.amountLabel(fee)} per byte`
- : `Mining settings saved while off`
+ ? `Finalization burns on: ${this.amountLabel(amount)} LUUN per block with ${this.amountLabel(fee)} per byte`
+ : `Burn settings saved while off`
);
this.burnAmountDirty = false;
this.burnAmount = amount;
@@ -670,13 +670,13 @@ window.luunApp = function luunApp() {
const fee = this.parseLuunAmountRequired(this.burnFeeDraft, "Burn fee per byte is required");
if (enabled && amount === 0) {
this.miningEnabled = false;
- throw new Error("Set LUUN per block before turning mining on");
+ throw new Error("Set LUUN per block before turning finalization burns on");
}
this.miningEnabled = enabled;
await this.postForm(
"/api/settings/burn-per-block",
{ enabled, amount, fee_per_byte: fee },
- enabled ? "Mining turned on" : "Mining turned off"
+ enabled ? "Finalization burns turned on" : "Finalization burns turned off"
);
this.burnAmountDirty = false;
this.burnAmount = amount;
@@ -962,7 +962,7 @@ window.luunApp = function luunApp() {
txFeeRecipient(tx) {
const context = this.selectedTransaction?.context || {};
- return tx.blockMiner ?? context.blockMiner ?? "future block miner";
+ return tx.blockFinalizer ?? tx.blockMiner ?? context.blockFinalizer ?? context.blockMiner ?? "future block finalizer";
},
selectedTransactionLabel() {
@@ -1021,9 +1021,9 @@ window.luunApp = function luunApp() {
return `${count} mine${count === 1 ? "" : "s"}`;
},
- blockMinerLabel(block) {
- const miner = this.short(block.miner);
- return block.miner === this.status.wallet_address ? `${miner} (me)` : miner;
+ blockFinalizerLabel(block) {
+ const finalizer = this.short(block.miner);
+ return block.miner === this.status.wallet_address ? `${finalizer} (me)` : finalizer;
},
walletTransactions() {
@@ -1056,6 +1056,23 @@ window.luunApp = function luunApp() {
return ms ? `${Math.round(ms / 1000)}s` : "-";
},
+ stratumListenAddr() {
+ return this.status.stratum?.listen_addr || "-";
+ },
+
+ stratumPoolUrl() {
+ const listen = this.status.stratum?.listen_addr;
+ if (!this.status.stratum?.enabled || !listen) return "-";
+ const lastColon = listen.lastIndexOf(":");
+ if (lastColon < 0) return `stratum+tcp://${listen}`;
+ let host = listen.slice(0, lastColon);
+ const port = listen.slice(lastColon + 1);
+ if (host === "0.0.0.0" || host === "::" || host === "[::]") {
+ host = window.location.hostname || "127.0.0.1";
+ }
+ return `stratum+tcp://${host}:${port}`;
+ },
+
lastUpdatedLabel() {
return this.lastUpdated ? `Updated ${this.lastUpdated.toLocaleTimeString()}` : "Loading";
},
diff --git a/docs/index.html b/docs/index.html
@@ -48,7 +48,7 @@
<div class="mark">L</div>
<h1>Luun</h1>
<p class="lead">A small experimental currency where blocks are finalized by burning LUUN, while new coins are introduced by proof-of-work mine actions.</p>
- <p>Luun is not a mainnet and not money yet. It is a devnet prototype for learning how a wallet, node, miner, mempool, and peer network behave when real people run it on real machines.</p>
+ <p>Luun is not a mainnet and not money yet. It is a devnet prototype for learning how a wallet, node, finalizer, miner, mempool, and peer network behave when real people run it on real machines.</p>
<div class="next">Start a node below</div>
</div>
</header>
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -28,7 +28,7 @@ use crate::{
p2p::{GossipNetwork, P2pMetrics},
wallet_store,
},
- app::{FeeEstimate, NodeStatus, PeerInfo, SharedNode, SharedPeerBook},
+ app::{FeeEstimate, NodeStatus, PeerInfo, SharedNode, SharedPeerBook, StratumStatus},
domain::{Amount, Block, OutPoint, Transaction, TxInput, TxOutput, hex_hash},
};
@@ -47,6 +47,7 @@ struct HttpState {
ui_config: Arc<Mutex<UiConfig>>,
config_path: PathBuf,
wallet_path: PathBuf,
+ stratum: StratumStatus,
auth_sessions: Arc<Mutex<BTreeMap<String, AuthSession>>>,
}
@@ -56,6 +57,13 @@ struct AuthSession {
wallet_password: String,
}
+pub struct ServeOptions {
+ pub config_path: PathBuf,
+ pub wallet_path: PathBuf,
+ pub stratum: StratumStatus,
+ pub addr: SocketAddr,
+}
+
#[derive(Debug, Deserialize)]
struct AuthForm {
password: String,
@@ -148,7 +156,7 @@ struct WalletTransactionRow {
signature: String,
status: &'static str,
block_height: Option<u64>,
- block_miner: Option<String>,
+ block_finalizer: Option<String>,
direction: &'static str,
difficulty_bits: Option<u32>,
proof_bits: Option<u32>,
@@ -208,17 +216,17 @@ pub async fn serve(
peers: SharedPeerBook,
gossip: GossipNetwork,
ui_config: Arc<Mutex<UiConfig>>,
- config_path: PathBuf,
- wallet_path: PathBuf,
- addr: SocketAddr,
+ options: ServeOptions,
) -> Result<()> {
+ let addr = options.addr;
let state = HttpState {
node,
peers,
gossip,
ui_config,
- config_path,
- wallet_path,
+ config_path: options.config_path,
+ wallet_path: options.wallet_path,
+ stratum: options.stratum,
auth_sessions: Arc::new(Mutex::new(BTreeMap::new())),
};
let app = Router::new()
@@ -398,7 +406,9 @@ async fn api_auth_logout_form(State(state): State<HttpState>, headers: HeaderMap
}
async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> {
- Json(state.node.lock().await.status())
+ let mut status = state.node.lock().await.status();
+ status.stratum = state.stratum.clone();
+ Json(status)
}
async fn api_blocks(
@@ -742,7 +752,7 @@ fn wallet_transaction_row(
outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>,
status: &'static str,
block_height: Option<u64>,
- block_miner: Option<String>,
+ block_finalizer: Option<String>,
) -> Option<WalletTransactionRow> {
match tx {
Transaction::Transfer {
@@ -762,7 +772,7 @@ fn wallet_transaction_row(
signature: signature.clone(),
status,
block_height,
- block_miner,
+ block_finalizer,
direction: if tx.to() == Some(wallet) {
"received"
} else {
@@ -790,7 +800,7 @@ fn wallet_transaction_row(
signature: signature.clone(),
status,
block_height,
- block_miner,
+ block_finalizer,
direction: "received",
difficulty_bits: Some(*difficulty_bits),
proof_bits: Some(proof_bits(signature)),
@@ -1567,6 +1577,15 @@ const INDEX_HTML: &str = r#"<!doctype html>
.mine-stat-label { display: flex; gap: 5px; align-items: center; color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; }
.mine-stat-value { margin-top: 5px; color: #dce4e7; font-size: 14px; font-weight: 850; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.mine-stat-value.money { color: #d5f55f; }
+ .panel-separator { border-top: 1px solid #2f363c; margin: 14px 0 12px; }
+ .stratum-config { display: grid; gap: 10px; }
+ .stratum-note { max-width: 760px; color: #9eb3bc; font-size: 12px; line-height: 1.45; }
+ .stratum-note code { color: #dce4e7; }
+ .stratum-fields { display: grid; gap: 8px; }
+ .stratum-field { display: grid; grid-template-columns: 86px minmax(0, 1fr); gap: 10px; align-items: baseline; min-width: 0; }
+ .stratum-label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; }
+ .stratum-value { min-width: 0; color: #dce4e7; font-size: 13px; font-weight: 650; overflow-wrap: anywhere; }
+ .stratum-value.hash { color: #9eb3bc; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; font-weight: 500; }
.info-button { display: inline-grid; place-items: center; width: 18px; height: 18px; padding: 0; border-radius: 999px; border-color: #3a4248; background: #181b1f; color: #9eb3bc; font-size: 11px; line-height: 1; }
.info-button:hover, .info-button:focus-visible { border-color: #d5f55f; color: #d5f55f; outline: none; }
.info-copy { display: grid; gap: 10px; color: #c3cbd0; line-height: 1.45; }
@@ -1673,7 +1692,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/luun-ui.js?v=49"></script>
+ <script defer src="/assets/luun-ui.js?v=53"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="luunApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak>
@@ -1818,7 +1837,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<span class="toggle-text" x-text="miningEnabled ? 'On' : 'Off'"></span>
</label>
</div>
- <div class="panel-description">Burn LUUN to compete for block leadership. Winning burns produce PoB/VDF blocks and earn the transaction fees in those blocks.</div>
+ <div class="panel-description">Burn LUUN to compete for block finalization. Winning burns finalize PoB/VDF blocks and earn the transaction fees in those blocks.</div>
<form class="mining-form" @submit.prevent="saveBurn">
<div class="burn-fields">
<label>LUUN per block<input x-model="burnAmountDraft" @input="burnAmountDirty = true; scheduleFeeEstimates()" type="number" min="0" step="0.000001"></label>
@@ -1877,6 +1896,24 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
<div class="fee-preview" x-text="feeEstimateLabel('mine')"></div>
</form>
+ <div class="panel-separator"></div>
+ <div class="stratum-config">
+ <div class="stratum-note">Start the node with <code>--stratum 0.0.0.0:3333</code> to expose a Stratum V1 endpoint for ASIC miners. Use the pool URL below in the miner configuration.</div>
+ <div class="stratum-fields" aria-label="Stratum settings">
+ <div class="stratum-field">
+ <div class="stratum-label">Status</div>
+ <div class="stratum-value" x-text="status.stratum?.enabled ? 'On' : 'Off'"></div>
+ </div>
+ <div class="stratum-field">
+ <div class="stratum-label">Listener</div>
+ <code class="stratum-value hash" x-text="stratumListenAddr()"></code>
+ </div>
+ <div class="stratum-field">
+ <div class="stratum-label">Pool URL</div>
+ <code class="stratum-value hash" x-text="stratumPoolUrl()"></code>
+ </div>
+ </div>
+ </div>
</div>
</div>
</section>
@@ -1951,7 +1988,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<span x-text="transferCountLabel(block)"></span>
<span x-text="mineCountLabel(block)"></span>
</div>
- <div class="block-miner" x-text="blockMinerLabel(block)"></div>
+ <div class="block-miner" x-text="blockFinalizerLabel(block)"></div>
</button>
</template>
<template x-if="loadingOlder">
@@ -1972,7 +2009,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="detail-kv"><div class="key">Height</div><div x-text="selectedBlock.height"></div></div>
<div class="detail-kv"><div class="key">Hash</div><code x-text="selectedBlock.hash"></code></div>
<div class="detail-kv"><div class="key">Previous</div><code x-text="short(selectedBlock.prev_hash)"></code></div>
- <div class="detail-kv"><div class="key">Miner</div><code x-text="short(selectedBlock.miner)"></code></div>
+ <div class="detail-kv"><div class="key">Finalizer</div><code x-text="short(selectedBlock.miner)"></code></div>
<div class="detail-kv"><div class="key">Reward</div><div>LUUN <span x-text="amountLabel(selectedBlock.reward)"></span></div></div>
<div class="detail-kv"><div class="key">Burns</div><div x-text="blockBurnCount(selectedBlock)"></div></div>
<div class="detail-kv"><div class="key">Transfers</div><div x-text="blockTransferCount(selectedBlock)"></div></div>
@@ -1982,7 +2019,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="tx-list">
<h3>Transactions</h3>
<template x-for="tx in selectedBlock.transactions" :key="tx.signature">
- <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockMiner: selectedBlock.miner })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockMiner: selectedBlock.miner })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockMiner: selectedBlock.miner })">
+ <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })">
<span class="pill" :class="tx.kind" x-text="tx.kind"></span>
<div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="amountLabel(txAmount(tx))"></span></span></div>
<div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="amountLabel(tx.fee ?? 0)"></span></span></div>
@@ -2257,7 +2294,7 @@ mod tests {
use crate::{
adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork, wallet_store},
- app::{NodeCore, PeerBook},
+ app::{NodeCore, PeerBook, StratumStatus},
domain::{Block, Ledger, MICRO_LUUN, MINE_REWARD, OutPoint, Transaction, Wallet},
};
@@ -2442,7 +2479,7 @@ mod tests {
assert_eq!(rows[0].inputs[0].amount, Some(100));
assert_eq!(rows[0].status, "confirmed");
assert_eq!(rows[0].block_height, Some(31));
- assert_eq!(rows[0].block_miner.as_deref(), Some("miner"));
+ assert_eq!(rows[0].block_finalizer.as_deref(), Some("miner"));
assert_eq!(rows[0].direction, "received");
}
@@ -2499,6 +2536,10 @@ mod tests {
)),
config_path,
wallet_path,
+ stratum: StratumStatus {
+ enabled: false,
+ listen_addr: None,
+ },
auth_sessions: Arc::new(Mutex::new(BTreeMap::new())),
}
}
diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs
@@ -2,4 +2,5 @@ pub mod chain_store;
pub mod config_store;
pub mod http;
pub mod p2p;
+pub mod stratum;
pub mod wallet_store;
diff --git a/src/adapters/stratum.rs b/src/adapters/stratum.rs
@@ -0,0 +1,458 @@
+use std::{
+ collections::BTreeMap,
+ net::SocketAddr,
+ sync::{
+ Arc,
+ atomic::{AtomicU64, Ordering},
+ },
+};
+
+use anyhow::{Context, Result, bail};
+use serde_json::{Value, json};
+use tokio::{
+ io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
+ net::{TcpListener, TcpStream, tcp::OwnedWriteHalf},
+ sync::Mutex,
+};
+
+use crate::{
+ adapters::p2p::GossipNetwork,
+ app::{ExternalMineJob, SharedNode},
+ domain::{STRATUM_EXTRANONCE1_HEX, STRATUM_EXTRANONCE2_SIZE, StratumMineShare},
+};
+
+#[derive(Clone)]
+pub struct StratumServer {
+ node: SharedNode,
+ gossip: GossipNetwork,
+ listen_addr: SocketAddr,
+ next_job_salt: Arc<AtomicU64>,
+}
+
+#[derive(Clone, Debug)]
+struct StratumJob {
+ mine: ExternalMineJob,
+}
+
+impl StratumServer {
+ pub async fn start(
+ node: SharedNode,
+ gossip: GossipNetwork,
+ listen_addr: SocketAddr,
+ ) -> Result<Self> {
+ let listener = TcpListener::bind(listen_addr)
+ .await
+ .with_context(|| format!("failed to bind Stratum listener on {listen_addr}"))?;
+ let local_addr = listener.local_addr()?;
+ let server = Self {
+ node,
+ gossip,
+ listen_addr: local_addr,
+ next_job_salt: Arc::new(AtomicU64::new(1)),
+ };
+ tokio::spawn(run_listener(server.clone(), listener));
+ Ok(server)
+ }
+
+ pub fn listen_addr(&self) -> SocketAddr {
+ self.listen_addr
+ }
+}
+
+async fn run_listener(server: StratumServer, listener: TcpListener) {
+ loop {
+ match listener.accept().await {
+ Ok((stream, remote)) => {
+ let server = server.clone();
+ tokio::spawn(async move {
+ if let Err(error) = handle_connection(server, stream).await {
+ eprintln!("stratum session with {remote} failed: {error:#}");
+ }
+ });
+ }
+ Err(error) => {
+ eprintln!("stratum accept failed: {error:#}");
+ }
+ }
+ }
+}
+
+async fn handle_connection(server: StratumServer, stream: TcpStream) -> Result<()> {
+ let (read, write) = stream.into_split();
+ let mut session = StratumSession {
+ server,
+ writer: Arc::new(Mutex::new(write)),
+ authorized_worker: None,
+ jobs: BTreeMap::new(),
+ next_job_id: 1,
+ };
+ let mut lines = BufReader::new(read).lines();
+ while let Some(line) = lines.next_line().await? {
+ if line.trim().is_empty() {
+ continue;
+ }
+ let request: Value = serde_json::from_str(&line).context("invalid Stratum JSON")?;
+ session.handle_request(request).await?;
+ }
+ Ok(())
+}
+
+struct StratumSession {
+ server: StratumServer,
+ writer: Arc<Mutex<OwnedWriteHalf>>,
+ authorized_worker: Option<String>,
+ jobs: BTreeMap<String, StratumJob>,
+ next_job_id: u64,
+}
+
+impl StratumSession {
+ async fn handle_request(&mut self, request: Value) -> Result<()> {
+ let id = request.get("id").cloned().unwrap_or(Value::Null);
+ let method = request
+ .get("method")
+ .and_then(Value::as_str)
+ .context("Stratum request is missing method")?;
+ match method {
+ "mining.subscribe" => {
+ self.send_response(
+ id,
+ json!([
+ [["mining.set_difficulty", "luun"], ["mining.notify", "luun"]],
+ STRATUM_EXTRANONCE1_HEX,
+ STRATUM_EXTRANONCE2_SIZE
+ ]),
+ )
+ .await?;
+ }
+ "mining.authorize" => {
+ let worker = request
+ .get("params")
+ .and_then(Value::as_array)
+ .and_then(|params| params.first())
+ .and_then(Value::as_str)
+ .context("mining.authorize requires worker address")?
+ .to_string();
+ self.authorized_worker = Some(worker.clone());
+ self.send_response(id, json!(true)).await?;
+ self.send_job(&worker, true).await?;
+ }
+ "mining.submit" => {
+ let accepted = self.handle_submit(&request).await;
+ match accepted {
+ Ok(true) => self.send_response(id, json!(true)).await?,
+ Ok(false) => {
+ self.send_error(id, 23, "duplicate share or transaction")
+ .await?;
+ }
+ Err(error) => self.send_error(id, 23, &format!("{error:#}")).await?,
+ }
+ }
+ "mining.configure" => {
+ self.send_response(id, json!({})).await?;
+ }
+ "mining.extranonce.subscribe" => {
+ self.send_response(id, json!(true)).await?;
+ }
+ _ => {
+ self.send_error(id, 20, &format!("unsupported method {method}"))
+ .await?;
+ }
+ }
+ Ok(())
+ }
+
+ async fn send_job(&mut self, worker: &str, clean_jobs: bool) -> Result<()> {
+ let job_id = self.next_job_id.to_string();
+ self.next_job_id = self.next_job_id.saturating_add(1);
+ let salt = self.server.next_job_salt.fetch_add(1, Ordering::Relaxed);
+ let mine = self
+ .server
+ .node
+ .lock()
+ .await
+ .external_mine_job(recipient_from_worker(worker), salt)?;
+ let difficulty = stratum_difficulty_for_bits(mine.template.difficulty_bits);
+ self.send_notification("mining.set_difficulty", json!([difficulty]))
+ .await?;
+ self.send_notification(
+ "mining.notify",
+ json!([
+ job_id,
+ mine.template.prev_hash_hex,
+ mine.template.coinb1_hex(),
+ "",
+ [],
+ mine.template.version_hex,
+ mine.template.nbits_hex,
+ mine.template.ntime_hex,
+ clean_jobs
+ ]),
+ )
+ .await?;
+ self.jobs.insert(job_id, StratumJob { mine });
+ Ok(())
+ }
+
+ async fn handle_submit(&mut self, request: &Value) -> Result<bool> {
+ let params = request
+ .get("params")
+ .and_then(Value::as_array)
+ .context("mining.submit requires params")?;
+ let worker = str_param(params, 0, "worker")?;
+ let job_id = str_param(params, 1, "job id")?;
+ let extranonce2 = hex_array_4(str_param(params, 2, "extranonce2")?)?;
+ let ntime = str_param(params, 3, "ntime")?;
+ let header_nonce = hex_array_4(str_param(params, 4, "nonce")?)?;
+ let authorized = self
+ .authorized_worker
+ .as_deref()
+ .context("worker is not authorized")?;
+ if worker != authorized {
+ bail!("submitted worker does not match authorized worker");
+ }
+ let job = self
+ .jobs
+ .get(job_id)
+ .cloned()
+ .context("unknown Stratum job")?;
+ if ntime != job.mine.template.ntime_hex {
+ bail!("submitted ntime does not match job");
+ }
+
+ let (result, outbox) = {
+ let mut node = self.server.node.lock().await;
+ let result = node.submit_external_mine(
+ recipient_from_worker(worker),
+ job.mine.template.clone(),
+ StratumMineShare {
+ extranonce2,
+ header_nonce,
+ },
+ );
+ let outbox = node.drain_outbox();
+ (result, outbox)
+ };
+ match result {
+ Ok(_) => {
+ self.server.gossip.broadcast(outbox).await?;
+ let worker = worker.to_string();
+ self.send_job(&worker, false).await?;
+ Ok(true)
+ }
+ Err(error) => Err(error),
+ }
+ }
+
+ async fn send_response(&self, id: Value, result: Value) -> Result<()> {
+ self.send(json!({ "id": id, "result": result, "error": null }))
+ .await
+ }
+
+ async fn send_error(&self, id: Value, code: i64, message: &str) -> Result<()> {
+ self.send(json!({ "id": id, "result": null, "error": [code, message, null] }))
+ .await
+ }
+
+ async fn send_notification(&self, method: &str, params: Value) -> Result<()> {
+ self.send(json!({ "id": null, "method": method, "params": params }))
+ .await
+ }
+
+ async fn send(&self, value: Value) -> Result<()> {
+ let mut writer = self.writer.lock().await;
+ writer
+ .write_all(serde_json::to_string(&value)?.as_bytes())
+ .await?;
+ writer.write_all(b"\n").await?;
+ Ok(())
+ }
+}
+
+fn str_param<'a>(params: &'a [Value], index: usize, name: &str) -> Result<&'a str> {
+ params
+ .get(index)
+ .and_then(Value::as_str)
+ .with_context(|| format!("mining.submit requires {name}"))
+}
+
+fn recipient_from_worker(worker: &str) -> &str {
+ worker
+ .split_once('.')
+ .map_or(worker, |(recipient, _)| recipient)
+}
+
+fn hex_array_4(input: &str) -> Result<[u8; 4]> {
+ let bytes = decode_hex(input)?;
+ let len = bytes.len();
+ bytes
+ .try_into()
+ .map_err(|_| anyhow::anyhow!("expected 4 hex bytes, got {len}"))
+}
+
+fn decode_hex(input: &str) -> Result<Vec<u8>> {
+ if input.len() % 2 != 0 {
+ bail!("hex string has odd length");
+ }
+ let mut bytes = Vec::with_capacity(input.len() / 2);
+ for pair in input.as_bytes().chunks_exact(2) {
+ bytes.push((hex_value(pair[0])? << 4) | hex_value(pair[1])?);
+ }
+ Ok(bytes)
+}
+
+fn hex_value(byte: u8) -> Result<u8> {
+ match byte {
+ b'0'..=b'9' => Ok(byte - b'0'),
+ b'a'..=b'f' => Ok(byte - b'a' + 10),
+ b'A'..=b'F' => Ok(byte - b'A' + 10),
+ _ => bail!("invalid hex character"),
+ }
+}
+
+fn stratum_difficulty_for_bits(bits: u32) -> f64 {
+ 2_f64.powi(bits as i32 - 16).max(0.000001)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{collections::BTreeMap, net::SocketAddr, sync::Arc};
+
+ use serde_json::json;
+ use tokio::{
+ io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
+ sync::Mutex,
+ };
+
+ use crate::{
+ adapters::p2p::GossipNetwork,
+ app::{NodeCore, PeerBook},
+ domain::{Ledger, StratumMineShare, Wallet},
+ };
+
+ use super::StratumServer;
+
+ #[tokio::test]
+ async fn stratum_session_accepts_valid_luun_share() {
+ let wallet = Wallet::from_seed("stratum-session-wallet");
+ let ledger = Ledger::new(BTreeMap::new(), 1);
+ let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet.clone(), ledger, 0)));
+ let peers = Arc::new(Mutex::new(PeerBook::default()));
+ let gossip = GossipNetwork::new_for_tests(Arc::clone(&node), peers);
+ let server = match StratumServer::start(
+ Arc::clone(&node),
+ gossip,
+ "127.0.0.1:0".parse::<SocketAddr>().unwrap(),
+ )
+ .await
+ {
+ Ok(server) => server,
+ Err(error) if format!("{error:#}").contains("Operation not permitted") => return,
+ Err(error) => panic!("{error:#}"),
+ };
+
+ let stream = tokio::net::TcpStream::connect(server.listen_addr())
+ .await
+ .unwrap();
+ let (read, mut write) = stream.into_split();
+ let mut lines = BufReader::new(read).lines();
+ send(
+ &mut write,
+ json!({"id": 1, "method": "mining.subscribe", "params": []}),
+ )
+ .await;
+ assert_eq!(read_id(&mut lines, 1).await["error"], json!(null));
+
+ send(
+ &mut write,
+ json!({"id": 2, "method": "mining.authorize", "params": [wallet.address(), "x"]}),
+ )
+ .await;
+ assert_eq!(read_id(&mut lines, 2).await["result"], json!(true));
+ let notify = read_method(&mut lines, "mining.notify").await;
+ let job_id = notify["params"][0].as_str().unwrap().to_string();
+
+ let template = node
+ .lock()
+ .await
+ .external_mine_job(wallet.address(), 1)
+ .unwrap()
+ .template;
+ let mut nonce = None;
+ for candidate in 0_u32..50_000 {
+ let result = node.lock().await.ledger().build_stratum_mine(
+ template.clone(),
+ StratumMineShare {
+ extranonce2: [0, 0, 0, 0],
+ header_nonce: candidate.to_le_bytes(),
+ },
+ );
+ if result.is_ok() {
+ nonce = Some(candidate.to_le_bytes());
+ break;
+ }
+ }
+ let nonce = nonce.expect("expected valid share");
+
+ send(
+ &mut write,
+ json!({
+ "id": 3,
+ "method": "mining.submit",
+ "params": [wallet.address(), job_id, "00000000", template.ntime_hex, hex(nonce)]
+ }),
+ )
+ .await;
+ assert_eq!(read_id(&mut lines, 3).await["result"], json!(true));
+ assert_eq!(node.lock().await.ledger().pending().len(), 1);
+ }
+
+ #[test]
+ fn worker_suffix_is_not_part_of_recipient_address() {
+ assert_eq!(super::recipient_from_worker("abc.bitaxe"), "abc");
+ assert_eq!(super::recipient_from_worker("abc"), "abc");
+ }
+
+ async fn send(write: &mut tokio::net::tcp::OwnedWriteHalf, value: serde_json::Value) {
+ write
+ .write_all(serde_json::to_string(&value).unwrap().as_bytes())
+ .await
+ .unwrap();
+ write.write_all(b"\n").await.unwrap();
+ }
+
+ async fn read_id(
+ lines: &mut tokio::io::Lines<BufReader<tokio::net::tcp::OwnedReadHalf>>,
+ id: i64,
+ ) -> serde_json::Value {
+ loop {
+ let line = lines.next_line().await.unwrap().unwrap();
+ let value: serde_json::Value = serde_json::from_str(&line).unwrap();
+ if value.get("id").and_then(serde_json::Value::as_i64) == Some(id) {
+ return value;
+ }
+ }
+ }
+
+ async fn read_method(
+ lines: &mut tokio::io::Lines<BufReader<tokio::net::tcp::OwnedReadHalf>>,
+ method: &str,
+ ) -> serde_json::Value {
+ loop {
+ let line = lines.next_line().await.unwrap().unwrap();
+ let value: serde_json::Value = serde_json::from_str(&line).unwrap();
+ if value.get("method").and_then(serde_json::Value::as_str) == Some(method) {
+ return value;
+ }
+ }
+ }
+
+ fn hex(bytes: impl AsRef<[u8]>) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut output = String::new();
+ for byte in bytes.as_ref() {
+ output.push(HEX[(byte >> 4) as usize] as char);
+ output.push(HEX[(byte & 0x0f) as usize] as char);
+ }
+ output
+ }
+}
diff --git a/src/app.rs b/src/app.rs
@@ -10,7 +10,8 @@ use tokio::sync::Mutex;
use crate::domain::{
Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE,
- Ledger, OutPoint, PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf,
+ Ledger, OutPoint, PreparedBlock, StratumMineShare, StratumMineTemplate, Transaction, TxOutput,
+ VDF_TARGET_BLOCK_MS, Wallet, run_vdf,
};
pub type SharedNode = Arc<Mutex<NodeCore>>;
@@ -38,6 +39,11 @@ pub struct FeeEstimate {
pub fee: Amount,
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ExternalMineJob {
+ pub template: StratumMineTemplate,
+}
+
#[derive(Clone, Debug)]
enum NodeWallet {
Unlocked(Wallet),
@@ -137,6 +143,7 @@ pub struct NodeStatus {
pub wallet_locked: bool,
pub launch_profile: LaunchProfileStatus,
pub mining: MiningStatus,
+ pub stratum: StratumStatus,
pub chain: ChainStatus,
}
@@ -164,6 +171,12 @@ pub struct MiningStatus {
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct StratumStatus {
+ pub enabled: bool,
+ pub listen_addr: Option<String>,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AutoMineOutcome {
pub pow_mined: Option<Transaction>,
pub burned: Option<Transaction>,
@@ -446,6 +459,10 @@ impl NodeCore {
wallet_is_current_leader,
last_auto_burn_height: self.last_auto_burn_height,
},
+ stratum: StratumStatus {
+ enabled: false,
+ listen_addr: None,
+ },
chain,
}
}
@@ -603,6 +620,46 @@ impl NodeCore {
.map(|(_, estimate)| estimate)
}
+ pub fn external_mine_job(
+ &self,
+ recipient: impl Into<String>,
+ salt: u64,
+ ) -> Result<ExternalMineJob> {
+ let recipient = recipient.into();
+ let tip = self
+ .chain()
+ .last()
+ .context("cannot build mine job without a chain tip")?;
+ let difficulty_bits = self.ledger.current_mine_difficulty_bits();
+ let fee = self.estimate_external_mine_fee(&recipient, &tip.hash, difficulty_bits)?;
+ Ok(ExternalMineJob {
+ template: self.ledger.stratum_mine_template(
+ recipient,
+ fee,
+ &tip.hash,
+ salt,
+ difficulty_bits,
+ )?,
+ })
+ }
+
+ pub fn submit_external_mine(
+ &mut self,
+ recipient: impl Into<String>,
+ template: StratumMineTemplate,
+ share: StratumMineShare,
+ ) -> Result<Transaction> {
+ let tx = self.ledger.build_stratum_mine(template, share)?;
+ let recipient = recipient.into();
+ if tx.to() != Some(recipient.as_str()) {
+ bail!("submitted mine recipient does not match worker");
+ }
+ 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 {
@@ -651,6 +708,44 @@ impl NodeCore {
})
}
+ fn estimate_external_mine_fee(
+ &self,
+ recipient: &str,
+ anchor: &str,
+ difficulty_bits: u32,
+ ) -> Result<Amount> {
+ let fee_per_byte = self.pow_mine_fee;
+ let mine_reward = self.ledger.status().mine_reward;
+ let mut fee = 0;
+ for _ in 0..16 {
+ if fee > mine_reward {
+ bail!("mine transaction fee exceeds reward");
+ }
+ let tx = Transaction::Mine {
+ output: TxOutput {
+ address: recipient.to_string(),
+ amount: mine_reward - fee,
+ },
+ anchor: anchor.to_string(),
+ salt: 1,
+ nonce: u64::MAX,
+ difficulty_bits,
+ fee,
+ proof_header: Some("00".repeat(80)),
+ signature: "00".repeat(32),
+ };
+ let bytes = tx.serialized_size_bytes()?;
+ let next_fee = fee_per_byte
+ .checked_mul(bytes as Amount)
+ .context("fee per byte times transaction bytes overflows")?;
+ if fee >= next_fee {
+ return Ok(fee);
+ }
+ fee = next_fee;
+ }
+ Ok(fee.min(mine_reward))
+ }
+
pub fn mine_one(&mut self) -> Result<Block> {
self.mine_one_at(now_ms())
}
@@ -1205,8 +1300,11 @@ mod tests {
assert_eq!(anchor, &node.chain().last().unwrap().hash);
assert_eq!(output.address, wallet.address());
let minimum_fee = first_mine.serialized_size_bytes().unwrap() as u64;
+ let (_, expected_estimate) = node
+ .build_mine_with_fee_rate(node.status().mining.automatic_pow_mine_fee)
+ .unwrap();
assert!(*fee >= minimum_fee);
- assert!(*fee <= minimum_fee + 1);
+ assert_eq!(*fee, expected_estimate.fee);
assert_eq!(
*difficulty_bits,
node.ledger().current_mine_difficulty_bits()
@@ -1236,8 +1334,11 @@ mod tests {
let mine = plan.pow_mined.expect("PoW should be queued");
let minimum_fee = mine.serialized_size_bytes().unwrap() as u64 * configured_fee_per_byte;
+ let (_, expected_estimate) = node
+ .build_mine_with_fee_rate(configured_fee_per_byte)
+ .unwrap();
assert!(mine.fee() >= minimum_fee);
- assert!(mine.fee() <= minimum_fee + configured_fee_per_byte);
+ assert_eq!(mine.fee(), expected_estimate.fee);
assert_eq!(
mine.amount(),
node.ledger().status().mine_reward - mine.fee()
diff --git a/src/domain.rs b/src/domain.rs
@@ -111,10 +111,14 @@ pub enum Transaction {
Mine {
output: TxOutput,
anchor: String,
+ #[serde(default)]
+ salt: u64,
nonce: u64,
difficulty_bits: u32,
#[serde(default)]
fee: Amount,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ proof_header: Option<String>,
signature: String,
},
}
@@ -260,11 +264,12 @@ impl Transaction {
Self::Mine {
output,
anchor,
+ salt,
nonce,
difficulty_bits,
fee,
..
- } => mine_payload(output, anchor, *nonce, *difficulty_bits, *fee),
+ } => mine_payload(output, anchor, *salt, *nonce, *difficulty_bits, *fee),
}
}
@@ -272,13 +277,31 @@ impl Transaction {
if let Self::Mine {
output,
anchor,
+ salt,
nonce,
difficulty_bits,
fee,
+ proof_header,
signature,
} = self
{
- let expected = mine_signature(output, anchor, *nonce, *difficulty_bits, *fee);
+ let expected = if let Some(proof_header) = proof_header {
+ let header = stratum_mine_header_bytes(
+ output,
+ anchor,
+ *salt,
+ *nonce,
+ *difficulty_bits,
+ *fee,
+ )?;
+ let expected_header = hex_encode(header);
+ if *proof_header != expected_header {
+ bail!("mine transaction proof header is invalid");
+ }
+ stratum_mine_signature(&header)
+ } else {
+ mine_signature(output, anchor, *salt, *nonce, *difficulty_bits, *fee)
+ };
if *signature != expected {
bail!("mine transaction proof hash is invalid");
}
@@ -461,14 +484,22 @@ fn canonical_outputs(outputs: &[TxOutput]) -> String {
fn mine_payload(
output: &TxOutput,
anchor: &str,
+ salt: u64,
nonce: u64,
difficulty_bits: u32,
fee: Amount,
) -> String {
- let payload = format!(
- "luun-mine:{}:{}:{}:{}",
- output.address, output.amount, anchor, nonce
- ) + &format!(":{difficulty_bits}");
+ let payload = if salt == 0 {
+ format!(
+ "luun-mine:{}:{}:{}:{}",
+ output.address, output.amount, anchor, nonce
+ )
+ } else {
+ format!(
+ "luun-mine:{}:{}:{}:{}:{}",
+ output.address, output.amount, anchor, salt, nonce
+ )
+ } + &format!(":{difficulty_bits}");
if fee == 0 {
payload
} else {
@@ -479,11 +510,174 @@ fn mine_payload(
fn mine_signature(
output: &TxOutput,
anchor: &str,
+ salt: u64,
nonce: u64,
difficulty_bits: u32,
fee: Amount,
) -> String {
- hex_hash(mine_payload(output, anchor, nonce, difficulty_bits, fee))
+ hex_hash(mine_payload(
+ output,
+ anchor,
+ salt,
+ nonce,
+ difficulty_bits,
+ fee,
+ ))
+}
+
+pub const STRATUM_EXTRANONCE1_HEX: &str = "00000000";
+pub const STRATUM_EXTRANONCE2_SIZE: usize = 4;
+const STRATUM_MINE_VERSION: [u8; 4] = [1, 0, 0, 0];
+const STRATUM_MINE_NTIME: [u8; 4] = [0, 0, 0, 0];
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct StratumMineTemplate {
+ pub recipient: String,
+ pub output_amount: Amount,
+ pub fee: Amount,
+ pub anchor: String,
+ pub salt: u64,
+ pub difficulty_bits: u32,
+ pub coinbase_prefix: Vec<u8>,
+ pub version_hex: String,
+ pub prev_hash_hex: String,
+ pub nbits_hex: String,
+ pub ntime_hex: String,
+}
+
+impl StratumMineTemplate {
+ pub fn coinb1_hex(&self) -> String {
+ hex_encode(&self.coinbase_prefix)
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct StratumMineShare {
+ pub extranonce2: [u8; 4],
+ pub header_nonce: [u8; 4],
+}
+
+pub fn pack_stratum_nonce(extranonce2: [u8; 4], header_nonce: [u8; 4]) -> u64 {
+ let extra = u32::from_be_bytes(extranonce2) as u64;
+ let nonce = u32::from_le_bytes(header_nonce) as u64;
+ (extra << 32) | nonce
+}
+
+fn unpack_stratum_nonce(nonce: u64) -> ([u8; 4], [u8; 4]) {
+ (
+ ((nonce >> 32) as u32).to_be_bytes(),
+ (nonce as u32).to_le_bytes(),
+ )
+}
+
+fn stratum_coinbase_prefix(
+ output: &TxOutput,
+ anchor: &str,
+ salt: u64,
+ difficulty_bits: u32,
+ fee: Amount,
+) -> Vec<u8> {
+ if salt == 0 {
+ format!(
+ "luun-stratum-mine:{}:{}:{}:{}:{}:",
+ output.address, output.amount, fee, anchor, difficulty_bits
+ )
+ } else {
+ format!(
+ "luun-stratum-mine:{}:{}:{}:{}:{}:{}:",
+ output.address, output.amount, fee, anchor, salt, difficulty_bits
+ )
+ }
+ .into_bytes()
+}
+
+fn stratum_coinbase_bytes(
+ output: &TxOutput,
+ anchor: &str,
+ salt: u64,
+ nonce: u64,
+ difficulty_bits: u32,
+ fee: Amount,
+) -> Vec<u8> {
+ let (extranonce2, _) = unpack_stratum_nonce(nonce);
+ let mut coinbase = stratum_coinbase_prefix(output, anchor, salt, difficulty_bits, fee);
+ coinbase.extend_from_slice(&[0, 0, 0, 0]);
+ coinbase.extend_from_slice(&extranonce2);
+ coinbase
+}
+
+fn double_sha256(bytes: &[u8]) -> [u8; 32] {
+ let first = Sha256::digest(bytes);
+ let second = Sha256::digest(first);
+ second.into()
+}
+
+fn stratum_mine_header_bytes(
+ output: &TxOutput,
+ anchor: &str,
+ salt: u64,
+ nonce: u64,
+ difficulty_bits: u32,
+ fee: Amount,
+) -> Result<[u8; 80]> {
+ let mut header = [0_u8; 80];
+ header[0..4].copy_from_slice(&STRATUM_MINE_VERSION);
+ let anchor_bytes =
+ decode_hex_array::<32>(anchor).context("mine transaction anchor is not hex")?;
+ header[4..36].copy_from_slice(&anchor_bytes);
+ let merkle_root = double_sha256(&stratum_coinbase_bytes(
+ output,
+ anchor,
+ salt,
+ nonce,
+ difficulty_bits,
+ fee,
+ ));
+ header[36..68].copy_from_slice(&merkle_root);
+ header[68..72].copy_from_slice(&STRATUM_MINE_NTIME);
+ header[72..76].copy_from_slice(&difficulty_bits.to_le_bytes());
+ let (_, header_nonce) = unpack_stratum_nonce(nonce);
+ header[76..80].copy_from_slice(&header_nonce);
+ Ok(header)
+}
+
+fn stratum_mine_signature(header: &[u8; 80]) -> String {
+ let mut digest = double_sha256(header);
+ digest.reverse();
+ hex_encode(digest)
+}
+
+fn stratum_mine_template(
+ recipient: impl Into<String>,
+ mine_reward: Amount,
+ fee: Amount,
+ anchor: &str,
+ salt: u64,
+ difficulty_bits: u32,
+) -> Result<StratumMineTemplate> {
+ let recipient = recipient.into();
+ if fee > mine_reward {
+ bail!("mine transaction fee exceeds reward");
+ }
+ let output = TxOutput {
+ address: recipient.clone(),
+ amount: mine_reward - fee,
+ };
+ let anchor_bytes =
+ decode_hex_array::<32>(anchor).context("mine transaction anchor is not hex")?;
+ Ok(StratumMineTemplate {
+ recipient,
+ output_amount: output.amount,
+ fee,
+ anchor: anchor.to_string(),
+ salt,
+ difficulty_bits,
+ coinbase_prefix: stratum_coinbase_prefix(&output, anchor, salt, difficulty_bits, fee),
+ version_hex: hex_encode(STRATUM_MINE_VERSION),
+ prev_hash_hex: hex_encode(anchor_bytes),
+ nbits_hex: hex_encode(difficulty_bits.to_le_bytes()),
+ ntime_hex: hex_encode(STRATUM_MINE_NTIME),
+ })
}
fn hash_meets_difficulty(hash: &str, difficulty_bits: u32) -> bool {
@@ -1417,18 +1611,21 @@ impl Ledger {
amount: self.mine_reward - fee,
};
let anchor = self.tip().hash.clone();
+ let salt = 1;
let difficulty_bits = self.current_mine_difficulty_bits();
for nonce in 0..u64::MAX {
- let signature = mine_signature(&output, &anchor, nonce, difficulty_bits, fee);
+ let signature = mine_signature(&output, &anchor, salt, nonce, difficulty_bits, fee);
if !hash_meets_difficulty(&signature, difficulty_bits) {
continue;
}
let transaction = Transaction::Mine {
output: output.clone(),
anchor: anchor.clone(),
+ salt,
nonce,
difficulty_bits,
fee,
+ proof_header: None,
signature,
};
if self.has_transaction(transaction.signature()) {
@@ -1440,6 +1637,56 @@ impl Ledger {
bail!("could not find valid mine proof");
}
+ pub fn stratum_mine_template(
+ &self,
+ recipient: impl Into<String>,
+ fee: Amount,
+ anchor: impl AsRef<str>,
+ salt: u64,
+ difficulty_bits: u32,
+ ) -> Result<StratumMineTemplate> {
+ stratum_mine_template(
+ recipient,
+ self.mine_reward,
+ fee,
+ anchor.as_ref(),
+ salt,
+ difficulty_bits,
+ )
+ }
+
+ pub fn build_stratum_mine(
+ &self,
+ template: StratumMineTemplate,
+ share: StratumMineShare,
+ ) -> Result<Transaction> {
+ let output = TxOutput {
+ address: template.recipient,
+ amount: template.output_amount,
+ };
+ let nonce = pack_stratum_nonce(share.extranonce2, share.header_nonce);
+ let header = stratum_mine_header_bytes(
+ &output,
+ &template.anchor,
+ template.salt,
+ nonce,
+ template.difficulty_bits,
+ template.fee,
+ )?;
+ let transaction = Transaction::Mine {
+ output,
+ anchor: template.anchor,
+ salt: template.salt,
+ nonce,
+ difficulty_bits: template.difficulty_bits,
+ fee: template.fee,
+ proof_header: Some(hex_encode(header)),
+ signature: stratum_mine_signature(&header),
+ };
+ self.validate_new_transaction(&transaction)?;
+ Ok(transaction)
+ }
+
pub fn submit_transaction(&mut self, transaction: Transaction) -> Result<bool> {
if self.has_transaction(transaction.signature()) {
return Ok(false);
@@ -1614,7 +1861,7 @@ impl Ledger {
};
if leader != block.miner {
bail!(
- "block miner {} is not selected leader {leader}",
+ "block finalizer {} is not selected leader {leader}",
block.miner
);
}
@@ -2155,7 +2402,7 @@ fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> {
bail!("block is missing leader proof");
};
if proof.public_key != block.miner {
- bail!("leader proof public key does not match block miner");
+ bail!("leader proof public key does not match block finalizer");
}
let ticket = tickets
.iter()
@@ -2164,7 +2411,7 @@ fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> {
})
.context("leader ticket is not pending for this height")?;
if ticket.owner != block.miner {
- bail!("leader ticket owner does not match block miner");
+ bail!("leader ticket owner does not match block finalizer");
}
if ticket.eligible_from_height > block.height {
bail!("leader ticket is not mature");
@@ -2720,14 +2967,17 @@ mod tests {
let anchor = ledger.tip().hash.clone();
let difficulty_bits = ledger.current_mine_difficulty_bits();
for nonce in 0..u64::MAX {
- let signature = mine_signature(&output, &anchor, nonce, difficulty_bits, fee);
+ let salt = 1;
+ let signature = mine_signature(&output, &anchor, salt, nonce, difficulty_bits, fee);
if hash_meets_difficulty(&signature, difficulty_bits) {
return Transaction::Mine {
output,
anchor,
+ salt,
nonce,
difficulty_bits,
fee,
+ proof_header: None,
signature,
};
}
@@ -3134,4 +3384,83 @@ mod tests {
let error = ledger.submit_transaction(stale_mine).unwrap_err();
assert!(format!("{error:#}").contains("mine transaction anchor is too old"));
}
+
+ #[test]
+ fn stratum_mine_header_proof_is_validated() {
+ let alice = Wallet::from_seed("stratum-proof-alice");
+ let ledger = ledger_with_allocation(&alice, 100 * MICRO_LUUN);
+ let anchor = ledger.tip().hash.clone();
+ let difficulty_bits = ledger.current_mine_difficulty_bits();
+ let template = ledger
+ .stratum_mine_template(alice.address(), 0, anchor, 1, difficulty_bits)
+ .unwrap();
+
+ let mut accepted = None;
+ for nonce in 0_u32..50_000 {
+ let result = ledger.build_stratum_mine(
+ template.clone(),
+ StratumMineShare {
+ extranonce2: [0, 0, 0, 0],
+ header_nonce: nonce.to_le_bytes(),
+ },
+ );
+ if let Ok(tx) = result {
+ accepted = Some(tx);
+ break;
+ }
+ }
+
+ let tx = accepted.expect("expected Stratum proof within search range");
+ let Transaction::Mine {
+ proof_header,
+ signature,
+ ..
+ } = tx
+ else {
+ panic!("expected mine action");
+ };
+ assert_eq!(proof_header.as_deref().unwrap_or_default().len(), 160);
+ assert!(hash_meets_difficulty(&signature, difficulty_bits));
+ }
+
+ #[test]
+ fn stratum_mine_salt_allows_multiple_actions_for_same_anchor() {
+ let alice = Wallet::from_seed("stratum-salt-alice");
+ let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_LUUN);
+ let anchor = ledger.tip().hash.clone();
+ let difficulty_bits = ledger.current_mine_difficulty_bits();
+
+ for salt in [1, 2] {
+ let template = ledger
+ .stratum_mine_template(alice.address(), 0, anchor.clone(), salt, difficulty_bits)
+ .unwrap();
+ let mut accepted = None;
+ for nonce in 0_u32..50_000 {
+ let result = ledger.build_stratum_mine(
+ template.clone(),
+ StratumMineShare {
+ extranonce2: [0, 0, 0, 0],
+ header_nonce: nonce.to_le_bytes(),
+ },
+ );
+ if let Ok(tx) = result {
+ accepted = Some(tx);
+ break;
+ }
+ }
+ let tx = accepted.expect("expected Stratum proof within search range");
+ assert!(ledger.submit_transaction(tx).unwrap());
+ }
+
+ assert_eq!(ledger.pending().len(), 2);
+ let salts = ledger
+ .pending()
+ .iter()
+ .map(|tx| match tx {
+ Transaction::Mine { salt, .. } => *salt,
+ _ => panic!("expected mine action"),
+ })
+ .collect::<BTreeSet<_>>();
+ assert_eq!(salts, BTreeSet::from([1, 2]));
+ }
}
diff --git a/src/main.rs b/src/main.rs
@@ -9,8 +9,8 @@ use std::{
use anyhow::{Context, Result, bail};
use luun::{
- adapters::{chain_store::SqliteChainStore, config_store, http, p2p, wallet_store},
- app::{NodeCore, PeerBook, SharedNode, SharedPeerBook, now_ms},
+ adapters::{chain_store::SqliteChainStore, config_store, http, p2p, stratum, wallet_store},
+ app::{NodeCore, PeerBook, SharedNode, SharedPeerBook, StratumStatus, now_ms},
domain::{Amount, ChainSnapshot, GenesisBurn, Ledger, MICRO_LUUN, run_vdf},
};
use tokio::sync::Mutex;
@@ -92,13 +92,26 @@ async fn main() -> Result<()> {
println!("management UI: http://{}", opts.http_addr);
println!("p2p listener: {}", opts.p2p_addr);
println!(
- "automatic mining: VDF-driven, burning {} LUUN per block with {} LUUN per byte fee rate",
+ "automatic finalization: VDF-driven, burning {} LUUN per block with {} LUUN per byte fee rate",
format_luun(initial_burn_per_block),
format_luun(initial_burn_fee)
);
let gossip =
p2p::GossipNetwork::start(Arc::clone(&node), Arc::clone(&peers), opts.p2p_addr).await?;
+ let mut stratum_status = StratumStatus {
+ enabled: false,
+ listen_addr: None,
+ };
+ if let Some(stratum_addr) = opts.stratum_addr {
+ let stratum =
+ stratum::StratumServer::start(Arc::clone(&node), gossip.clone(), stratum_addr).await?;
+ println!("stratum listener: {}", stratum.listen_addr());
+ stratum_status = StratumStatus {
+ enabled: true,
+ listen_addr: Some(stratum.listen_addr().to_string()),
+ };
+ }
if has_chain {
let persistence_node = Arc::clone(&node);
@@ -107,10 +120,10 @@ async fn main() -> Result<()> {
run_chain_persistence(persistence_node, persistence_store).await;
});
- let miner_node = Arc::clone(&node);
- let miner_gossip = gossip.clone();
+ let finalizer_node = Arc::clone(&node);
+ let finalizer_gossip = gossip.clone();
tokio::spawn(async move {
- run_automatic_miner(miner_node, miner_gossip).await;
+ run_automatic_finalizer(finalizer_node, finalizer_gossip).await;
});
let sync_node = Arc::clone(&node);
@@ -119,7 +132,7 @@ async fn main() -> Result<()> {
run_peer_sync(sync_node, sync_gossip).await;
});
} else {
- println!("setup mode: no chain selected; skipping mining and chain persistence");
+ println!("setup mode: no chain selected; skipping finalization and chain persistence");
}
http::serve(
@@ -127,9 +140,12 @@ async fn main() -> Result<()> {
peers,
gossip,
ui_config,
- config_path,
- wallet_path,
- opts.http_addr,
+ http::ServeOptions {
+ config_path,
+ wallet_path,
+ stratum: stratum_status,
+ addr: opts.http_addr,
+ },
)
.await
}
@@ -226,6 +242,7 @@ struct CliOptions {
chain_db_path: Option<PathBuf>,
http_addr: SocketAddr,
p2p_addr: SocketAddr,
+ stratum_addr: Option<SocketAddr>,
peers: Vec<String>,
join_peers: Vec<String>,
chain_mode: ChainMode,
@@ -243,6 +260,7 @@ impl CliOptions {
chain_db_path: None,
http_addr: SocketAddr::from_str("127.0.0.1:18661")?,
p2p_addr: SocketAddr::from_str("127.0.0.1:9444")?,
+ stratum_addr: None,
peers: Vec::new(),
join_peers: Vec::new(),
chain_mode: ChainMode::Setup,
@@ -280,6 +298,13 @@ impl CliOptions {
.parse()
.context("invalid --p2p address")?;
}
+ "--stratum" => {
+ opts.stratum_addr = Some(
+ next_value(&mut args, "--stratum")?
+ .parse()
+ .context("invalid --stratum address")?,
+ );
+ }
"--join" => {
if opts.chain_mode == ChainMode::Genesis {
bail!("choose either --genesis or --join, not both");
@@ -375,7 +400,8 @@ fn help_text() -> &'static str {
--chain-db <path> Chain SQLite database (default <data-dir>/chain.sqlite3)\n\
--http <addr:port> HTTP management UI address (default 127.0.0.1:18661)\n\
--p2p <addr:port> P2P TCP listener address (default 127.0.0.1:9444)\n\
- --join <addr:port> Fetch chain snapshot from this peer before mining\n\
+ --stratum <addr:port> Stratum V1 listener for SHA-256 ASIC miners\n\
+ --join <addr:port> Fetch chain snapshot from this peer before finalization\n\
--data-dir <path> Local wallet directory\n\n\
Environment:\n\
LUUN_DEV_SKIP_SEED_VERIFY=1 Show a setup button to skip seed verification\n"
@@ -481,7 +507,7 @@ async fn join_chain_ledger(join_peers: &[String], advertised_addr: SocketAddr) -
)
}
-async fn run_automatic_miner(node: SharedNode, gossip: p2p::GossipNetwork) {
+async fn run_automatic_finalizer(node: SharedNode, gossip: p2p::GossipNetwork) {
let mut last_logged_skip: Option<(u64, String)> = None;
loop {
let (height, plan, outbox) = {
@@ -500,7 +526,7 @@ async fn run_automatic_miner(node: SharedNode, gossip: p2p::GossipNetwork) {
if let Some(reason) = &plan.skipped_reason {
let skip = (height, reason.clone());
if last_logged_skip.as_ref() != Some(&skip) {
- println!("auto-mining skipped at height {height}: {reason}");
+ println!("auto-finalization skipped at height {height}: {reason}");
last_logged_skip = Some(skip);
}
}
@@ -525,18 +551,18 @@ async fn run_automatic_miner(node: SharedNode, gossip: p2p::GossipNetwork) {
}
};
- let (mined, outbox) = {
+ let (finalized, outbox) = {
let mut node = node.lock().await;
- let mined = node.complete_prepared_block(work, vdf_output);
+ let finalized = node.complete_prepared_block(work, vdf_output);
let outbox = node.drain_outbox();
- (mined, outbox)
+ (finalized, outbox)
};
- match mined {
+ match finalized {
Ok(block) => {
- println!("auto-mined block {} ({})", block.height, block.hash);
+ println!("auto-finalized block {} ({})", block.height, block.hash);
}
- Err(error) => println!("auto-mining skipped after VDF: {error:#}"),
+ Err(error) => println!("auto-finalization skipped after VDF: {error:#}"),
}
if let Err(error) = gossip.broadcast(outbox).await {
@@ -628,6 +654,7 @@ mod tests {
fn help_mentions_dev_seed_verify_bypass_env() {
assert!(help_text().contains("LUUN_DEV_SKIP_SEED_VERIFY=1"));
assert!(help_text().contains("skip seed verification"));
+ assert!(help_text().contains("--stratum <addr:port>"));
}
fn ledger_with_one_spendable_luun(wallet: &Wallet) -> Ledger {
@@ -670,6 +697,12 @@ mod tests {
}
#[test]
+ fn stratum_port_can_be_configured() {
+ let opts = parse(&["--stratum", "127.0.0.1:3333"]).unwrap().unwrap();
+ assert_eq!(opts.stratum_addr, Some("127.0.0.1:3333".parse().unwrap()));
+ }
+
+ #[test]
fn removed_wallet_seed_is_rejected() {
let error = parse(&["--wallet-seed", "alice", "--genesis"]).unwrap_err();
assert!(error.to_string().contains("--wallet-seed was removed"));
diff --git a/tests/luun.rs b/tests/luun.rs
@@ -385,7 +385,7 @@ fn burn_larger_than_existing_balance_cannot_use_next_block_reward() {
}
#[test]
-fn transaction_fees_are_paid_to_the_block_miner() {
+fn transaction_fees_are_paid_to_the_block_finalizer() {
let alice = Wallet::from_seed("fee-miner-alice");
let bob = Wallet::from_seed("fee-payer-bob");
let mut allocations = BTreeMap::new();