commit 9e17dfe4cff42c7b105e6b8cac8e60bcbd6ddd57
parent 0b6c5a352ee3c09593a5a1fdb86e0081a34be465
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 19 Aug 2026 13:44:35 +0200
Add Stratum endpoint settings
Diffstat:
10 files changed, 240 insertions(+), 11 deletions(-)
diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs
@@ -21,6 +21,7 @@ pub const DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT: u8 = 50;
pub const DEFAULT_POW_MINING_WORKERS: u8 = 1;
pub const MAX_POW_MINING_WORKERS: u8 = 32;
pub const DEFAULT_P2P_BIND_PORT: u16 = 9444;
+pub const DEFAULT_STRATUM_BIND_PORT: u16 = 3333;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct UiConfig {
@@ -37,6 +38,8 @@ pub struct UiConfig {
pub p2p_accept_inbound: bool,
pub p2p_bind_port: u16,
pub p2p_announce_addr: Option<String>,
+ pub stratum_enabled: bool,
+ pub stratum_bind_port: u16,
pub peers: Vec<String>,
pub address_book: BTreeMap<String, String>,
}
@@ -56,6 +59,8 @@ impl Default for UiConfig {
p2p_accept_inbound: false,
p2p_bind_port: DEFAULT_P2P_BIND_PORT,
p2p_announce_addr: None,
+ stratum_enabled: false,
+ stratum_bind_port: DEFAULT_STRATUM_BIND_PORT,
peers: Vec::new(),
address_book: BTreeMap::new(),
}
@@ -91,6 +96,10 @@ struct ConfigFile {
#[serde(default)]
p2p_announce_addr: Option<String>,
#[serde(default)]
+ stratum_enabled: bool,
+ #[serde(default = "default_stratum_bind_port")]
+ stratum_bind_port: u16,
+ #[serde(default)]
peers: Vec<String>,
#[serde(default)]
address_book: BTreeMap<String, String>,
@@ -127,6 +136,8 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> {
p2p_accept_inbound: Some(config.p2p_accept_inbound),
p2p_bind_port: config.p2p_bind_port,
p2p_announce_addr: config.p2p_announce_addr.clone(),
+ stratum_enabled: config.stratum_enabled,
+ stratum_bind_port: config.stratum_bind_port,
peers: config.peers.clone(),
address_book: config.address_book.clone(),
};
@@ -185,6 +196,8 @@ fn load(path: &Path) -> Result<UiConfig> {
p2p_accept_inbound,
p2p_bind_port: stored.p2p_bind_port,
p2p_announce_addr: stored.p2p_announce_addr,
+ stratum_enabled: stored.stratum_enabled,
+ stratum_bind_port: stored.stratum_bind_port,
peers: stored.peers,
address_book: stored.address_book,
})
@@ -202,6 +215,10 @@ fn default_p2p_bind_port() -> u16 {
DEFAULT_P2P_BIND_PORT
}
+fn default_stratum_bind_port() -> u16 {
+ DEFAULT_STRATUM_BIND_PORT
+}
+
fn create_config_file(path: &Path) -> Result<File> {
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(true);
@@ -246,6 +263,8 @@ mod tests {
assert!(stored.contains("\"p2p_accept_inbound\": false"));
assert!(stored.contains("\"p2p_bind_port\": 9444"));
assert!(stored.contains("\"p2p_announce_addr\": null"));
+ assert!(stored.contains("\"stratum_enabled\": false"));
+ assert!(stored.contains("\"stratum_bind_port\": 3333"));
assert!(stored.contains("\"peers\": []"));
assert!(stored.contains("\"address_book\": {}"));
}
@@ -269,6 +288,8 @@ mod tests {
p2p_accept_inbound: true,
p2p_bind_port: 9555,
p2p_announce_addr: Some("203.0.113.10:9444".to_string()),
+ stratum_enabled: true,
+ stratum_bind_port: 3334,
peers: vec!["127.0.0.1:9444".to_string()],
address_book: [("iuna-address".to_string(), "Alice".to_string())].into(),
..UiConfig::default()
@@ -291,6 +312,8 @@ mod tests {
config.p2p_announce_addr.as_deref(),
Some("203.0.113.10:9444")
);
+ assert!(config.stratum_enabled);
+ assert_eq!(config.stratum_bind_port, 3334);
assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
assert_eq!(
config.address_book.get("iuna-address"),
@@ -336,6 +359,8 @@ mod tests {
assert!(!config.keep_track_of_metrics);
assert!(!config.p2p_accept_inbound);
assert_eq!(config.p2p_bind_port, 9444);
+ assert!(!config.stratum_enabled);
+ assert_eq!(config.stratum_bind_port, 3333);
assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
assert!(config.address_book.is_empty());
}
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -39,7 +39,7 @@ use actions::{
api_address_book_delete_form, api_address_book_form, api_burn_fee_estimate_form,
api_burn_per_block_form, api_chain_reset_form, api_metrics_settings_form,
api_mine_fee_estimate_form, api_p2p_announce_form, api_p2p_inbound_form, api_peer_delete_form,
- api_peer_form, api_pow_mining_form, api_recovery_vdf_settings_form,
+ api_peer_form, api_pow_mining_form, api_recovery_vdf_settings_form, api_stratum_settings_form,
api_transfer_fee_estimate_form, api_transfer_form, api_wallet_generate_form,
api_wallet_import_form, apply_config_form, burn_per_block_form, peer_form, transfer_form,
};
@@ -183,6 +183,7 @@ pub async fn serve(
.route("/api/settings/chain-reset", post(api_chain_reset_form))
.route("/api/settings/p2p-inbound", post(api_p2p_inbound_form))
.route("/api/settings/p2p-announce", post(api_p2p_announce_form))
+ .route("/api/settings/stratum", post(api_stratum_settings_form))
.route("/api/transfer", post(api_transfer_form))
.route("/settings/burn-per-block", post(burn_per_block_form))
.route("/transfer", post(transfer_form))
diff --git a/src/adapters/http/actions.rs b/src/adapters/http/actions.rs
@@ -12,8 +12,8 @@ use tokio::sync::Mutex;
use super::types::{
ActionResponse, AddressBookDeleteForm, AddressBookForm, BurnSettingsForm, ChainResetForm,
ConfigForm, FeeEstimateResponse, MetricsSettingsForm, P2pAnnounceForm, P2pInboundForm,
- PeerForm, PowMiningForm, RecoveryVdfSettingsForm, SeedPhraseForm, TransferForm,
- WalletSetupResponse,
+ PeerForm, PowMiningForm, RecoveryVdfSettingsForm, SeedPhraseForm, StratumSettingsForm,
+ TransferForm, WalletSetupResponse,
};
use super::{
HttpState, action_json, api_error, config_store, estimate_burn_fee, estimate_mine_fee,
@@ -136,6 +136,13 @@ pub(super) async fn api_p2p_inbound_form(
action_json(set_p2p_accept_inbound(&state, form.enabled, form.bind_port).await)
}
+pub(super) async fn api_stratum_settings_form(
+ State(state): State<HttpState>,
+ Form(form): Form<StratumSettingsForm>,
+) -> Json<ActionResponse> {
+ action_json(set_stratum_settings(&state, form.enabled, form.bind_port).await)
+}
+
pub(super) async fn burn_per_block_form(
State(state): State<HttpState>,
Form(form): Form<BurnSettingsForm>,
@@ -394,6 +401,25 @@ pub(super) async fn set_p2p_accept_inbound(
Ok(())
}
+pub(super) async fn set_stratum_settings(
+ state: &HttpState,
+ enabled: bool,
+ bind_port: Option<u16>,
+) -> Result<()> {
+ let bind_port = bind_port.unwrap_or(config_store::DEFAULT_STRATUM_BIND_PORT);
+ if bind_port == 0 {
+ bail!("Stratum bind port must be between 1 and 65535");
+ }
+
+ let mut config = state.ui_config.lock().await;
+ let mut next_config = config.clone();
+ next_config.stratum_enabled = enabled;
+ next_config.stratum_bind_port = bind_port;
+ config_store::save(&state.config_path, &next_config)?;
+ *config = next_config;
+ Ok(())
+}
+
async fn replace_metrics_for_snapshot(
store: &SqliteUiDataStore,
snapshot: crate::domain::ChainSnapshot,
diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs
@@ -76,6 +76,8 @@ pub(super) async fn api_config(State(state): State<HttpState>) -> Json<ConfigRes
config: state.ui_config.lock().await.clone(),
p2p_inbound_runtime_active: state.gossip.accepts_inbound().await,
p2p_runtime_bind_addr: state.gossip.listen_addr().to_string(),
+ stratum_runtime_enabled: state.stratum.enabled,
+ stratum_runtime_listen_addr: state.stratum.listen_addr.clone(),
})
}
diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs
@@ -761,9 +761,9 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html>
</div>
<div class="fee-preview" x-text="autoPowStatusLabel()"></div>
</div>
- <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="panel-separator" x-show="stratumRuntimeEnabled()"></div>
+ <div class="stratum-config" x-show="stratumRuntimeEnabled()">
+ <div class="stratum-note">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>
@@ -1197,6 +1197,25 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html>
<div class="setup-actions"><button class="primary" type="submit">Save</button></div>
</form>
</div>
+ <div class="panel" x-show="advancedMode()">
+ <div class="settings-mode-row">
+ <div class="settings-mode-copy">
+ <div class="settings-mode-title">Stratum endpoint</div>
+ <div class="muted" x-text="stratumEnabled ? 'ASIC miners can connect after the listener is active.' : 'Stratum listener is disabled.'"></div>
+ </div>
+ <label class="toggle-switch" :class="{ active: stratumEnabled }">
+ <input type="checkbox" :checked="stratumEnabled" @change="setStratumEnabled($event.target.checked)">
+ <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
+ <span class="toggle-text" x-text="stratumEnabled ? 'On' : 'Off'"></span>
+ </label>
+ </div>
+ <form class="settings-form" x-show="stratumEnabled" x-transition @submit.prevent="saveStratumSettings">
+ <label>Bind port<input x-model.number="stratumBindPort" @input="stratumBindPortDirty = true" type="number" min="1" max="65535" step="1" required></label>
+ <div class="muted" x-text="stratumRestartMessage() || 'Stratum is using the configured runtime setting.'"></div>
+ <div class="setup-actions"><button class="primary" type="submit">Save</button></div>
+ </form>
+ <div class="muted" x-show="!stratumEnabled && stratumRestartRequired()" x-text="stratumRestartMessage()"></div>
+ </div>
<div class="panel">
<h3>Change Password</h3>
<div class="setup-feedback" :class="settingsFeedback?.kind" x-show="settingsFeedback" x-transition x-text="settingsFeedback?.message"></div>
diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs
@@ -99,12 +99,20 @@ pub(super) struct P2pInboundForm {
pub(super) bind_port: Option<u16>,
}
+#[derive(Debug, Deserialize)]
+pub(super) struct StratumSettingsForm {
+ pub(super) enabled: bool,
+ pub(super) bind_port: Option<u16>,
+}
+
#[derive(Debug, Serialize)]
pub(super) struct ConfigResponse {
#[serde(flatten)]
pub(super) config: UiConfig,
pub(super) p2p_inbound_runtime_active: bool,
pub(super) p2p_runtime_bind_addr: String,
+ pub(super) stratum_runtime_enabled: bool,
+ pub(super) stratum_runtime_listen_addr: Option<String>,
}
#[derive(Debug, Deserialize)]
diff --git a/src/cli.rs b/src/cli.rs
@@ -36,6 +36,17 @@ pub(crate) fn configured_p2p_bind_addr(
opts.p2p_addr
}
+pub(crate) fn configured_stratum_addr(
+ opts: &CliOptions,
+ ui_config: &config_store::UiConfig,
+) -> Option<SocketAddr> {
+ opts.stratum_addr.or_else(|| {
+ ui_config
+ .stratum_enabled
+ .then(|| SocketAddr::from((Ipv4Addr::UNSPECIFIED, ui_config.stratum_bind_port)))
+ })
+}
+
pub(crate) fn apply_cli_p2p_config_overrides(
opts: &CliOptions,
ui_config: &mut config_store::UiConfig,
@@ -61,6 +72,25 @@ pub(crate) fn apply_cli_p2p_config_overrides(
dirty
}
+pub(crate) fn apply_cli_stratum_config_overrides(
+ opts: &CliOptions,
+ ui_config: &mut config_store::UiConfig,
+) -> bool {
+ let Some(addr) = opts.stratum_addr else {
+ return false;
+ };
+ let mut dirty = false;
+ if !ui_config.stratum_enabled {
+ ui_config.stratum_enabled = true;
+ dirty = true;
+ }
+ if ui_config.stratum_bind_port != addr.port() {
+ ui_config.stratum_bind_port = addr.port();
+ dirty = true;
+ }
+ dirty
+}
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ChainMode {
Setup,
diff --git a/src/main.rs b/src/main.rs
@@ -25,8 +25,9 @@ use tokio::sync::Mutex;
mod cli;
use cli::{
- ChainMode, CliOptions, apply_cli_p2p_config_overrides, configured_p2p_announce_addr,
- configured_p2p_bind_addr, initial_burn_fee, initial_burn_per_block, validate_wallet_for_mode,
+ ChainMode, CliOptions, apply_cli_p2p_config_overrides, apply_cli_stratum_config_overrides,
+ configured_p2p_announce_addr, configured_p2p_bind_addr, configured_stratum_addr,
+ initial_burn_fee, initial_burn_per_block, validate_wallet_for_mode,
};
#[cfg(test)]
use cli::{default_data_dir, help_text};
@@ -62,9 +63,12 @@ async fn main() -> Result<()> {
);
}
let mut ui_config = config_store::load_or_create(&config_path)?;
- let ui_config_dirty = apply_cli_p2p_config_overrides(&opts, &mut ui_config);
+ let p2p_config_dirty = apply_cli_p2p_config_overrides(&opts, &mut ui_config);
+ let stratum_config_dirty = apply_cli_stratum_config_overrides(&opts, &mut ui_config);
+ let ui_config_dirty = p2p_config_dirty || stratum_config_dirty;
let p2p_announce_addr = configured_p2p_announce_addr(&opts, &ui_config)?;
let configured_p2p_addr = configured_p2p_bind_addr(&opts, &ui_config);
+ let configured_stratum_addr = configured_stratum_addr(&opts, &ui_config);
let p2p_accept_inbound = ui_config.p2p_accept_inbound;
let advertised_p2p_addr = p2p_announce_addr.unwrap_or(configured_p2p_addr);
let wallet_load = load_startup_wallet(&wallet_path)?;
@@ -155,7 +159,7 @@ async fn main() -> Result<()> {
enabled: false,
listen_addr: None,
};
- if let Some(stratum_addr) = opts.stratum_addr {
+ if let Some(stratum_addr) = configured_stratum_addr {
let stratum =
stratum::StratumServer::start(Arc::clone(&node), gossip.clone(), stratum_addr).await?;
println!("stratum listener: {}", stratum.listen_addr());
diff --git a/src/main_tests.rs b/src/main_tests.rs
@@ -14,7 +14,8 @@ use tokio::sync::Mutex;
use super::{
ChainMode, CliOptions, GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK, StartupWallet,
- apply_cli_p2p_config_overrides, configured_p2p_announce_addr, configured_p2p_bind_addr,
+ apply_cli_p2p_config_overrides, apply_cli_stratum_config_overrides,
+ configured_p2p_announce_addr, configured_p2p_bind_addr, configured_stratum_addr,
extrapolate_vdf_rounds, help_text, initial_burn_fee, initial_burn_per_block, initialize_ledger,
load_startup_wallet, measure_vdf_rounds, persist_chain_snapshot, project_ui_data_store,
run_chain_persistence_with_interval, validate_wallet_for_mode,
@@ -77,6 +78,36 @@ fn stratum_port_can_be_configured() {
}
#[test]
+fn configured_stratum_addr_uses_settings_when_enabled() {
+ let opts = parse(&[]).unwrap().unwrap();
+ let config = UiConfig {
+ stratum_enabled: true,
+ stratum_bind_port: 3334,
+ ..UiConfig::default()
+ };
+
+ assert_eq!(
+ configured_stratum_addr(&opts, &config).unwrap().to_string(),
+ "0.0.0.0:3334"
+ );
+}
+
+#[test]
+fn configured_stratum_addr_prefers_cli_addr() {
+ let opts = parse(&["--stratum", "127.0.0.1:3335"]).unwrap().unwrap();
+ let config = UiConfig {
+ stratum_enabled: true,
+ stratum_bind_port: 3334,
+ ..UiConfig::default()
+ };
+
+ assert_eq!(
+ configured_stratum_addr(&opts, &config).unwrap().to_string(),
+ "127.0.0.1:3335"
+ );
+}
+
+#[test]
fn debug_logging_can_be_enabled() {
assert!(!parse(&[]).unwrap().unwrap().debug);
assert!(parse(&["--debug"]).unwrap().unwrap().debug);
@@ -345,6 +376,16 @@ fn cli_p2p_port_overrides_config_bind_port() {
}
#[test]
+fn cli_stratum_addr_enables_and_persists_config_port() {
+ let opts = parse(&["--stratum", "127.0.0.1:3335"]).unwrap().unwrap();
+ let mut config = UiConfig::default();
+
+ assert!(apply_cli_stratum_config_overrides(&opts, &mut config));
+ assert!(config.stratum_enabled);
+ assert_eq!(config.stratum_bind_port, 3335);
+}
+
+#[test]
fn http_management_port_defaults_to_iuna_port() {
let opts = parse(&[]).unwrap().unwrap();
assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18661");
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -67,6 +67,9 @@ window.iunaApp = function iunaApp() {
p2pBindPortDirty: false,
p2pAnnounceAddr: "",
p2pAnnounceDirty: false,
+ stratumEnabled: false,
+ stratumBindPort: 3333,
+ stratumBindPortDirty: false,
setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false, requires_peer: false },
setupNodeMode: "wallet",
setupWalletMode: "create",
@@ -420,6 +423,10 @@ window.iunaApp = function iunaApp() {
if (!this.p2pBindPortDirty) {
this.p2pBindPort = Number(this.config.p2p_bind_port || 9444);
}
+ this.stratumEnabled = this.config.stratum_enabled === true;
+ if (!this.stratumBindPortDirty) {
+ this.stratumBindPort = Number(this.config.stratum_bind_port || 3333);
+ }
if (
options.addressBookVersion === undefined ||
options.addressBookVersion >= this.addressBookVersion
@@ -1601,6 +1608,68 @@ window.iunaApp = function iunaApp() {
return `Restart iuna to open public P2P on ${configured || "the configured bind port"}.`;
},
+ async setStratumEnabled(enabled) {
+ const previous = this.stratumEnabled;
+ try {
+ this.stratumEnabled = enabled;
+ await this.postForm(
+ "/api/settings/stratum",
+ { enabled, bind_port: this.stratumBindPortValue() },
+ enabled ? "Stratum endpoint setting saved" : "Stratum endpoint disabled"
+ );
+ this.stratumBindPortDirty = false;
+ await this.refreshConfig();
+ } catch (error) {
+ this.stratumEnabled = previous;
+ this.showFlash(error.message, "error");
+ }
+ },
+
+ stratumBindPortValue() {
+ const port = Number(this.stratumBindPort);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ throw new Error("Stratum bind port must be between 1 and 65535");
+ }
+ return port;
+ },
+
+ stratumConfiguredBindAddr() {
+ const port = Number(this.config.stratum_bind_port || 3333);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
+ return `0.0.0.0:${port}`;
+ },
+
+ stratumRestartRequired() {
+ const runtimeActive = this.config.stratum_runtime_enabled === true;
+ if (this.stratumEnabled !== runtimeActive) return true;
+ if (!this.stratumEnabled) return false;
+ const configured = this.stratumConfiguredBindAddr();
+ return configured ? this.config.stratum_runtime_listen_addr !== configured : false;
+ },
+
+ stratumRestartMessage() {
+ if (!this.stratumRestartRequired()) return "";
+ if (!this.stratumEnabled && this.config.stratum_runtime_enabled === true) {
+ return "Restart iuna to close the Stratum listener.";
+ }
+ const configured = this.stratumConfiguredBindAddr();
+ return `Restart iuna to open Stratum on ${configured || "the configured bind port"}.`;
+ },
+
+ async saveStratumSettings() {
+ try {
+ await this.postForm(
+ "/api/settings/stratum",
+ { enabled: this.stratumEnabled, bind_port: this.stratumBindPortValue() },
+ "Stratum endpoint setting saved"
+ );
+ this.stratumBindPortDirty = false;
+ await this.refreshConfig();
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ }
+ },
+
async saveP2pAnnounce() {
if (!this.p2pAcceptInbound) {
this.showFlash("Enable public node before setting a public P2P address", "error");
@@ -3023,6 +3092,10 @@ window.iunaApp = function iunaApp() {
return this.status.stratum?.listen_addr || "-";
},
+ stratumRuntimeEnabled() {
+ return this.status.stratum?.enabled === true;
+ },
+
stratumPoolUrl() {
const listen = this.status.stratum?.listen_addr;
if (!this.status.stratum?.enabled || !listen) return "-";