Skip to main content

bsv_wallet_cli/server/
mod.rs

1pub mod handlers;
2pub mod types;
3
4use anyhow::Result;
5use axum::extract::{DefaultBodyLimit, Request};
6use axum::http::{HeaderMap, StatusCode};
7use axum::middleware::{self, Next};
8use axum::response::{IntoResponse, Response};
9use axum::routing::{get, post};
10use axum::{Json, Router};
11use bsv_wallet_toolbox::{Chain, Services, StorageSqlx, Wallet};
12use serde_json::json;
13use std::net::SocketAddr;
14use std::sync::Arc;
15use tower_http::cors::CorsLayer;
16use tower_http::trace::TraceLayer;
17
18use handlers::WalletState;
19
20/// Lock that serializes spending operations (createAction).
21/// Non-spending endpoints (encrypt, getPublicKey, listOutputs, etc.) are unaffected.
22///
23/// Why: With limited UTXOs, concurrent createAction calls race on SQLite's write lock
24/// and the loser gets SQLITE_BUSY_SNAPSHOT instead of waiting for the winner's change
25/// output. This lock queues them so each spending request sees the previous one's change.
26pub type SpendingLock = Arc<tokio::sync::Mutex<()>>;
27
28/// TLS configuration (cert + key paths).
29#[derive(Clone)]
30#[allow(dead_code)]
31pub struct TlsConfig {
32    pub cert_path: String,
33    pub key_path: String,
34}
35
36/// Server configuration (auth, TLS, etc.)
37#[derive(Clone, Default)]
38pub struct ServerConfig {
39    /// Optional bearer token. When set, all requests must include
40    /// `Authorization: Bearer <token>`. When None, auth is disabled.
41    pub auth_token: Option<String>,
42    /// Optional TLS config. Requires `--features tls` at build time.
43    pub tls: Option<TlsConfig>,
44    /// Network the served wallet is on. Drives post-broadcast verification
45    /// (which ARC / WoC endpoints to probe). Defaults to `Main`.
46    pub chain: Chain,
47}
48
49/// Auth middleware — checks Bearer token if configured.
50async fn auth_middleware(headers: HeaderMap, request: Request, next: Next) -> Response {
51    // Extract config from request extensions
52    let token = request
53        .extensions()
54        .get::<ServerConfig>()
55        .and_then(|c| c.auth_token.clone());
56
57    if let Some(expected) = token {
58        let provided = headers
59            .get("authorization")
60            .and_then(|v| v.to_str().ok())
61            .and_then(|v| v.strip_prefix("Bearer "));
62
63        match provided {
64            Some(t) if t == expected => {}
65            _ => {
66                return (
67                    StatusCode::UNAUTHORIZED,
68                    Json(json!({"code": "UNAUTHORIZED", "message": "Invalid or missing bearer token"})),
69                )
70                    .into_response();
71            }
72        }
73    }
74
75    next.run(request).await
76}
77
78/// Build the axum Router with all 28 WalletInterface endpoints.
79pub fn make_router(wallet: WalletState, config: ServerConfig) -> Router {
80    let cors = CorsLayer::very_permissive();
81    let cfg = config.clone();
82    let spending_lock: SpendingLock = Arc::new(tokio::sync::Mutex::new(()));
83    // Post-broadcast verifier — lets `/createAction` fail loudly when ARC
84    // silently dropped an immediate broadcast (e.g. 465 fee-too-low on a deep
85    // unconfirmed BEEF) instead of returning a phantom txid.
86    let verifier = crate::broadcast_verify::BroadcastVerifier::from_env(config.chain);
87
88    Router::new()
89        // Existing 5 endpoints
90        // Status endpoints accept BOTH GET and POST: @bsv/sdk's HTTPWalletJSON substrate
91        // POSTs every method (incl. these), while curl/CLI clients GET them. Supporting
92        // both makes the server wire-compatible with browser WalletClient connections.
93        .route(
94            "/isAuthenticated",
95            get(handlers::is_authenticated).post(handlers::is_authenticated),
96        )
97        .route("/getPublicKey", post(handlers::get_public_key))
98        .route("/createSignature", post(handlers::create_signature))
99        .route("/createAction", post(handlers::create_action))
100        .route("/internalizeAction", post(handlers::internalize_action))
101        // Batch 1: Status (GET + POST for HTTPWalletJSON compat)
102        .route(
103            "/getHeight",
104            get(handlers::get_height).post(handlers::get_height),
105        )
106        .route(
107            "/getNetwork",
108            get(handlers::get_network).post(handlers::get_network),
109        )
110        .route(
111            "/getVersion",
112            get(handlers::get_version).post(handlers::get_version),
113        )
114        .route(
115            "/waitForAuthentication",
116            get(handlers::wait_for_authentication).post(handlers::wait_for_authentication),
117        )
118        // Batch 2: Header
119        .route("/getHeaderForHeight", post(handlers::get_header_for_height))
120        // Batch 3: Crypto
121        .route("/verifySignature", post(handlers::verify_signature))
122        .route("/encrypt", post(handlers::encrypt))
123        .route("/decrypt", post(handlers::decrypt))
124        .route("/createHmac", post(handlers::create_hmac))
125        .route("/verifyHmac", post(handlers::verify_hmac))
126        // Batch 4: Transaction workflow
127        .route("/signAction", post(handlers::sign_action))
128        .route("/abortAction", post(handlers::abort_action))
129        .route("/listActions", post(handlers::list_actions))
130        .route("/listOutputs", post(handlers::list_outputs))
131        .route("/relinquishOutput", post(handlers::relinquish_output))
132        // Batch 5: Certificates
133        .route("/acquireCertificate", post(handlers::acquire_certificate))
134        .route("/listCertificates", post(handlers::list_certificates))
135        .route("/proveCertificate", post(handlers::prove_certificate))
136        .route(
137            "/relinquishCertificate",
138            post(handlers::relinquish_certificate),
139        )
140        // Batch 6: Discovery + Key linkage
141        .route(
142            "/discoverByIdentityKey",
143            post(handlers::discover_by_identity_key),
144        )
145        .route(
146            "/discoverByAttributes",
147            post(handlers::discover_by_attributes),
148        )
149        .route(
150            "/revealCounterpartyKeyLinkage",
151            post(handlers::reveal_counterparty_key_linkage),
152        )
153        .route(
154            "/revealSpecificKeyLinkage",
155            post(handlers::reveal_specific_key_linkage),
156        )
157        // Layer ordering: CORS (outermost) → auth → trace → body limit
158        .layer(cors)
159        .layer(middleware::from_fn(auth_middleware))
160        .layer(axum::Extension(cfg))
161        .layer(axum::Extension(spending_lock))
162        .layer(axum::Extension(verifier))
163        .layer(TraceLayer::new_for_http())
164        .layer(DefaultBodyLimit::max(50 * 1024 * 1024))
165        .with_state(wallet)
166}
167
168pub async fn run(wallet: WalletState, port: u16, config: ServerConfig) -> Result<()> {
169    let tls = config.tls.clone();
170    let app = make_router(wallet, config);
171    let addr = SocketAddr::from(([127, 0, 0, 1], port));
172
173    #[cfg(feature = "tls")]
174    if let Some(tls_cfg) = tls {
175        use axum_server::tls_rustls::RustlsConfig;
176        let rustls = RustlsConfig::from_pem_file(&tls_cfg.cert_path, &tls_cfg.key_path).await?;
177        tracing::info!("HTTPS server listening on {addr}");
178        eprintln!("HTTPS server listening on {addr}");
179        axum_server::bind_rustls(addr, rustls)
180            .serve(app.into_make_service())
181            .await?;
182        return Ok(());
183    }
184
185    #[cfg(not(feature = "tls"))]
186    if tls.is_some() {
187        anyhow::bail!("TLS requested but binary was built without `--features tls`");
188    }
189
190    tracing::info!("HTTP server listening on {addr}");
191    eprintln!("HTTP server listening on {addr}");
192
193    let listener = tokio::net::TcpListener::bind(addr).await?;
194    axum::serve(listener, app)
195        .with_graceful_shutdown(shutdown_signal())
196        .await?;
197
198    Ok(())
199}
200
201pub fn make_wallet_state(wallet: Wallet<StorageSqlx, Services>) -> WalletState {
202    Arc::new(wallet)
203}
204
205/// Build a `WalletState` from an existing `Arc<Wallet>`.
206///
207/// Use this when the caller is already holding an `Arc<Wallet>` and wants the
208/// HTTP server to share the *same* wallet instance — same identity key, same
209/// balance, same UTXO set. Avoids opening a second `Wallet` handle against
210/// the same SQLite DB (which would work via busy_timeout but adds write
211/// contention and a second instance of any in-memory caches).
212#[allow(dead_code)] // public API for external callers; not used inside this binary
213pub fn make_wallet_state_from_arc(wallet: Arc<Wallet<StorageSqlx, Services>>) -> WalletState {
214    wallet
215}
216
217async fn shutdown_signal() {
218    tokio::signal::ctrl_c()
219        .await
220        .expect("failed to listen for ctrl-c");
221    eprintln!("\nShutting down...");
222}