Skip to main content

bsv_wallet_cli/server/
mod.rs

1pub mod audit;
2pub mod broadcast_follow_up;
3pub mod handlers;
4pub mod types;
5
6use anyhow::Result;
7use axum::extract::{DefaultBodyLimit, Request};
8use axum::http::{HeaderMap, StatusCode};
9use axum::middleware::{self, Next};
10use axum::response::{IntoResponse, Response};
11use axum::routing::{get, post};
12use axum::{Json, Router};
13use bsv_wallet_toolbox::{Chain, Services, StorageSqlx, Wallet};
14use serde_json::json;
15use std::net::SocketAddr;
16use std::sync::Arc;
17use tower_http::cors::CorsLayer;
18use tower_http::trace::TraceLayer;
19
20use handlers::WalletState;
21
22/// Lock that serializes spending operations (createAction).
23/// Non-spending endpoints (encrypt, getPublicKey, listOutputs, etc.) are unaffected.
24///
25/// Why: With limited UTXOs, concurrent createAction calls race on SQLite's write lock
26/// and the loser gets SQLITE_BUSY_SNAPSHOT instead of waiting for the winner's change
27/// output. This lock queues them so each spending request sees the previous one's change.
28pub type SpendingLock = Arc<tokio::sync::Mutex<()>>;
29
30/// TLS configuration (cert + key paths).
31#[derive(Clone)]
32#[allow(dead_code)]
33pub struct TlsConfig {
34    pub cert_path: String,
35    pub key_path: String,
36}
37
38/// Server configuration (auth, TLS, etc.)
39#[derive(Clone)]
40pub struct ServerConfig {
41    /// Optional bearer token. When set, all requests must include
42    /// `Authorization: Bearer <token>`. When None, auth is disabled.
43    pub auth_token: Option<String>,
44    /// Optional TLS config. Requires `--features tls` at build time.
45    pub tls: Option<TlsConfig>,
46    /// Network the served wallet is on. Drives post-broadcast verification
47    /// (which ARC / WoC endpoints to probe). Defaults to `Main`.
48    pub chain: Chain,
49    /// Address to bind (default `127.0.0.1`). Set `BIND_ADDR=0.0.0.0` for the
50    /// tunnel/public-webhook case (`POST /arc-callback` behind cloudflared /
51    /// tailscale funnel / direct TLS).
52    pub bind_addr: std::net::IpAddr,
53    /// Per-wallet ARC/Arcade callback token. When set, `POST /arc-callback`
54    /// is enabled, authenticated by THIS token (`Authorization: Bearer` or
55    /// `X-CallbackToken`) and EXEMPT from the wallet bearer auth above.
56    pub callback_token: Option<String>,
57}
58
59impl Default for ServerConfig {
60    fn default() -> Self {
61        Self {
62            auth_token: None,
63            tls: None,
64            chain: Chain::Main,
65            bind_addr: std::net::IpAddr::from([127, 0, 0, 1]),
66            callback_token: None,
67        }
68    }
69}
70
71/// The address to bind: `BIND_ADDR` when it parses, else `127.0.0.1`. Read
72/// by every served command and by the bind guard, so the two agree.
73pub fn bind_addr_from_env() -> std::net::IpAddr {
74    std::env::var("BIND_ADDR")
75        .ok()
76        .and_then(|v| v.parse().ok())
77        .unwrap_or_else(|| std::net::IpAddr::from([127, 0, 0, 1]))
78}
79
80/// The bind guard (0.7.1). With no bearer token a served wallet answers any
81/// caller that can reach it, and its two BEEF doors take a body of any size
82/// (0.7.0), so an address beyond loopback needs a token or the operator's
83/// explicit `allow_no_token`. An empty token counts as none. Loopback is
84/// unchanged. Run at startup, before the wallet is opened or a socket bound.
85pub fn refuse_open_bind(
86    bind_addr: std::net::IpAddr,
87    auth_token: Option<&str>,
88    allow_no_token: bool,
89) -> Result<()> {
90    let has_token = auth_token.is_some_and(|t| !t.is_empty());
91    if has_token || allow_no_token || bind_addr.to_canonical().is_loopback() {
92        return Ok(());
93    }
94    anyhow::bail!(
95        "refusing to bind {bind_addr}: no bearer token is configured, and a wallet served \
96         beyond loopback without one answers anyone who can reach it. Set AUTH_TOKEN, or pass \
97         --allow-no-token to bind it open deliberately."
98    )
99}
100
101/// [`refuse_open_bind`] on the environment the served commands read
102/// (`BIND_ADDR`, `AUTH_TOKEN`).
103pub fn refuse_open_bind_from_env(allow_no_token: bool) -> Result<()> {
104    let token = std::env::var("AUTH_TOKEN").ok();
105    refuse_open_bind(bind_addr_from_env(), token.as_deref(), allow_no_token)
106}
107
108/// Auth middleware — checks Bearer token if configured.
109async fn auth_middleware(headers: HeaderMap, request: Request, next: Next) -> Response {
110    // /arc-callback is EXEMPT from wallet bearer auth: it is authenticated by
111    // the per-wallet callback token inside its own handler (ARC/Arcade call it
112    // with `Authorization: Bearer <callback-token>`, not the wallet token).
113    if request.uri().path() == "/arc-callback" {
114        return next.run(request).await;
115    }
116
117    // Extract config from request extensions
118    let token = request
119        .extensions()
120        .get::<ServerConfig>()
121        .and_then(|c| c.auth_token.clone());
122
123    if let Some(expected) = token {
124        let provided = headers
125            .get("authorization")
126            .and_then(|v| v.to_str().ok())
127            .and_then(|v| v.strip_prefix("Bearer "));
128
129        match provided {
130            Some(t) if t == expected => {}
131            _ => {
132                return (
133                    StatusCode::UNAUTHORIZED,
134                    Json(json!({"code": "UNAUTHORIZED", "message": "Invalid or missing bearer token"})),
135                )
136                    .into_response();
137            }
138        }
139    }
140
141    next.run(request).await
142}
143
144/// Build the axum Router with all 28 WalletInterface endpoints.
145pub fn make_router(wallet: WalletState, config: ServerConfig) -> Router {
146    let cors = CorsLayer::very_permissive();
147    let cfg = config.clone();
148    let spending_lock: SpendingLock = Arc::new(tokio::sync::Mutex::new(()));
149    // Post-broadcast verifier — lets `/createAction` fail loudly when ARC
150    // silently dropped an immediate broadcast (e.g. 465 fee-too-low on a deep
151    // unconfirmed BEEF) instead of returning a phantom txid.
152    let verifier = crate::broadcast_verify::BroadcastVerifier::from_env(config.chain);
153
154    Router::new()
155        // Existing 5 endpoints
156        // Status endpoints accept BOTH GET and POST: @bsv/sdk's HTTPWalletJSON substrate
157        // POSTs every method (incl. these), while curl/CLI clients GET them. Supporting
158        // both makes the server wire-compatible with browser WalletClient connections.
159        .route(
160            "/isAuthenticated",
161            get(handlers::is_authenticated).post(handlers::is_authenticated),
162        )
163        .route("/getPublicKey", post(handlers::get_public_key))
164        // PERMISSION AUDIT (not BRC-100): what a wallet with a human behind it
165        // would have prompted for. See `audit.rs` — this daemon grants everything
166        // silently, which is what makes it headless AND blind.
167        .route("/permission-audit", get(permission_audit))
168        .route("/permission-audit/reset", post(permission_audit_reset))
169        .route("/createSignature", post(handlers::create_signature))
170        // The two doors that carry a caller's BEEF (`inputBEEF`, `tx`): no
171        // body cap, a valid BEEF is never refused for its size (see
172        // `BEEF_DOORS`).
173        .route(
174            "/createAction",
175            post(handlers::create_action).layer(DefaultBodyLimit::disable()),
176        )
177        .route(
178            "/internalizeAction",
179            post(handlers::internalize_action).layer(DefaultBodyLimit::disable()),
180        )
181        // Batch 1: Status (GET + POST for HTTPWalletJSON compat)
182        .route(
183            "/getHeight",
184            get(handlers::get_height).post(handlers::get_height),
185        )
186        .route(
187            "/getNetwork",
188            get(handlers::get_network).post(handlers::get_network),
189        )
190        .route(
191            "/getVersion",
192            get(handlers::get_version).post(handlers::get_version),
193        )
194        .route(
195            "/waitForAuthentication",
196            get(handlers::wait_for_authentication).post(handlers::wait_for_authentication),
197        )
198        // Batch 2: Header
199        .route("/getHeaderForHeight", post(handlers::get_header_for_height))
200        // Batch 3: Crypto
201        .route("/verifySignature", post(handlers::verify_signature))
202        .route("/encrypt", post(handlers::encrypt))
203        .route("/decrypt", post(handlers::decrypt))
204        .route("/createHmac", post(handlers::create_hmac))
205        .route("/verifyHmac", post(handlers::verify_hmac))
206        // Batch 4: Transaction workflow
207        .route("/signAction", post(handlers::sign_action))
208        .route("/abortAction", post(handlers::abort_action))
209        .route("/listActions", post(handlers::list_actions))
210        .route("/listOutputs", post(handlers::list_outputs))
211        .route("/relinquishOutput", post(handlers::relinquish_output))
212        // Batch 5: Certificates
213        .route("/acquireCertificate", post(handlers::acquire_certificate))
214        .route("/listCertificates", post(handlers::list_certificates))
215        .route("/proveCertificate", post(handlers::prove_certificate))
216        .route(
217            "/relinquishCertificate",
218            post(handlers::relinquish_certificate),
219        )
220        // Batch 6: Discovery + Key linkage
221        .route(
222            "/discoverByIdentityKey",
223            post(handlers::discover_by_identity_key),
224        )
225        .route(
226            "/discoverByAttributes",
227            post(handlers::discover_by_attributes),
228        )
229        .route(
230            "/revealCounterpartyKeyLinkage",
231            post(handlers::reveal_counterparty_key_linkage),
232        )
233        .route(
234            "/revealSpecificKeyLinkage",
235            post(handlers::reveal_specific_key_linkage),
236        )
237        // ARC/Arcade proof-delivery webhook (callback-token auth, exempt from
238        // wallet bearer auth — see auth_middleware).
239        .route("/arc-callback", post(arc_callback))
240        // The last layer added runs first: body limit, trace, the
241        // extensions, then auth BEFORE the body is read (an uncapped BEEF
242        // door is open only to the wallet's own caller), then the body.
243        .layer(cors)
244        .layer(middleware::from_fn(lenient_json_body))
245        .layer(middleware::from_fn(auth_middleware))
246        .layer(axum::Extension(cfg))
247        .layer(axum::Extension(spending_lock))
248        .layer(axum::Extension(verifier))
249        .layer(TraceLayer::new_for_http())
250        .layer(DefaultBodyLimit::max(BODY_CAP))
251        .with_state(wallet)
252}
253
254/// `POST /arc-callback` — ARC/Arcade status webhook receiving push status
255/// updates and (on MINED) the merkle path, straight into wallet storage.
256///
257/// Authenticated by the per-wallet callback token: ARC-convention
258/// `Authorization: Bearer <callback-token>` or an `X-CallbackToken` header
259/// (both accepted — broadcaster implementations vary). Returns 404 when no
260/// callback token is configured (route effectively disabled).
261async fn arc_callback(
262    axum::extract::State(wallet): axum::extract::State<WalletState>,
263    request: Request,
264) -> Response {
265    let expected = request
266        .extensions()
267        .get::<ServerConfig>()
268        .and_then(|c| c.callback_token.clone());
269    let Some(expected) = expected else {
270        return (
271            StatusCode::NOT_FOUND,
272            Json(json!({"code": "CALLBACK_DISABLED", "message": "no callback token configured"})),
273        )
274            .into_response();
275    };
276
277    let headers = request.headers().clone();
278    let provided = headers
279        .get("authorization")
280        .and_then(|v| v.to_str().ok())
281        .and_then(|v| v.strip_prefix("Bearer "))
282        .map(str::to_string)
283        .or_else(|| {
284            headers
285                .get("x-callbacktoken")
286                .and_then(|v| v.to_str().ok())
287                .map(str::to_string)
288        });
289
290    if provided.as_deref() != Some(expected.as_str()) {
291        // Arcade authenticates its webhook POSTs as `Authorization: Bearer
292        // <X-CallbackToken>` — a 401 here means a token mismatch and Arcade
293        // will burn its (~10) retries, then permanently drop the submission.
294        tracing::warn!(
295            has_authorization = headers.contains_key("authorization"),
296            has_x_callbacktoken = headers.contains_key("x-callbacktoken"),
297            "arc-callback: rejected POST with invalid/missing callback token"
298        );
299        return (
300            StatusCode::UNAUTHORIZED,
301            Json(json!({"code": "UNAUTHORIZED", "message": "invalid or missing callback token"})),
302        )
303            .into_response();
304    }
305
306    let bytes = match axum::body::to_bytes(request.into_body(), 1_000_000).await {
307        Ok(b) => b,
308        Err(_) => {
309            return (
310                StatusCode::PAYLOAD_TOO_LARGE,
311                Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
312            )
313                .into_response();
314        }
315    };
316    let payload: serde_json::Value = match serde_json::from_slice(&bytes) {
317        Ok(v) => v,
318        Err(e) => {
319            return (
320                StatusCode::BAD_REQUEST,
321                Json(json!({"code": "BAD_JSON", "message": e.to_string()})),
322            )
323                .into_response();
324        }
325    };
326
327    match crate::arc_ingest::ingest_arc_payload(wallet.storage(), &payload).await {
328        Ok(action) => (
329            StatusCode::OK,
330            Json(json!({"ok": true, "action": format!("{:?}", action)})),
331        )
332            .into_response(),
333        Err(e) => (
334            StatusCode::BAD_REQUEST,
335            Json(json!({"code": "BAD_PAYLOAD", "message": e.to_string()})),
336        )
337            .into_response(),
338    }
339}
340
341/// Browser wallets accept JSON whose strings carry lone UTF-16 surrogate escapes
342/// (`"\ud83d"` without its pair); serde does not, so a page whose keyID or data
343/// happened to contain one got `400 Failed to parse the request body as JSON:
344/// keyID: lone leading surrogate` from this wallet while MetaNet Desktop served
345/// the same call (beta soak, 2026-09-02, two logins lost). A browser's encoder
346/// turns such a code unit into U+FFFD when it hashes the string, so the same
347/// substitution here yields the same bytes the JS wallet would derive from.
348pub fn sanitize_lone_surrogates(input: &[u8]) -> std::borrow::Cow<'_, [u8]> {
349    if !input.windows(2).any(|w| w == b"\\u") {
350        return std::borrow::Cow::Borrowed(input);
351    }
352    let hex4 = |b: &[u8]| -> Option<u32> {
353        if b.len() < 4 {
354            return None;
355        }
356        std::str::from_utf8(&b[..4])
357            .ok()
358            .and_then(|h| u32::from_str_radix(h, 16).ok())
359    };
360    let mut out = Vec::with_capacity(input.len());
361    let mut i = 0;
362    let mut changed = false;
363    while i < input.len() {
364        if input[i] == b'\\' && i + 1 < input.len() && input[i + 1] == b'u' {
365            if let Some(cu) = hex4(&input[i + 2..]) {
366                let is_lead = (0xD800..=0xDBFF).contains(&cu);
367                let is_trail = (0xDC00..=0xDFFF).contains(&cu);
368                if is_lead {
369                    let next = &input[i + 6..];
370                    let paired = next.len() >= 6
371                        && next[0] == b'\\'
372                        && next[1] == b'u'
373                        && hex4(&next[2..]).is_some_and(|t| (0xDC00..=0xDFFF).contains(&t));
374                    if paired {
375                        out.extend_from_slice(&input[i..i + 12]);
376                        i += 12;
377                        continue;
378                    }
379                    out.extend_from_slice(b"\\ufffd");
380                    changed = true;
381                    i += 6;
382                    continue;
383                }
384                if is_trail {
385                    out.extend_from_slice(b"\\ufffd");
386                    changed = true;
387                    i += 6;
388                    continue;
389                }
390                out.extend_from_slice(&input[i..i + 6]);
391                i += 6;
392                continue;
393            }
394        }
395        out.push(input[i]);
396        i += 1;
397    }
398    if changed {
399        std::borrow::Cow::Owned(out)
400    } else {
401        std::borrow::Cow::Borrowed(input)
402    }
403}
404
405/// The routes whose JSON body carries a caller's BEEF. Their body is read
406/// whole with no cap: the BRC-100 JSON wire carries the BEEF as one array, so
407/// the body is held in memory (linear in the BEEF, about four bytes of body
408/// per byte of BEEF) and the BEEF is then judged by the toolbox's streaming
409/// reader for invalid bytes only. Every other route keeps
410/// [`BODY_CAP`].
411const BEEF_DOORS: [&str; 2] = ["/createAction", "/internalizeAction"];
412
413/// The body cap of the routes that carry no BEEF.
414const BODY_CAP: usize = 50 * 1024 * 1024;
415
416/// Rewrite lone surrogate escapes in JSON request bodies before the `Json`
417/// extractors see them (see [`sanitize_lone_surrogates`]).
418async fn lenient_json_body(request: Request, next: Next) -> Response {
419    let is_json = request
420        .headers()
421        .get(axum::http::header::CONTENT_TYPE)
422        .and_then(|v| v.to_str().ok())
423        .is_some_and(|ct| ct.to_ascii_lowercase().contains("json"));
424    if !is_json {
425        return next.run(request).await;
426    }
427    let (parts, body) = request.into_parts();
428    let cap = if BEEF_DOORS.contains(&parts.uri.path()) {
429        usize::MAX
430    } else {
431        BODY_CAP
432    };
433    let bytes = match axum::body::to_bytes(body, cap).await {
434        Ok(b) => b,
435        Err(_) => {
436            return (
437                StatusCode::PAYLOAD_TOO_LARGE,
438                Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
439            )
440                .into_response();
441        }
442    };
443    let body = match sanitize_lone_surrogates(&bytes) {
444        std::borrow::Cow::Borrowed(_) => axum::body::Body::from(bytes),
445        std::borrow::Cow::Owned(fixed) => {
446            tracing::warn!(
447                path = %parts.uri.path(),
448                "json body carried a lone UTF-16 surrogate escape; substituted U+FFFD"
449            );
450            axum::body::Body::from(fixed)
451        }
452    };
453    next.run(Request::from_parts(parts, body)).await
454}
455
456pub async fn run(wallet: WalletState, port: u16, config: ServerConfig) -> Result<()> {
457    let tls = config.tls.clone();
458    let bind_addr = config.bind_addr;
459    let app = make_router(wallet, config);
460    let addr = SocketAddr::from((bind_addr, port));
461
462    #[cfg(feature = "tls")]
463    if let Some(tls_cfg) = tls {
464        use axum_server::tls_rustls::RustlsConfig;
465        let rustls = RustlsConfig::from_pem_file(&tls_cfg.cert_path, &tls_cfg.key_path).await?;
466        tracing::info!("HTTPS server listening on {addr}");
467        eprintln!("HTTPS server listening on {addr}");
468        axum_server::bind_rustls(addr, rustls)
469            .serve(app.into_make_service())
470            .await?;
471        return Ok(());
472    }
473
474    #[cfg(not(feature = "tls"))]
475    if tls.is_some() {
476        anyhow::bail!("TLS requested but binary was built without `--features tls`");
477    }
478
479    tracing::info!("HTTP server listening on {addr}");
480    eprintln!("HTTP server listening on {addr}");
481
482    let listener = tokio::net::TcpListener::bind(addr).await?;
483    axum::serve(listener, app)
484        .with_graceful_shutdown(shutdown_signal())
485        .await?;
486
487    Ok(())
488}
489
490pub fn make_wallet_state(wallet: Wallet<StorageSqlx, Services>) -> WalletState {
491    Arc::new(wallet)
492}
493
494/// Build a `WalletState` from an existing `Arc<Wallet>`.
495///
496/// Use this when the caller is already holding an `Arc<Wallet>` and wants the
497/// HTTP server to share the *same* wallet instance — same identity key, same
498/// balance, same UTXO set. Avoids opening a second `Wallet` handle against
499/// the same SQLite DB (which would work via busy_timeout but adds write
500/// contention and a second instance of any in-memory caches).
501#[allow(dead_code)] // public API for external callers; not used inside this binary
502pub fn make_wallet_state_from_arc(wallet: Arc<Wallet<StorageSqlx, Services>>) -> WalletState {
503    wallet
504}
505
506async fn shutdown_signal() {
507    tokio::signal::ctrl_c()
508        .await
509        .expect("failed to listen for ctrl-c");
510    eprintln!("\nShutting down...");
511}
512
513/// GET /permission-audit — every permissioned request since start or reset.
514async fn permission_audit() -> Json<serde_json::Value> {
515    let entries = audit::snapshot();
516    Json(json!({ "count": entries.len(), "entries": entries }))
517}
518
519/// POST /permission-audit/reset — call immediately before the flow you measure.
520async fn permission_audit_reset() -> Json<serde_json::Value> {
521    audit::reset();
522    Json(json!({ "ok": true }))
523}
524
525#[cfg(test)]
526mod lenient_json_tests {
527    use super::sanitize_lone_surrogates;
528
529    #[test]
530    fn lone_lead_and_lone_trail_become_fffd_and_pairs_survive() {
531        let lead = br#"{"keyID":"ab \ud83d cd"}"#;
532        assert_eq!(
533            &*sanitize_lone_surrogates(lead),
534            br#"{"keyID":"ab \ufffd cd"}"#
535        );
536        let trail = br#"{"keyID":"\udc00"}"#;
537        assert_eq!(&*sanitize_lone_surrogates(trail), br#"{"keyID":"\ufffd"}"#);
538        let pair = br#"{"keyID":"\ud83d\ude00"}"#;
539        assert!(matches!(
540            sanitize_lone_surrogates(pair),
541            std::borrow::Cow::Borrowed(_)
542        ));
543        let plain = br#"{"keyID":"plain \\u0041"}"#;
544        assert!(matches!(
545            sanitize_lone_surrogates(plain),
546            std::borrow::Cow::Borrowed(_)
547        ));
548        let fixed = sanitize_lone_surrogates(lead);
549        let v: serde_json::Value = serde_json::from_slice(&fixed).unwrap();
550        assert_eq!(v["keyID"], "ab \u{fffd} cd");
551    }
552}
553
554#[cfg(test)]
555mod bind_guard_tests {
556    use super::refuse_open_bind;
557    use std::net::IpAddr;
558
559    fn ip(s: &str) -> IpAddr {
560        s.parse().unwrap()
561    }
562
563    #[test]
564    fn beyond_loopback_needs_a_token_or_the_flag() {
565        for open in ["0.0.0.0", "::", "192.168.1.10", "10.0.0.1"] {
566            let err = refuse_open_bind(ip(open), None, false).unwrap_err();
567            assert!(
568                err.to_string().contains("--allow-no-token"),
569                "{open}: {err}"
570            );
571            assert!(
572                refuse_open_bind(ip(open), Some(""), false).is_err(),
573                "{open}"
574            );
575            assert!(
576                refuse_open_bind(ip(open), Some("t"), false).is_ok(),
577                "{open}"
578            );
579            assert!(refuse_open_bind(ip(open), None, true).is_ok(), "{open}");
580        }
581    }
582
583    #[test]
584    fn loopback_is_unchanged() {
585        for lo in ["127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"] {
586            assert!(refuse_open_bind(ip(lo), None, false).is_ok(), "{lo}");
587        }
588    }
589}