iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 0d5d39b49ac0ce1c5bc028600c5db070b475b61a
parent 92fdfec95df9afca46bd58798c61ea111dd05290
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Mon,  7 Sep 2026 23:17:41 +0200

feat: add public wallet endpoint

Diffstat:
MDockerfile.node | 2+-
MREADME.md | 36+++++++++++++++++++++++++++++++++++-
Mconfig/deployment.yml | 24++++++++++++++++++++++++
Msrc/adapters/config_store.rs | 25+++++++++++++++++++++++++
Msrc/adapters/http.rs | 10++++++++--
Msrc/adapters/http/actions.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/adapters/http/api.rs | 4++++
Msrc/adapters/http/auth_routes.rs | 1+
Msrc/adapters/http/index_html.rs | 36++++++++++++++++++++++++++++++++++--
Msrc/adapters/http/request_auth.rs | 1+
Msrc/adapters/http/state.rs | 2++
Msrc/adapters/http/types.rs | 8++++++++
Msrc/adapters/mod.rs | 1+
Msrc/adapters/ui_data_store.rs | 24++++++++++++++++++++++++
Asrc/adapters/wallet_endpoint.rs | 597+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/wallet.rs | 18+++++++++++++++++-
Msrc/cli.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/main_tests.rs | 51++++++++++++++++++++++++++++++++++++++++++++-------
Mwww/assets/iuna-ui.js | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
20 files changed, 1113 insertions(+), 23 deletions(-)

diff --git a/Dockerfile.node b/Dockerfile.node @@ -14,7 +14,7 @@ COPY LICENSE /usr/share/doc/iuna/LICENSE WORKDIR /data -EXPOSE 18661 9444 +EXPOSE 18661 18662 9444 ENTRYPOINT ["iuna"] CMD ["--data-dir", "/data", "--http", "0.0.0.0:18661", "--p2p", "0.0.0.0:9444", "--p2p-announce", "142.132.164.59:9444"] diff --git a/README.md b/README.md @@ -108,6 +108,7 @@ Deployment publishes two images to the `jhx-app` k3s cluster: - `https://getiuna.org/` routes to the static website image. - `https://admin.iuna.jhx.app/` routes to the IP-restricted node management UI. +- `https://iuna.jhx.app/v1` routes to the public lightweight-wallet API. - `iuna.jhx.app:9444` routes to the node P2P listener. Useful overrides: @@ -124,7 +125,9 @@ You can use iuna as a wallet, a node, or a public peer. - **Node:** keep a local chain copy and participate in mining/finalization settings. - **Public peer:** same as a node, but reachable by other nodes through a public P2P address. -Keep the management UI local or behind a strict access control such as the production IP allowlist. Only the P2P listener should be generally reachable by other nodes. +Keep the management UI local or behind a strict access control such as the +production IP allowlist. Only the P2P listener and explicitly enabled wallet +endpoint should be generally reachable. ## Optional: CLI @@ -209,6 +212,8 @@ settings: - `IUNA_AUTOMATIC_BURN_ENABLED=true|false` - `IUNA_POW_MINING_ENABLED=true|false` - `IUNA_POW_MINING_WORKERS=1..32` +- `IUNA_WALLET_ENDPOINT_ENABLED=true|false` +- `IUNA_WALLET_ENDPOINT_PORT=1..65535` (default `18662`) The compose bootstrap also selects the isolated `iuna-local-testnet-v1` launch profile. Its PoW burn-committee lineages are eligible immediately, so joining @@ -241,6 +246,35 @@ Reset the local testnet volumes and create a fresh genesis: docker compose down -v ``` +## Optional: Public Wallet Endpoint + +Public nodes can expose an unauthenticated API for lightweight mobile wallets on +a listener that is completely separate from the management UI: + +```sh +./iuna --wallet-endpoint 0.0.0.0:18662 +``` + +The same setting can be managed in **Settings → Wallet endpoint**, or at startup +with `IUNA_WALLET_ENDPOINT_ENABLED=true` and +`IUNA_WALLET_ENDPOINT_PORT=18662`. A settings change takes effect after restart. +Keep port `18661` local or access-controlled; forward only the wallet API port. + +The v1 API provides: + +- `GET /v1/status` — chain/signing parameters and current tip; +- `GET /v1/addresses/{address}/balance` — confirmed and spendable balance; +- `GET /v1/addresses/{address}/utxos` — spendable inputs for local signing; +- `GET /v1/addresses/{address}/transactions` — paginated pending/confirmed history; +- `GET /v1/transactions/{signature}` — pending or confirmed transaction lookup; +- `POST /v1/transactions` — validate, relay, and gossip a signed transfer/burn. + +Clients keep private keys and seed phrases locally. The public endpoint never +creates signatures and does not expose management, local-wallet, mining, peer, +configuration, or authentication routes. JSON request bodies are capped at 64 +KiB. The production manifest exposes this API as `https://iuna.jhx.app/v1` while +`https://admin.iuna.jhx.app/` remains the separately protected management UI. + ## Optional: Stratum Mining iuna can expose a Stratum V1 endpoint for SHA-256 ASIC miners such as a Bitaxe: diff --git a/config/deployment.yml b/config/deployment.yml @@ -100,8 +100,15 @@ spec: - web - websecure routes: + - match: Host(`iuna.jhx.app`) && PathPrefix(`/v1`) + kind: Rule + priority: 100 + services: + - name: iuna-wallet + port: 18662 - match: Host(`iuna.jhx.app`) kind: Rule + priority: 1 middlewares: - name: iuna-redirect-to-getiuna services: @@ -179,11 +186,15 @@ ${IUNA_NODE_GENESIS_ARG} - 0.0.0.0:9444 - --p2p-announce - 142.132.164.59:9444 + - --wallet-endpoint + - 0.0.0.0:18662 ports: - name: management containerPort: 18661 - name: p2p containerPort: 9444 + - name: wallet-api + containerPort: 18662 readinessProbe: tcpSocket: port: management @@ -215,6 +226,19 @@ spec: apiVersion: v1 kind: Service metadata: + name: iuna-wallet + namespace: iuna +spec: + selector: + app: iuna-node + ports: + - name: wallet-api + port: 18662 + targetPort: 18662 +--- +apiVersion: v1 +kind: Service +metadata: name: iuna namespace: iuna spec: diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs @@ -23,6 +23,7 @@ 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; +pub const DEFAULT_WALLET_ENDPOINT_BIND_PORT: u16 = 18662; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct UiConfig { @@ -41,6 +42,8 @@ pub struct UiConfig { pub p2p_announce_addr: Option<String>, pub stratum_enabled: bool, pub stratum_bind_port: u16, + pub wallet_endpoint_enabled: bool, + pub wallet_endpoint_bind_port: u16, pub peers: Vec<String>, pub address_book: BTreeMap<String, String>, } @@ -62,6 +65,8 @@ impl Default for UiConfig { p2p_announce_addr: None, stratum_enabled: false, stratum_bind_port: DEFAULT_STRATUM_BIND_PORT, + wallet_endpoint_enabled: false, + wallet_endpoint_bind_port: DEFAULT_WALLET_ENDPOINT_BIND_PORT, peers: Vec::new(), address_book: BTreeMap::new(), } @@ -101,6 +106,10 @@ struct ConfigFile { #[serde(default = "default_stratum_bind_port")] stratum_bind_port: u16, #[serde(default)] + wallet_endpoint_enabled: bool, + #[serde(default = "default_wallet_endpoint_bind_port")] + wallet_endpoint_bind_port: u16, + #[serde(default)] peers: Vec<String>, #[serde(default)] address_book: BTreeMap<String, String>, @@ -139,6 +148,8 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> { p2p_announce_addr: config.p2p_announce_addr.clone(), stratum_enabled: config.stratum_enabled, stratum_bind_port: config.stratum_bind_port, + wallet_endpoint_enabled: config.wallet_endpoint_enabled, + wallet_endpoint_bind_port: config.wallet_endpoint_bind_port, peers: config.peers.clone(), address_book: config.address_book.clone(), }; @@ -199,6 +210,8 @@ fn parse_config_bytes(bytes: &[u8], source: &str) -> Result<UiConfig> { p2p_announce_addr: stored.p2p_announce_addr, stratum_enabled: stored.stratum_enabled, stratum_bind_port: stored.stratum_bind_port, + wallet_endpoint_enabled: stored.wallet_endpoint_enabled, + wallet_endpoint_bind_port: stored.wallet_endpoint_bind_port, peers: stored.peers, address_book: stored.address_book, }) @@ -225,6 +238,10 @@ fn default_stratum_bind_port() -> u16 { DEFAULT_STRATUM_BIND_PORT } +fn default_wallet_endpoint_bind_port() -> u16 { + DEFAULT_WALLET_ENDPOINT_BIND_PORT +} + fn atomic_write_config_file(path: &Path, bytes: &[u8]) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) @@ -344,6 +361,8 @@ mod tests { assert!(stored.contains("\"p2p_announce_addr\": null")); assert!(stored.contains("\"stratum_enabled\": false")); assert!(stored.contains("\"stratum_bind_port\": 3333")); + assert!(stored.contains("\"wallet_endpoint_enabled\": false")); + assert!(stored.contains("\"wallet_endpoint_bind_port\": 18662")); assert!(stored.contains("\"peers\": []")); assert!(stored.contains("\"address_book\": {}")); } @@ -369,6 +388,8 @@ mod tests { p2p_announce_addr: Some("203.0.113.10:9444".to_string()), stratum_enabled: true, stratum_bind_port: 3334, + wallet_endpoint_enabled: true, + wallet_endpoint_bind_port: 18663, peers: vec!["127.0.0.1:9444".to_string()], address_book: [("iuna-address".to_string(), "Alice".to_string())].into(), ..UiConfig::default() @@ -393,6 +414,8 @@ mod tests { ); assert!(config.stratum_enabled); assert_eq!(config.stratum_bind_port, 3334); + assert!(config.wallet_endpoint_enabled); + assert_eq!(config.wallet_endpoint_bind_port, 18663); assert_eq!(config.peers, vec!["127.0.0.1:9444"]); assert_eq!( config.address_book.get("iuna-address"), @@ -466,6 +489,8 @@ mod tests { assert_eq!(config.p2p_bind_port, 9444); assert!(!config.stratum_enabled); assert_eq!(config.stratum_bind_port, 3333); + assert!(!config.wallet_endpoint_enabled); + assert_eq!(config.wallet_endpoint_bind_port, 18662); 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 @@ -40,8 +40,9 @@ use actions::{ 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_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, + api_transfer_fee_estimate_form, api_transfer_form, api_wallet_endpoint_settings_form, + api_wallet_generate_form, api_wallet_import_form, apply_config_form, burn_per_block_form, + peer_form, transfer_form, }; use api::{ api_blocks, api_config, api_mempool, api_metrics, api_network_health, api_p2p_metrics, @@ -124,6 +125,7 @@ pub async fn serve( auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), setup_capability: Arc::new(Mutex::new(Some(setup_capability))), management_port: addr.port(), + wallet_endpoint_addr: options.wallet_endpoint_addr, }; let app = Router::new() .route("/", get(index)) @@ -184,6 +186,10 @@ pub async fn serve( .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/settings/wallet-endpoint", + post(api_wallet_endpoint_settings_form), + ) .route("/api/transfer", post(api_transfer_form)) .route("/api/wallet/optimize/preview", post(consolidation::preview)) .route("/api/wallet/optimize/submit", post(consolidation::submit)) diff --git a/src/adapters/http/actions.rs b/src/adapters/http/actions.rs @@ -13,7 +13,7 @@ use super::types::{ ActionResponse, AddressBookDeleteForm, AddressBookForm, BurnSettingsForm, ChainResetForm, ConfigForm, FeeEstimateResponse, MetricsSettingsForm, P2pAnnounceForm, P2pInboundForm, PeerForm, PowMiningForm, RecoveryVdfSettingsForm, SeedPhraseForm, StratumSettingsForm, - TransferForm, WalletSetupResponse, + TransferForm, WalletEndpointSettingsForm, WalletSetupResponse, }; use super::{ HttpState, action_json, api_error, config_store, estimate_burn_fee, estimate_mine_fee, @@ -143,6 +143,13 @@ pub(super) async fn api_stratum_settings_form( action_json(set_stratum_settings(&state, form.enabled, form.bind_port).await) } +pub(super) async fn api_wallet_endpoint_settings_form( + State(state): State<HttpState>, + Form(form): Form<WalletEndpointSettingsForm>, +) -> Json<ActionResponse> { + action_json(set_wallet_endpoint_settings(&state, form.enabled, form.bind_port).await) +} + pub(super) async fn burn_per_block_form( State(state): State<HttpState>, Form(form): Form<BurnSettingsForm>, @@ -374,6 +381,9 @@ pub(super) async fn set_p2p_accept_inbound( let mut next_config = config.clone(); next_config.p2p_accept_inbound = enabled; next_config.p2p_bind_port = bind_port; + if !enabled { + next_config.wallet_endpoint_enabled = false; + } if let Err(error) = config_store::save(&state.config_path, &next_config) { let _ = state.gossip.set_accept_inbound(previous).await; return Err(error); @@ -407,6 +417,45 @@ pub(super) async fn set_stratum_settings( Ok(()) } +pub(super) async fn set_wallet_endpoint_settings( + state: &HttpState, + enabled: bool, + bind_port: Option<u16>, +) -> Result<()> { + let bind_port = bind_port.unwrap_or(config_store::DEFAULT_WALLET_ENDPOINT_BIND_PORT); + if bind_port == 0 { + bail!("Wallet endpoint bind port must be between 1 and 65535"); + } + if enabled && bind_port == state.management_port { + bail!("Wallet endpoint port must differ from the management UI port"); + } + + let mut config = state.ui_config.lock().await; + let mut next_config = config.clone(); + validate_wallet_endpoint_public_node(&next_config, enabled)?; + if enabled && next_config.p2p_accept_inbound && bind_port == next_config.p2p_bind_port { + bail!("Wallet endpoint port must differ from the P2P listener port"); + } + if enabled && next_config.stratum_enabled && bind_port == next_config.stratum_bind_port { + bail!("Wallet endpoint port must differ from the Stratum listener port"); + } + next_config.wallet_endpoint_enabled = enabled; + next_config.wallet_endpoint_bind_port = bind_port; + config_store::save(&state.config_path, &next_config)?; + *config = next_config; + Ok(()) +} + +fn validate_wallet_endpoint_public_node( + config: &config_store::UiConfig, + enabled: bool, +) -> Result<()> { + if enabled && !config.p2p_accept_inbound { + bail!("Enable Public node before enabling the wallet endpoint"); + } + Ok(()) +} + async fn clear_ui_data(store: &SqliteUiDataStore) -> Result<()> { let store = store.clone(); tokio::task::spawn_blocking(move || store.clear_all()) @@ -557,12 +606,25 @@ mod tests { }; use super::super::{AuthSession, HttpState, state::AuthBackoff}; - use super::{CHAIN_RESET_CONFIRMATION, reset_local_chain}; + use super::{ + CHAIN_RESET_CONFIRMATION, reset_local_chain, validate_wallet_endpoint_public_node, + }; fn socket() -> SocketAddr { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9444) } + #[test] + fn wallet_endpoint_requires_a_public_node() { + let mut config = UiConfig::default(); + let error = validate_wallet_endpoint_public_node(&config, true).unwrap_err(); + assert!(error.to_string().contains("Public node")); + + config.p2p_accept_inbound = true; + validate_wallet_endpoint_public_node(&config, true).unwrap(); + validate_wallet_endpoint_public_node(&UiConfig::default(), false).unwrap(); + } + #[tokio::test] async fn local_chain_reset_clears_chain_and_ui_without_touching_wallet_or_config() { let dir = tempdir().unwrap(); @@ -620,6 +682,7 @@ mod tests { auth_backoff: Arc::new(Mutex::new(BTreeMap::<String, AuthBackoff>::new())), setup_capability: Arc::new(Mutex::new(Some("test-setup-capability".to_string()))), management_port: 9444, + wallet_endpoint_addr: None, }; reset_local_chain(&state, CHAIN_RESET_CONFIRMATION) diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -70,6 +70,10 @@ pub(super) async fn api_config(State(state): State<HttpState>) -> Json<ConfigRes 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(), + wallet_endpoint_runtime_enabled: state.wallet_endpoint_addr.is_some(), + wallet_endpoint_runtime_listen_addr: state + .wallet_endpoint_addr + .map(|addr| addr.to_string()), }) } diff --git a/src/adapters/http/auth_routes.rs b/src/adapters/http/auth_routes.rs @@ -291,6 +291,7 @@ mod tests { auth_backoff: Arc::new(Mutex::new(BTreeMap::<String, AuthBackoff>::new())), setup_capability: Arc::new(Mutex::new(Some(SETUP_CAPABILITY.to_string()))), management_port: MANAGEMENT_PORT, + wallet_endpoint_addr: None, } } diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -1430,6 +1430,24 @@ pub(super) const INDEX_HTML: &str = concat!( <div class="setup-actions"><button class="primary" type="submit">Save</button></div> </form> </div> + <div class="panel" x-show="advancedMode() && p2pAcceptInbound"> + <div class="settings-mode-row"> + <div class="settings-mode-copy"> + <div class="settings-mode-title">Wallet endpoint</div> + <div class="muted" x-text="walletEndpointEnabled ? 'Public read and transaction relay API for lightweight wallets.' : 'Public wallet API is disabled.'"></div> + </div> + <label class="toggle-switch" :class="{ active: walletEndpointEnabled }"> + <input type="checkbox" :checked="walletEndpointEnabled" @change="setWalletEndpointEnabled($event.target.checked)"> + <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> + <span class="toggle-text" x-text="walletEndpointEnabled ? 'On' : 'Off'"></span> + </label> + </div> + <form class="settings-form" x-show="walletEndpointEnabled" x-transition @submit.prevent="saveWalletEndpointSettings"> + <label>Wallet API port<input x-model.number="walletEndpointBindPort" @input="walletEndpointBindPortDirty = true" type="number" min="1" max="65535" step="1" required></label> + <div class="muted">This is a separate listener from the management UI. Only forward this port publicly.</div> + <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"> @@ -1444,10 +1462,8 @@ pub(super) const INDEX_HTML: &str = concat!( </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> @@ -1992,4 +2008,20 @@ mod tests { r#""></script>"# ))); } + + #[test] + fn wallet_endpoint_is_public_only_and_precedes_stratum() { + let wallet = INDEX_HTML.find("Wallet endpoint").expect("wallet endpoint"); + let stratum = INDEX_HTML + .find("Stratum endpoint") + .expect("stratum endpoint"); + + assert!(wallet < stratum); + assert!( + INDEX_HTML + .contains(r#"<div class="panel" x-show="advancedMode() && p2pAcceptInbound">"#) + ); + assert!(!INDEX_HTML.contains(r#"x-text="walletEndpointRestartMessage()""#)); + assert!(!INDEX_HTML.contains(r#"x-text="stratumRestartMessage()""#)); + } } diff --git a/src/adapters/http/request_auth.rs b/src/adapters/http/request_auth.rs @@ -482,6 +482,7 @@ mod tests { auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), setup_capability: Arc::new(Mutex::new(Some("test-setup-capability".to_string()))), management_port: 9444, + wallet_endpoint_addr: None, } } diff --git a/src/adapters/http/state.rs b/src/adapters/http/state.rs @@ -25,6 +25,7 @@ pub(super) struct HttpState { pub(super) auth_backoff: Arc<Mutex<BTreeMap<String, AuthBackoff>>>, pub(super) setup_capability: Arc<Mutex<Option<String>>>, pub(super) management_port: u16, + pub(super) wallet_endpoint_addr: Option<SocketAddr>, } #[derive(Clone)] @@ -48,5 +49,6 @@ pub struct ServeOptions { pub ui_data_store: SqliteUiDataStore, pub wallet_path: PathBuf, pub stratum: StratumStatus, + pub wallet_endpoint_addr: Option<SocketAddr>, pub addr: SocketAddr, } diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -144,6 +144,12 @@ pub(super) struct StratumSettingsForm { pub(super) bind_port: Option<u16>, } +#[derive(Debug, Deserialize)] +pub(super) struct WalletEndpointSettingsForm { + pub(super) enabled: bool, + pub(super) bind_port: Option<u16>, +} + #[derive(Debug, Serialize)] pub(super) struct ConfigResponse { #[serde(flatten)] @@ -152,6 +158,8 @@ pub(super) struct ConfigResponse { pub(super) p2p_runtime_bind_addr: String, pub(super) stratum_runtime_enabled: bool, pub(super) stratum_runtime_listen_addr: Option<String>, + pub(super) wallet_endpoint_runtime_enabled: bool, + pub(super) wallet_endpoint_runtime_listen_addr: Option<String>, } #[derive(Debug, Deserialize)] diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs @@ -5,4 +5,5 @@ pub mod p2p; pub mod stratum; pub mod ui_data_store; pub(crate) mod ui_index; +pub mod wallet_endpoint; pub mod wallet_store; diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs @@ -107,6 +107,9 @@ ON ui_wallet_transactions(address, kind, sort_key DESC); CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_address_sort ON ui_wallet_transactions(address, sort_key DESC); +CREATE INDEX IF NOT EXISTS idx_ui_wallet_transactions_signature +ON ui_wallet_transactions(signature); + CREATE TABLE IF NOT EXISTS ui_burn_leader_ranks ( block_hash TEXT NOT NULL, rank INTEGER NOT NULL, @@ -456,6 +459,27 @@ LIMIT ?1 }) } + pub fn load_wallet_transaction_by_signature( + &self, + signature: &str, + ) -> Result<Option<WalletTransactionProjection>> { + self.with_connection(|connection| { + connection + .query_row( + r#" +SELECT sort_key, kind, block_height, timestamp_ms, block_finalizer, transaction_json +FROM ui_wallet_transactions +WHERE signature = ?1 +LIMIT 1 +"#, + [signature], + wallet_transaction_projection_from_row, + ) + .optional() + .context("failed to load UI wallet transaction by signature") + }) + } + fn with_connection<T>(&self, work: impl FnOnce(&Connection) -> Result<T>) -> Result<T> { let connection = self.open_connection()?; connection diff --git a/src/adapters/wallet_endpoint.rs b/src/adapters/wallet_endpoint.rs @@ -0,0 +1,597 @@ +use std::{net::SocketAddr, sync::Arc}; + +use anyhow::{Context, Result}; +use axum::{ + Json, Router, + body::Body, + extract::{DefaultBodyLimit, Path, Query, State}, + http::{HeaderValue, Method, Request, StatusCode, header}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use serde::{Deserialize, Serialize}; +use tokio::{net::TcpListener, sync::Semaphore}; + +use crate::{ + adapters::{p2p::GossipNetwork, ui_data_store::SqliteUiDataStore}, + app::{NETWORK_ID, PROTOCOL_VERSION, SharedNode}, + domain::{ + Amount, DEFAULT_FEE_PER_BYTE, MICRO_IUNA, OutPoint, TRANSACTION_SIGNING_FORMAT_VERSION, + TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, Transaction, TransactionSubmitOutcome, TxOutput, + }, +}; + +const MAX_TRANSACTION_BODY_BYTES: usize = 64 * 1024; +const MAX_CONCURRENT_TRANSACTION_SUBMISSIONS: usize = 32; + +#[derive(Clone)] +struct WalletEndpointState { + node: SharedNode, + gossip: GossipNetwork, + transaction_slots: Arc<Semaphore>, + ui_data_store: SqliteUiDataStore, +} + +#[derive(Debug, Serialize)] +struct ApiError { + error: String, +} + +#[derive(Debug, Serialize)] +struct WalletEndpointStatus { + api_version: u16, + ready: bool, + network_migration_required: bool, + network_id: String, + protocol_version: u32, + chain_id: String, + genesis_hash: String, + height: u64, + tip_hash: String, + transaction_signing_format_version: u16, + transaction_signing_v1_activation_height: u64, + default_fee_per_byte: Amount, + amount_unit: &'static str, + microiuna_per_iuna: Amount, +} + +#[derive(Debug, Serialize)] +struct BalanceResponse { + address: String, + public_key: String, + confirmed: Amount, + spendable: Amount, + pending_outgoing: Amount, + pending_incoming: Amount, + height: u64, + tip_hash: String, +} + +#[derive(Debug, Serialize)] +struct UtxoResponse { + address: String, + public_key: String, + height: u64, + tip_hash: String, + utxos: Vec<WalletUtxo>, +} + +#[derive(Debug, Serialize)] +struct WalletUtxo { + outpoint: OutPoint, + output: TxOutput, +} + +#[derive(Debug, Serialize)] +struct SubmitResponse { + transaction_id: String, + status: &'static str, +} + +#[derive(Debug, Serialize)] +struct TransactionResponse { + transaction_id: String, + status: &'static str, + transaction: Transaction, +} + +#[derive(Debug, Default, Deserialize)] +struct TransactionsQuery { + offset: Option<usize>, + limit: Option<usize>, +} + +#[derive(Debug, Serialize)] +struct TransactionsResponse { + items: Vec<AddressTransaction>, + offset: usize, + limit: usize, + total: usize, + has_more: bool, + next_offset: Option<usize>, +} + +#[derive(Debug, Serialize)] +struct AddressTransaction { + status: &'static str, + block_height: Option<u64>, + timestamp_ms: Option<u64>, + transaction: Transaction, +} + +pub async fn serve( + node: SharedNode, + gossip: GossipNetwork, + ui_data_store: SqliteUiDataStore, + addr: SocketAddr, +) -> Result<()> { + let listener = TcpListener::bind(addr) + .await + .with_context(|| format!("binding public wallet endpoint on {addr}"))?; + println!("wallet endpoint: http://{}", listener.local_addr()?); + axum::serve(listener, router(node, gossip, ui_data_store)) + .await + .context("serving public wallet endpoint") +} + +fn router(node: SharedNode, gossip: GossipNetwork, ui_data_store: SqliteUiDataStore) -> Router { + Router::new() + .route("/v1/status", get(status)) + .route("/v1/addresses/{address}/balance", get(balance)) + .route("/v1/addresses/{address}/utxos", get(utxos)) + .route( + "/v1/addresses/{address}/transactions", + get(address_transactions), + ) + .route("/v1/transactions/{transaction_id}", get(transaction)) + .route("/v1/transactions", post(submit_transaction)) + .layer(DefaultBodyLimit::max(MAX_TRANSACTION_BODY_BYTES)) + .layer(middleware::from_fn(cors)) + .with_state(WalletEndpointState { + node, + gossip, + transaction_slots: Arc::new(Semaphore::new(MAX_CONCURRENT_TRANSACTION_SUBMISSIONS)), + ui_data_store, + }) +} + +async fn status(State(state): State<WalletEndpointState>) -> Json<WalletEndpointStatus> { + let node = state.node.lock().await; + let ledger = node.ledger(); + Json(WalletEndpointStatus { + api_version: 1, + ready: node.has_real_chain() && node.network_migration_from().is_none(), + network_migration_required: node.network_migration_from().is_some(), + network_id: NETWORK_ID.to_string(), + protocol_version: PROTOCOL_VERSION, + chain_id: ledger.launch_profile().profile_id.clone(), + genesis_hash: ledger.genesis_hash().to_string(), + height: ledger.height(), + tip_hash: ledger.tip_hash().to_string(), + transaction_signing_format_version: TRANSACTION_SIGNING_FORMAT_VERSION, + transaction_signing_v1_activation_height: TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, + default_fee_per_byte: DEFAULT_FEE_PER_BYTE, + amount_unit: "microiuna", + microiuna_per_iuna: MICRO_IUNA, + }) +} + +async fn balance( + State(state): State<WalletEndpointState>, + Path(address): Path<String>, +) -> Result<Json<BalanceResponse>, (StatusCode, Json<ApiError>)> { + let node = state.node.lock().await; + let public_key = node.normalize_user_address(&address).map_err(bad_request)?; + let ledger = node.ledger(); + let confirmed = ledger.balance_of(&public_key); + let spendable = ledger + .available_utxos_for_address(&public_key) + .map_err(internal_error)? + .iter() + .map(|(_, output)| output.amount) + .sum(); + Ok(Json(BalanceResponse { + address: address.trim().to_ascii_lowercase(), + public_key, + confirmed, + spendable, + pending_outgoing: confirmed.saturating_sub(spendable), + pending_incoming: spendable.saturating_sub(confirmed), + height: ledger.height(), + tip_hash: ledger.tip_hash().to_string(), + })) +} + +async fn utxos( + State(state): State<WalletEndpointState>, + Path(address): Path<String>, +) -> Result<Json<UtxoResponse>, (StatusCode, Json<ApiError>)> { + let node = state.node.lock().await; + let public_key = node.normalize_user_address(&address).map_err(bad_request)?; + let ledger = node.ledger(); + let utxos = ledger + .available_utxos_for_address(&public_key) + .map_err(internal_error)? + .into_iter() + .map(|(outpoint, output)| WalletUtxo { outpoint, output }) + .collect(); + Ok(Json(UtxoResponse { + address: address.trim().to_ascii_lowercase(), + public_key, + height: ledger.height(), + tip_hash: ledger.tip_hash().to_string(), + utxos, + })) +} + +async fn transaction( + State(state): State<WalletEndpointState>, + Path(transaction_id): Path<String>, +) -> Result<Json<TransactionResponse>, (StatusCode, Json<ApiError>)> { + let pending = { + let node = state.node.lock().await; + let ledger = node.ledger(); + ledger + .pending() + .iter() + .find(|item| item.signature() == transaction_id) + .cloned() + .map(|transaction| ("pending", transaction)) + .or_else(|| { + ledger + .orphan_transactions() + .iter() + .find(|item| item.signature() == transaction_id) + .cloned() + .map(|transaction| ("orphan", transaction)) + }) + }; + let (status, transaction) = match pending { + Some(transaction) => transaction, + None => { + let store = state.ui_data_store.clone(); + let lookup_id = transaction_id.clone(); + let transaction = tokio::task::spawn_blocking(move || { + store.load_wallet_transaction_by_signature(&lookup_id) + }) + .await + .map_err(internal_error)? + .map_err(internal_error)? + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "transaction not found"))?; + ("confirmed", transaction.transaction) + } + }; + Ok(Json(TransactionResponse { + transaction_id, + status, + transaction, + })) +} + +async fn address_transactions( + State(state): State<WalletEndpointState>, + Path(address): Path<String>, + Query(query): Query<TransactionsQuery>, +) -> Result<Json<TransactionsResponse>, (StatusCode, Json<ApiError>)> { + let offset = query.offset.unwrap_or(0); + let limit = query.limit.unwrap_or(25).clamp(1, 100); + let (public_key, pending) = { + let node = state.node.lock().await; + let public_key = node.normalize_user_address(&address).map_err(bad_request)?; + let pending = node + .pending_transactions() + .into_iter() + .rev() + .filter(|transaction| transaction_mentions_address(transaction, &public_key)) + .collect::<Vec<_>>(); + (public_key, pending) + }; + let pending_total = pending.len(); + let mut items = pending + .into_iter() + .skip(offset.min(pending_total)) + .take(limit) + .map(|transaction| AddressTransaction { + status: "pending", + block_height: None, + timestamp_ms: None, + transaction, + }) + .collect::<Vec<_>>(); + + let confirmed_offset = offset.saturating_sub(pending_total); + let remaining = limit.saturating_sub(items.len()); + let store = state.ui_data_store.clone(); + let confirmed = tokio::task::spawn_blocking(move || { + store.load_wallet_transactions( + &public_key, + &["transfer", "mine", "burn"], + confirmed_offset, + remaining, + ) + }) + .await + .map_err(internal_error)? + .map_err(internal_error)?; + let confirmed_total = confirmed.1; + items.extend(confirmed.0.into_iter().map(|row| AddressTransaction { + status: "confirmed", + block_height: Some(row.block_height), + timestamp_ms: Some(row.timestamp_ms), + transaction: row.transaction, + })); + let total = pending_total + confirmed_total; + let next_offset = offset.saturating_add(items.len()); + Ok(Json(TransactionsResponse { + items, + offset: offset.min(total), + limit, + total, + has_more: next_offset < total, + next_offset: (next_offset < total).then_some(next_offset), + })) +} + +fn transaction_mentions_address(transaction: &Transaction, address: &str) -> bool { + match transaction { + Transaction::Transfer { + inputs, outputs, .. + } => { + inputs.iter().any(|input| input.owner == address) + || outputs.iter().any(|output| output.address == address) + } + Transaction::Burn { inputs, change, .. } => { + inputs.iter().any(|input| input.owner == address) + || change.iter().any(|output| output.address == address) + } + Transaction::Mine { recipient, .. } => recipient == address, + } +} + +async fn submit_transaction( + State(state): State<WalletEndpointState>, + Json(transaction): Json<Transaction>, +) -> Result<(StatusCode, Json<SubmitResponse>), (StatusCode, Json<ApiError>)> { + let _permit = state + .transaction_slots + .clone() + .try_acquire_owned() + .map_err(|_| { + api_error( + StatusCode::TOO_MANY_REQUESTS, + "too many transaction submissions", + ) + })?; + let transaction_id = transaction.signature().to_string(); + let (outcome, outbox) = { + let mut node = state.node.lock().await; + if !node.has_real_chain() { + return Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "node has not joined a chain yet", + )); + } + if node.network_migration_from().is_some() { + return Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "node requires a network migration reset", + )); + } + let outcome = node + .submit_external_wallet_transaction(transaction) + .map_err(bad_request)?; + (outcome, node.drain_outbox()) + }; + if outcome.added() { + state + .gossip + .broadcast(outbox) + .await + .map_err(internal_error)?; + } + let (code, status) = match outcome { + TransactionSubmitOutcome::Added => (StatusCode::ACCEPTED, "accepted"), + TransactionSubmitOutcome::AlreadyKnown => (StatusCode::OK, "already_known"), + TransactionSubmitOutcome::ConflictsWithPending => (StatusCode::CONFLICT, "conflict"), + }; + Ok(( + code, + Json(SubmitResponse { + transaction_id, + status, + }), + )) +} + +fn bad_request(error: impl std::fmt::Display) -> (StatusCode, Json<ApiError>) { + api_error(StatusCode::BAD_REQUEST, error) +} + +fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json<ApiError>) { + api_error(StatusCode::INTERNAL_SERVER_ERROR, error) +} + +fn api_error(status: StatusCode, error: impl std::fmt::Display) -> (StatusCode, Json<ApiError>) { + ( + status, + Json(ApiError { + error: error.to_string(), + }), + ) +} + +async fn cors(request: Request<Body>, next: Next) -> Response { + if request.method() == Method::OPTIONS { + return cors_headers(StatusCode::NO_CONTENT.into_response()); + } + cors_headers(next.run(request).await) +} + +fn cors_headers(mut response: Response) -> Response { + let headers = response.headers_mut(); + headers.insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, POST, OPTIONS"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("content-type"), + ); + response +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + net::{IpAddr, Ipv4Addr}, + sync::Arc, + }; + + use axum::{body::to_bytes, http::Request}; + use tokio::sync::Mutex; + use tower::ServiceExt; + + use crate::{ + adapters::p2p::GossipNetwork, + app::{NodeCore, PeerBook}, + domain::{AddressNetwork, Ledger, MICRO_IUNA, Wallet, encode_address}, + }; + + use super::*; + + async fn test_app() -> (Router, Wallet, Ledger, tempfile::TempDir) { + let wallet = Wallet::from_seed("public-wallet-endpoint-test"); + let ledger = Ledger::new( + BTreeMap::from([(wallet.address().to_string(), 5 * MICRO_IUNA)]), + 1, + ); + let node = Arc::new(Mutex::new(NodeCore::from_ledger( + wallet.clone(), + ledger.clone(), + 0, + ))); + let peers = Arc::new(Mutex::new(PeerBook::default())); + let gossip = GossipNetwork::start( + node.clone(), + peers, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), + None, + false, + ) + .await + .unwrap(); + let dir = tempfile::tempdir().unwrap(); + let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui.sqlite3")).unwrap(); + ui_data_store + .project_snapshot(&ledger.snapshot(), false) + .unwrap(); + (router(node, gossip, ui_data_store), wallet, ledger, dir) + } + + #[tokio::test] + async fn public_router_exposes_wallet_data_but_not_management_api() { + let (app, wallet, _, _dir) = test_app().await; + let receive_address = encode_address(wallet.address(), AddressNetwork::Mainnet).unwrap(); + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v1/addresses/{receive_address}/balance")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["confirmed"], 5 * MICRO_IUNA); + assert_eq!(value["spendable"], 5 * MICRO_IUNA); + assert_eq!(value["address"], receive_address); + assert_eq!(value["public_key"], wallet.address()); + + let wrong_network = encode_address(wallet.address(), AddressNetwork::Testnet).unwrap(); + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v1/addresses/{wrong_network}/balance")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = app + .oneshot( + Request::builder() + .uri("/api/config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn signed_transfer_is_accepted_and_available_by_signature() { + let (app, wallet, ledger, _dir) = test_app().await; + let receive_address = encode_address(wallet.address(), AddressNetwork::Mainnet).unwrap(); + let recipient = Wallet::from_seed("public-wallet-endpoint-recipient"); + let transaction = ledger + .build_transfer(&wallet, recipient.address(), MICRO_IUNA, 1) + .unwrap(); + let transaction_id = transaction.signature().to_string(); + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/transactions") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&transaction).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v1/transactions/{transaction_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["status"], "pending"); + + let response = app + .oneshot( + Request::builder() + .uri(format!("/v1/addresses/{receive_address}/transactions")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["total"], 1); + assert_eq!(value["items"][0]["status"], "pending"); + } +} diff --git a/src/app/wallet.rs b/src/app/wallet.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result, bail}; use crate::domain::{ Amount, Block, BurnBundle, DEFAULT_TRANSACTION_FEE, Ledger, OutPoint, PreparedBlock, - StratumMineShare, StratumMineTemplate, Transaction, Wallet, run_vdf, + StratumMineShare, StratumMineTemplate, Transaction, TransactionSubmitOutcome, Wallet, run_vdf, }; use super::{ @@ -38,6 +38,22 @@ impl NodeWallet { } impl NodeCore { + /// Accept a transaction signed by an external/lightweight wallet. + /// Mining actions are deliberately excluded from the public wallet API. + pub fn submit_external_wallet_transaction( + &mut self, + tx: Transaction, + ) -> Result<TransactionSubmitOutcome> { + if matches!(tx, Transaction::Mine { .. }) { + bail!("the wallet endpoint accepts only transfer and burn transactions"); + } + let outcome = self.ledger.submit_transaction_with_outcome(tx.clone())?; + if outcome.added() { + self.outbox.push(GossipEnvelope::Transaction(tx)); + } + Ok(outcome) + } + pub fn burn(&mut self, amount: Amount) -> Result<Transaction> { self.burn_with_fee(amount, DEFAULT_TRANSACTION_FEE) } diff --git a/src/cli.rs b/src/cli.rs @@ -47,6 +47,17 @@ pub(crate) fn configured_stratum_addr( }) } +pub(crate) fn configured_wallet_endpoint_addr( + opts: &CliOptions, + ui_config: &config_store::UiConfig, +) -> Option<SocketAddr> { + opts.wallet_endpoint_addr.or_else(|| { + ui_config + .wallet_endpoint_enabled + .then(|| SocketAddr::from((Ipv4Addr::UNSPECIFIED, ui_config.wallet_endpoint_bind_port))) + }) +} + pub(crate) fn apply_cli_p2p_config_overrides( opts: &CliOptions, ui_config: &mut config_store::UiConfig, @@ -91,6 +102,20 @@ pub(crate) fn apply_cli_stratum_config_overrides( dirty } +pub(crate) fn apply_cli_wallet_endpoint_config_overrides( + opts: &CliOptions, + ui_config: &mut config_store::UiConfig, +) -> bool { + let Some(addr) = opts.wallet_endpoint_addr else { + return false; + }; + let dirty = + !ui_config.wallet_endpoint_enabled || ui_config.wallet_endpoint_bind_port != addr.port(); + ui_config.wallet_endpoint_enabled = true; + ui_config.wallet_endpoint_bind_port = addr.port(); + dirty +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ChainMode { Setup, @@ -107,6 +132,7 @@ pub(crate) struct CliOptions { pub(crate) p2p_addr_configured: bool, pub(crate) p2p_announce_addr: Option<SocketAddr>, pub(crate) stratum_addr: Option<SocketAddr>, + pub(crate) wallet_endpoint_addr: Option<SocketAddr>, pub(crate) peers: Vec<String>, pub(crate) join_peers: Vec<String>, pub(crate) chain_mode: ChainMode, @@ -128,6 +154,7 @@ impl CliOptions { p2p_addr_configured: false, p2p_announce_addr: None, stratum_addr: None, + wallet_endpoint_addr: None, peers: Vec::new(), join_peers: Vec::new(), chain_mode: ChainMode::Setup, @@ -181,6 +208,13 @@ impl CliOptions { .context("invalid --stratum address")?, ); } + "--wallet-endpoint" => { + opts.wallet_endpoint_addr = Some( + next_value(&mut args, "--wallet-endpoint")? + .parse() + .context("invalid --wallet-endpoint address")?, + ); + } "--join" => { if opts.chain_mode == ChainMode::Genesis { bail!("choose either --genesis or --join, not both"); @@ -203,6 +237,12 @@ impl CliOptions { if opts.chain_mode == ChainMode::Genesis && !opts.join_peers.is_empty() { bail!("choose either --genesis or --join, not both"); } + if opts + .wallet_endpoint_addr + .is_some_and(|address| address.port() == 0) + { + bail!("--wallet-endpoint port must be between 1 and 65535"); + } Ok(Some(opts)) } @@ -282,6 +322,7 @@ pub(crate) fn help_text() -> &'static str { --p2p <addr:port> Inbound P2P listener address when public node is enabled\n\ --p2p-announce <addr:port> Public P2P address to gossip; enables inbound P2P\n\ --stratum <addr:port> Stratum V1 listener for SHA-256 ASIC miners\n\ + --wallet-endpoint <addr:port> Public lightweight-wallet API on a separate listener\n\ --join <addr:port> Fetch chain snapshot from this peer before finalization\n\ --data-dir <path> Local wallet directory (default ~/.iuna)\n\ --debug Print verbose runtime logs\n\n\ @@ -291,6 +332,8 @@ pub(crate) fn help_text() -> &'static str { IUNA_AUTOMATIC_BURN_ENABLED=true|false Persist automatic burn/finalization at startup\n\ IUNA_POW_MINING_ENABLED=true|false Persist automatic PoW mining at startup\n\ IUNA_POW_MINING_WORKERS=1..32 Set and persist the PoW worker count at startup\n\ + IUNA_WALLET_ENDPOINT_ENABLED=true|false Enable the separate public wallet API\n\ + IUNA_WALLET_ENDPOINT_PORT=1..65535 Set its public listener port (default 18662)\n\ IUNA_DEV_SKIP_SEED_VERIFY=1 Show a setup button to skip seed verification\n" } diff --git a/src/main.rs b/src/main.rs @@ -13,7 +13,7 @@ use anyhow::{Context, Result, bail}; use iuna::{ adapters::{ chain_store::SqliteChainStore, config_store, http, p2p, stratum, - ui_data_store::SqliteUiDataStore, wallet_store, + ui_data_store::SqliteUiDataStore, wallet_endpoint, wallet_store, }, app::{ NodeCore, PeerBook, SharedNode, SharedPeerBook, StratumStatus, debug_logging_enabled, @@ -30,7 +30,8 @@ use tokio::sync::Mutex; mod cli; use cli::{ ChainMode, CliOptions, apply_cli_p2p_config_overrides, apply_cli_stratum_config_overrides, - configured_p2p_announce_addr, configured_p2p_bind_addr, configured_stratum_addr, + apply_cli_wallet_endpoint_config_overrides, configured_p2p_announce_addr, + configured_p2p_bind_addr, configured_stratum_addr, configured_wallet_endpoint_addr, initial_burn_fee, initial_burn_per_block, validate_wallet_for_mode, }; #[cfg(test)] @@ -51,6 +52,8 @@ const POW_MINING_WORKERS_ENV: &str = "IUNA_POW_MINING_WORKERS"; const LOCAL_TESTNET_ENV: &str = "IUNA_LOCAL_TESTNET"; const SETUP_COMPLETE_ENV: &str = "IUNA_SETUP_COMPLETE"; const WALLET_PASSWORD_ENV: &str = "IUNA_WALLET_PASSWORD"; +const WALLET_ENDPOINT_ENABLED_ENV: &str = "IUNA_WALLET_ENDPOINT_ENABLED"; +const WALLET_ENDPOINT_PORT_ENV: &str = "IUNA_WALLET_ENDPOINT_PORT"; #[tokio::main] async fn main() -> Result<()> { @@ -85,11 +88,38 @@ async fn main() -> Result<()> { let startup_pow_mining_enabled = startup_bool_from_env(POW_MINING_ENABLED_ENV)?; let startup_pow_mining_workers = startup_pow_mining_workers_from_env()?; let startup_setup_complete = startup_bool_from_env(SETUP_COMPLETE_ENV)?; + let startup_wallet_endpoint_enabled = startup_bool_from_env(WALLET_ENDPOINT_ENABLED_ENV)?; + let startup_wallet_endpoint_port = startup_port_from_env(WALLET_ENDPOINT_PORT_ENV)?; + let wallet_endpoint_env_dirty = apply_startup_wallet_endpoint_config_overrides( + &mut ui_config, + startup_wallet_endpoint_enabled, + startup_wallet_endpoint_port, + ); 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 wallet_endpoint_cli_dirty = + apply_cli_wallet_endpoint_config_overrides(&opts, &mut ui_config); 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 configured_wallet_endpoint_addr = configured_wallet_endpoint_addr(&opts, &ui_config); + if let Some(wallet_addr) = configured_wallet_endpoint_addr { + if !ui_config.p2p_accept_inbound { + bail!("wallet endpoint requires the node to be configured as Public"); + } + if wallet_addr.port() == 0 { + bail!("wallet endpoint port must be between 1 and 65535"); + } + if wallet_addr.port() == opts.http_addr.port() { + bail!("wallet endpoint port must differ from the management UI port"); + } + if ui_config.p2p_accept_inbound && wallet_addr.port() == configured_p2p_addr.port() { + bail!("wallet endpoint port must differ from the P2P listener port"); + } + if configured_stratum_addr.is_some_and(|addr| addr.port() == wallet_addr.port()) { + bail!("wallet endpoint port must differ from the Stratum listener port"); + } + } let p2p_accept_inbound = ui_config.p2p_accept_inbound; let advertised_p2p_addr = p2p_announce_addr.unwrap_or(configured_p2p_addr); if opts.chain_mode == ChainMode::Genesis { @@ -121,6 +151,8 @@ async fn main() -> Result<()> { let ui_config_dirty = opts.chain_mode == ChainMode::Genesis || p2p_config_dirty || stratum_config_dirty + || wallet_endpoint_env_dirty + || wallet_endpoint_cli_dirty || mining_config_dirty || setup_config_dirty; if ui_config_dirty || auth_config_dirty { @@ -195,6 +227,11 @@ async fn main() -> Result<()> { } else { println!("p2p listener: disabled (outbound-only)"); } + if let Some(addr) = configured_wallet_endpoint_addr { + println!("public wallet endpoint configured on separate listener: {addr}"); + } else { + println!("public wallet endpoint: disabled"); + } if p2p_accept_inbound { if let Some(addr) = p2p_announce_addr { println!("p2p announce address: {addr}"); @@ -277,10 +314,11 @@ async fn main() -> Result<()> { println!("setup mode: waiting to join or create a chain"); } - http::serve( - node, + let wallet_endpoint_ui_data_store = ui_data_store.clone(); + let management = http::serve( + Arc::clone(&node), peers, - gossip, + gossip.clone(), ui_config, http::ServeOptions { config_path, @@ -288,10 +326,19 @@ async fn main() -> Result<()> { ui_data_store, wallet_path, stratum: stratum_status, + wallet_endpoint_addr: configured_wallet_endpoint_addr, addr: opts.http_addr, }, - ) - .await + ); + if let Some(addr) = configured_wallet_endpoint_addr { + tokio::try_join!( + management, + wallet_endpoint::serve(node, gossip, wallet_endpoint_ui_data_store, addr) + )?; + Ok(()) + } else { + management.await + } } enum StartupWallet { @@ -381,6 +428,40 @@ fn startup_pow_mining_workers_from_env() -> Result<Option<u8>> { parse_startup_pow_mining_workers_env_value(value.trim()).map(Some) } +fn startup_port_from_env(name: &str) -> Result<Option<u16>> { + let Some(value) = std::env::var_os(name) else { + return Ok(None); + }; + let value = value + .into_string() + .map_err(|_| anyhow::anyhow!("{name} must be valid UTF-8"))?; + let port = value + .trim() + .parse::<u16>() + .with_context(|| format!("{name} must be an integer between 1 and 65535"))?; + if port == 0 { + bail!("{name} must be between 1 and 65535"); + } + Ok(Some(port)) +} + +fn apply_startup_wallet_endpoint_config_overrides( + ui_config: &mut config_store::UiConfig, + enabled: Option<bool>, + port: Option<u16>, +) -> bool { + let mut dirty = false; + if let Some(enabled) = enabled { + dirty |= ui_config.wallet_endpoint_enabled != enabled; + ui_config.wallet_endpoint_enabled = enabled; + } + if let Some(port) = port { + dirty |= ui_config.wallet_endpoint_bind_port != port; + ui_config.wallet_endpoint_bind_port = port; + } + dirty +} + fn parse_startup_pow_mining_workers_env_value(value: &str) -> Result<u8> { let workers = value .parse::<u8>() diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -19,13 +19,14 @@ use super::{ GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK, StartupWallet, apply_cli_p2p_config_overrides, apply_cli_stratum_config_overrides, apply_startup_mining_config_overrides, apply_startup_setup_config_override, - apply_startup_wallet_password_config, automatic_finalization_precheck_time, - 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, parse_startup_bool_env_value, - parse_startup_pow_mining_workers_env_value, persist_chain_snapshot, project_ui_data_store, - run_chain_persistence_with_interval, setup_ledger, should_defer_sync_checkpoint, - should_log_automatic_finalization_skip, validate_wallet_for_mode, + apply_startup_wallet_endpoint_config_overrides, apply_startup_wallet_password_config, + automatic_finalization_precheck_time, 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, + parse_startup_bool_env_value, parse_startup_pow_mining_workers_env_value, + persist_chain_snapshot, project_ui_data_store, run_chain_persistence_with_interval, + setup_ledger, should_defer_sync_checkpoint, should_log_automatic_finalization_skip, + validate_wallet_for_mode, }; fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> { @@ -504,6 +505,42 @@ fn stratum_port_can_be_configured() { } #[test] +fn wallet_endpoint_uses_a_separate_configurable_listener() { + let opts = parse(&["--wallet-endpoint", "0.0.0.0:28662"]) + .unwrap() + .unwrap(); + assert_eq!( + opts.wallet_endpoint_addr, + Some("0.0.0.0:28662".parse().unwrap()) + ); + assert!(help_text().contains("--wallet-endpoint <addr:port>")); + assert!(help_text().contains("IUNA_WALLET_ENDPOINT_PORT")); +} + +#[test] +fn wallet_endpoint_rejects_zero_port() { + let error = parse(&["--wallet-endpoint", "0.0.0.0:0"]).unwrap_err(); + assert!(error.to_string().contains("between 1 and 65535")); +} + +#[test] +fn wallet_endpoint_environment_settings_are_persistable() { + let mut config = UiConfig::default(); + assert!(apply_startup_wallet_endpoint_config_overrides( + &mut config, + Some(true), + Some(28662), + )); + assert!(config.wallet_endpoint_enabled); + assert_eq!(config.wallet_endpoint_bind_port, 28662); + assert!(!apply_startup_wallet_endpoint_config_overrides( + &mut config, + Some(true), + Some(28662), + )); +} + +#[test] fn configured_stratum_addr_uses_settings_when_enabled() { let opts = parse(&[]).unwrap().unwrap(); let config = UiConfig { diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -75,6 +75,9 @@ window.iunaApp = function iunaApp() { stratumEnabled: false, stratumBindPort: 3333, stratumBindPortDirty: false, + walletEndpointEnabled: false, + walletEndpointBindPort: 18662, + walletEndpointBindPortDirty: false, setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false, requires_peer: false }, setupNodeMode: "wallet", setupWalletMode: "create", @@ -506,6 +509,10 @@ window.iunaApp = function iunaApp() { if (!this.stratumBindPortDirty) { this.stratumBindPort = Number(this.config.stratum_bind_port || 3333); } + this.walletEndpointEnabled = this.config.wallet_endpoint_enabled === true; + if (!this.walletEndpointBindPortDirty) { + this.walletEndpointBindPort = Number(this.config.wallet_endpoint_bind_port || 18662); + } if ( options.addressBookVersion === undefined || options.addressBookVersion >= this.addressBookVersion @@ -1690,6 +1697,7 @@ window.iunaApp = function iunaApp() { async setP2pAcceptInbound(enabled) { const previous = this.p2pAcceptInbound; + const walletWasEnabled = this.walletEndpointEnabled; try { this.p2pAcceptInbound = enabled; await this.postForm( @@ -1699,6 +1707,9 @@ window.iunaApp = function iunaApp() { ); this.p2pBindPortDirty = false; await this.refreshConfig(); + if (!enabled && walletWasEnabled && this.walletEndpointRestartRequired()) { + this.showFlash(this.walletEndpointRestartMessage(), "success"); + } } catch (error) { this.p2pAcceptInbound = previous; this.showFlash(error.message, "error"); @@ -1747,6 +1758,8 @@ window.iunaApp = function iunaApp() { ); this.stratumBindPortDirty = false; await this.refreshConfig(); + const restartMessage = this.stratumRestartMessage(); + if (restartMessage) this.showFlash(restartMessage, "success"); } catch (error) { this.stratumEnabled = previous; this.showFlash(error.message, "error"); @@ -1793,6 +1806,84 @@ window.iunaApp = function iunaApp() { ); this.stratumBindPortDirty = false; await this.refreshConfig(); + const restartMessage = this.stratumRestartMessage(); + if (restartMessage) this.showFlash(restartMessage, "success"); + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + + async setWalletEndpointEnabled(enabled) { + if (enabled && !this.p2pAcceptInbound) { + this.showFlash("Enable Public node before enabling the wallet endpoint", "error"); + return; + } + const previous = this.walletEndpointEnabled; + try { + this.walletEndpointEnabled = enabled; + await this.postForm( + "/api/settings/wallet-endpoint", + { enabled, bind_port: this.walletEndpointBindPortValue() }, + enabled ? "Wallet endpoint setting saved" : "Wallet endpoint disabled" + ); + this.walletEndpointBindPortDirty = false; + await this.refreshConfig(); + const restartMessage = this.walletEndpointRestartMessage(); + if (restartMessage) this.showFlash(restartMessage, "success"); + } catch (error) { + this.walletEndpointEnabled = previous; + this.showFlash(error.message, "error"); + } + }, + + walletEndpointBindPortValue() { + const port = Number(this.walletEndpointBindPort); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("Wallet endpoint bind port must be between 1 and 65535"); + } + return port; + }, + + walletEndpointConfiguredBindAddr() { + const port = Number(this.config.wallet_endpoint_bind_port || 18662); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + return `0.0.0.0:${port}`; + }, + + walletEndpointRestartRequired() { + const runtimeActive = this.config.wallet_endpoint_runtime_enabled === true; + if (this.walletEndpointEnabled !== runtimeActive) return true; + if (!this.walletEndpointEnabled) return false; + const configured = this.walletEndpointConfiguredBindAddr(); + return configured + ? this.config.wallet_endpoint_runtime_listen_addr !== configured + : false; + }, + + walletEndpointRestartMessage() { + if (!this.walletEndpointRestartRequired()) return ""; + if (!this.walletEndpointEnabled && this.config.wallet_endpoint_runtime_enabled === true) { + return "Restart iuna to close the public wallet listener."; + } + const configured = this.walletEndpointConfiguredBindAddr(); + return `Restart iuna to open the wallet API on ${configured || "the configured bind port"}.`; + }, + + async saveWalletEndpointSettings() { + if (!this.p2pAcceptInbound) { + this.showFlash("Enable Public node before configuring the wallet endpoint", "error"); + return; + } + try { + await this.postForm( + "/api/settings/wallet-endpoint", + { enabled: this.walletEndpointEnabled, bind_port: this.walletEndpointBindPortValue() }, + "Wallet endpoint setting saved" + ); + this.walletEndpointBindPortDirty = false; + await this.refreshConfig(); + const restartMessage = this.walletEndpointRestartMessage(); + if (restartMessage) this.showFlash(restartMessage, "success"); } catch (error) { this.showFlash(error.message, "error"); }