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:
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");
}