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/// Auth middleware — checks Bearer token if configured.
72async fn auth_middleware(headers: HeaderMap, request: Request, next: Next) -> Response {
73    // /arc-callback is EXEMPT from wallet bearer auth: it is authenticated by
74    // the per-wallet callback token inside its own handler (ARC/Arcade call it
75    // with `Authorization: Bearer <callback-token>`, not the wallet token).
76    if request.uri().path() == "/arc-callback" {
77        return next.run(request).await;
78    }
79
80    // Extract config from request extensions
81    let token = request
82        .extensions()
83        .get::<ServerConfig>()
84        .and_then(|c| c.auth_token.clone());
85
86    if let Some(expected) = token {
87        let provided = headers
88            .get("authorization")
89            .and_then(|v| v.to_str().ok())
90            .and_then(|v| v.strip_prefix("Bearer "));
91
92        match provided {
93            Some(t) if t == expected => {}
94            _ => {
95                return (
96                    StatusCode::UNAUTHORIZED,
97                    Json(json!({"code": "UNAUTHORIZED", "message": "Invalid or missing bearer token"})),
98                )
99                    .into_response();
100            }
101        }
102    }
103
104    next.run(request).await
105}
106
107/// Build the axum Router with all 28 WalletInterface endpoints.
108pub fn make_router(wallet: WalletState, config: ServerConfig) -> Router {
109    let cors = CorsLayer::very_permissive();
110    let cfg = config.clone();
111    let spending_lock: SpendingLock = Arc::new(tokio::sync::Mutex::new(()));
112    // Post-broadcast verifier — lets `/createAction` fail loudly when ARC
113    // silently dropped an immediate broadcast (e.g. 465 fee-too-low on a deep
114    // unconfirmed BEEF) instead of returning a phantom txid.
115    let verifier = crate::broadcast_verify::BroadcastVerifier::from_env(config.chain);
116
117    Router::new()
118        // Existing 5 endpoints
119        // Status endpoints accept BOTH GET and POST: @bsv/sdk's HTTPWalletJSON substrate
120        // POSTs every method (incl. these), while curl/CLI clients GET them. Supporting
121        // both makes the server wire-compatible with browser WalletClient connections.
122        .route(
123            "/isAuthenticated",
124            get(handlers::is_authenticated).post(handlers::is_authenticated),
125        )
126        .route("/getPublicKey", post(handlers::get_public_key))
127        // PERMISSION AUDIT (not BRC-100): what a wallet with a human behind it
128        // would have prompted for. See `audit.rs` — this daemon grants everything
129        // silently, which is what makes it headless AND blind.
130        .route("/permission-audit", get(permission_audit))
131        .route("/permission-audit/reset", post(permission_audit_reset))
132        .route("/createSignature", post(handlers::create_signature))
133        // The two doors that carry a caller's BEEF (`inputBEEF`, `tx`): no
134        // body cap, a valid BEEF is never refused for its size (see
135        // `BEEF_DOORS`).
136        .route(
137            "/createAction",
138            post(handlers::create_action).layer(DefaultBodyLimit::disable()),
139        )
140        .route(
141            "/internalizeAction",
142            post(handlers::internalize_action).layer(DefaultBodyLimit::disable()),
143        )
144        // Batch 1: Status (GET + POST for HTTPWalletJSON compat)
145        .route(
146            "/getHeight",
147            get(handlers::get_height).post(handlers::get_height),
148        )
149        .route(
150            "/getNetwork",
151            get(handlers::get_network).post(handlers::get_network),
152        )
153        .route(
154            "/getVersion",
155            get(handlers::get_version).post(handlers::get_version),
156        )
157        .route(
158            "/waitForAuthentication",
159            get(handlers::wait_for_authentication).post(handlers::wait_for_authentication),
160        )
161        // Batch 2: Header
162        .route("/getHeaderForHeight", post(handlers::get_header_for_height))
163        // Batch 3: Crypto
164        .route("/verifySignature", post(handlers::verify_signature))
165        .route("/encrypt", post(handlers::encrypt))
166        .route("/decrypt", post(handlers::decrypt))
167        .route("/createHmac", post(handlers::create_hmac))
168        .route("/verifyHmac", post(handlers::verify_hmac))
169        // Batch 4: Transaction workflow
170        .route("/signAction", post(handlers::sign_action))
171        .route("/abortAction", post(handlers::abort_action))
172        .route("/listActions", post(handlers::list_actions))
173        .route("/listOutputs", post(handlers::list_outputs))
174        .route("/relinquishOutput", post(handlers::relinquish_output))
175        // Batch 5: Certificates
176        .route("/acquireCertificate", post(handlers::acquire_certificate))
177        .route("/listCertificates", post(handlers::list_certificates))
178        .route("/proveCertificate", post(handlers::prove_certificate))
179        .route(
180            "/relinquishCertificate",
181            post(handlers::relinquish_certificate),
182        )
183        // Batch 6: Discovery + Key linkage
184        .route(
185            "/discoverByIdentityKey",
186            post(handlers::discover_by_identity_key),
187        )
188        .route(
189            "/discoverByAttributes",
190            post(handlers::discover_by_attributes),
191        )
192        .route(
193            "/revealCounterpartyKeyLinkage",
194            post(handlers::reveal_counterparty_key_linkage),
195        )
196        .route(
197            "/revealSpecificKeyLinkage",
198            post(handlers::reveal_specific_key_linkage),
199        )
200        // ARC/Arcade proof-delivery webhook (callback-token auth, exempt from
201        // wallet bearer auth — see auth_middleware).
202        .route("/arc-callback", post(arc_callback))
203        // The last layer added runs first: body limit, trace, the
204        // extensions, then auth BEFORE the body is read (an uncapped BEEF
205        // door is open only to the wallet's own caller), then the body.
206        .layer(cors)
207        .layer(middleware::from_fn(lenient_json_body))
208        .layer(middleware::from_fn(auth_middleware))
209        .layer(axum::Extension(cfg))
210        .layer(axum::Extension(spending_lock))
211        .layer(axum::Extension(verifier))
212        .layer(TraceLayer::new_for_http())
213        .layer(DefaultBodyLimit::max(BODY_CAP))
214        .with_state(wallet)
215}
216
217/// `POST /arc-callback` — ARC/Arcade status webhook receiving push status
218/// updates and (on MINED) the merkle path, straight into wallet storage.
219///
220/// Authenticated by the per-wallet callback token: ARC-convention
221/// `Authorization: Bearer <callback-token>` or an `X-CallbackToken` header
222/// (both accepted — broadcaster implementations vary). Returns 404 when no
223/// callback token is configured (route effectively disabled).
224async fn arc_callback(
225    axum::extract::State(wallet): axum::extract::State<WalletState>,
226    request: Request,
227) -> Response {
228    let expected = request
229        .extensions()
230        .get::<ServerConfig>()
231        .and_then(|c| c.callback_token.clone());
232    let Some(expected) = expected else {
233        return (
234            StatusCode::NOT_FOUND,
235            Json(json!({"code": "CALLBACK_DISABLED", "message": "no callback token configured"})),
236        )
237            .into_response();
238    };
239
240    let headers = request.headers().clone();
241    let provided = headers
242        .get("authorization")
243        .and_then(|v| v.to_str().ok())
244        .and_then(|v| v.strip_prefix("Bearer "))
245        .map(str::to_string)
246        .or_else(|| {
247            headers
248                .get("x-callbacktoken")
249                .and_then(|v| v.to_str().ok())
250                .map(str::to_string)
251        });
252
253    if provided.as_deref() != Some(expected.as_str()) {
254        // Arcade authenticates its webhook POSTs as `Authorization: Bearer
255        // <X-CallbackToken>` — a 401 here means a token mismatch and Arcade
256        // will burn its (~10) retries, then permanently drop the submission.
257        tracing::warn!(
258            has_authorization = headers.contains_key("authorization"),
259            has_x_callbacktoken = headers.contains_key("x-callbacktoken"),
260            "arc-callback: rejected POST with invalid/missing callback token"
261        );
262        return (
263            StatusCode::UNAUTHORIZED,
264            Json(json!({"code": "UNAUTHORIZED", "message": "invalid or missing callback token"})),
265        )
266            .into_response();
267    }
268
269    let bytes = match axum::body::to_bytes(request.into_body(), 1_000_000).await {
270        Ok(b) => b,
271        Err(_) => {
272            return (
273                StatusCode::PAYLOAD_TOO_LARGE,
274                Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
275            )
276                .into_response();
277        }
278    };
279    let payload: serde_json::Value = match serde_json::from_slice(&bytes) {
280        Ok(v) => v,
281        Err(e) => {
282            return (
283                StatusCode::BAD_REQUEST,
284                Json(json!({"code": "BAD_JSON", "message": e.to_string()})),
285            )
286                .into_response();
287        }
288    };
289
290    match crate::arc_ingest::ingest_arc_payload(wallet.storage(), &payload).await {
291        Ok(action) => (
292            StatusCode::OK,
293            Json(json!({"ok": true, "action": format!("{:?}", action)})),
294        )
295            .into_response(),
296        Err(e) => (
297            StatusCode::BAD_REQUEST,
298            Json(json!({"code": "BAD_PAYLOAD", "message": e.to_string()})),
299        )
300            .into_response(),
301    }
302}
303
304/// Browser wallets accept JSON whose strings carry lone UTF-16 surrogate escapes
305/// (`"\ud83d"` without its pair); serde does not, so a page whose keyID or data
306/// happened to contain one got `400 Failed to parse the request body as JSON:
307/// keyID: lone leading surrogate` from this wallet while MetaNet Desktop served
308/// the same call (beta soak, 2026-09-02, two logins lost). A browser's encoder
309/// turns such a code unit into U+FFFD when it hashes the string, so the same
310/// substitution here yields the same bytes the JS wallet would derive from.
311pub fn sanitize_lone_surrogates(input: &[u8]) -> std::borrow::Cow<'_, [u8]> {
312    if !input.windows(2).any(|w| w == b"\\u") {
313        return std::borrow::Cow::Borrowed(input);
314    }
315    let hex4 = |b: &[u8]| -> Option<u32> {
316        if b.len() < 4 {
317            return None;
318        }
319        std::str::from_utf8(&b[..4])
320            .ok()
321            .and_then(|h| u32::from_str_radix(h, 16).ok())
322    };
323    let mut out = Vec::with_capacity(input.len());
324    let mut i = 0;
325    let mut changed = false;
326    while i < input.len() {
327        if input[i] == b'\\' && i + 1 < input.len() && input[i + 1] == b'u' {
328            if let Some(cu) = hex4(&input[i + 2..]) {
329                let is_lead = (0xD800..=0xDBFF).contains(&cu);
330                let is_trail = (0xDC00..=0xDFFF).contains(&cu);
331                if is_lead {
332                    let next = &input[i + 6..];
333                    let paired = next.len() >= 6
334                        && next[0] == b'\\'
335                        && next[1] == b'u'
336                        && hex4(&next[2..]).is_some_and(|t| (0xDC00..=0xDFFF).contains(&t));
337                    if paired {
338                        out.extend_from_slice(&input[i..i + 12]);
339                        i += 12;
340                        continue;
341                    }
342                    out.extend_from_slice(b"\\ufffd");
343                    changed = true;
344                    i += 6;
345                    continue;
346                }
347                if is_trail {
348                    out.extend_from_slice(b"\\ufffd");
349                    changed = true;
350                    i += 6;
351                    continue;
352                }
353                out.extend_from_slice(&input[i..i + 6]);
354                i += 6;
355                continue;
356            }
357        }
358        out.push(input[i]);
359        i += 1;
360    }
361    if changed {
362        std::borrow::Cow::Owned(out)
363    } else {
364        std::borrow::Cow::Borrowed(input)
365    }
366}
367
368/// The routes whose JSON body carries a caller's BEEF. Their body is read
369/// whole with no cap: the BRC-100 JSON wire carries the BEEF as one array, so
370/// the body is held in memory (linear in the BEEF, about four bytes of body
371/// per byte of BEEF) and the BEEF is then judged by the toolbox's streaming
372/// reader for invalid bytes only. Every other route keeps
373/// [`BODY_CAP`].
374const BEEF_DOORS: [&str; 2] = ["/createAction", "/internalizeAction"];
375
376/// The body cap of the routes that carry no BEEF.
377const BODY_CAP: usize = 50 * 1024 * 1024;
378
379/// Rewrite lone surrogate escapes in JSON request bodies before the `Json`
380/// extractors see them (see [`sanitize_lone_surrogates`]).
381async fn lenient_json_body(request: Request, next: Next) -> Response {
382    let is_json = request
383        .headers()
384        .get(axum::http::header::CONTENT_TYPE)
385        .and_then(|v| v.to_str().ok())
386        .is_some_and(|ct| ct.to_ascii_lowercase().contains("json"));
387    if !is_json {
388        return next.run(request).await;
389    }
390    let (parts, body) = request.into_parts();
391    let cap = if BEEF_DOORS.contains(&parts.uri.path()) {
392        usize::MAX
393    } else {
394        BODY_CAP
395    };
396    let bytes = match axum::body::to_bytes(body, cap).await {
397        Ok(b) => b,
398        Err(_) => {
399            return (
400                StatusCode::PAYLOAD_TOO_LARGE,
401                Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
402            )
403                .into_response();
404        }
405    };
406    let body = match sanitize_lone_surrogates(&bytes) {
407        std::borrow::Cow::Borrowed(_) => axum::body::Body::from(bytes),
408        std::borrow::Cow::Owned(fixed) => {
409            tracing::warn!(
410                path = %parts.uri.path(),
411                "json body carried a lone UTF-16 surrogate escape; substituted U+FFFD"
412            );
413            axum::body::Body::from(fixed)
414        }
415    };
416    next.run(Request::from_parts(parts, body)).await
417}
418
419pub async fn run(wallet: WalletState, port: u16, config: ServerConfig) -> Result<()> {
420    let tls = config.tls.clone();
421    let bind_addr = config.bind_addr;
422    let app = make_router(wallet, config);
423    let addr = SocketAddr::from((bind_addr, port));
424
425    #[cfg(feature = "tls")]
426    if let Some(tls_cfg) = tls {
427        use axum_server::tls_rustls::RustlsConfig;
428        let rustls = RustlsConfig::from_pem_file(&tls_cfg.cert_path, &tls_cfg.key_path).await?;
429        tracing::info!("HTTPS server listening on {addr}");
430        eprintln!("HTTPS server listening on {addr}");
431        axum_server::bind_rustls(addr, rustls)
432            .serve(app.into_make_service())
433            .await?;
434        return Ok(());
435    }
436
437    #[cfg(not(feature = "tls"))]
438    if tls.is_some() {
439        anyhow::bail!("TLS requested but binary was built without `--features tls`");
440    }
441
442    tracing::info!("HTTP server listening on {addr}");
443    eprintln!("HTTP server listening on {addr}");
444
445    let listener = tokio::net::TcpListener::bind(addr).await?;
446    axum::serve(listener, app)
447        .with_graceful_shutdown(shutdown_signal())
448        .await?;
449
450    Ok(())
451}
452
453pub fn make_wallet_state(wallet: Wallet<StorageSqlx, Services>) -> WalletState {
454    Arc::new(wallet)
455}
456
457/// Build a `WalletState` from an existing `Arc<Wallet>`.
458///
459/// Use this when the caller is already holding an `Arc<Wallet>` and wants the
460/// HTTP server to share the *same* wallet instance — same identity key, same
461/// balance, same UTXO set. Avoids opening a second `Wallet` handle against
462/// the same SQLite DB (which would work via busy_timeout but adds write
463/// contention and a second instance of any in-memory caches).
464#[allow(dead_code)] // public API for external callers; not used inside this binary
465pub fn make_wallet_state_from_arc(wallet: Arc<Wallet<StorageSqlx, Services>>) -> WalletState {
466    wallet
467}
468
469async fn shutdown_signal() {
470    tokio::signal::ctrl_c()
471        .await
472        .expect("failed to listen for ctrl-c");
473    eprintln!("\nShutting down...");
474}
475
476/// GET /permission-audit — every permissioned request since start or reset.
477async fn permission_audit() -> Json<serde_json::Value> {
478    let entries = audit::snapshot();
479    Json(json!({ "count": entries.len(), "entries": entries }))
480}
481
482/// POST /permission-audit/reset — call immediately before the flow you measure.
483async fn permission_audit_reset() -> Json<serde_json::Value> {
484    audit::reset();
485    Json(json!({ "ok": true }))
486}
487
488#[cfg(test)]
489mod lenient_json_tests {
490    use super::sanitize_lone_surrogates;
491
492    #[test]
493    fn lone_lead_and_lone_trail_become_fffd_and_pairs_survive() {
494        let lead = br#"{"keyID":"ab \ud83d cd"}"#;
495        assert_eq!(
496            &*sanitize_lone_surrogates(lead),
497            br#"{"keyID":"ab \ufffd cd"}"#
498        );
499        let trail = br#"{"keyID":"\udc00"}"#;
500        assert_eq!(&*sanitize_lone_surrogates(trail), br#"{"keyID":"\ufffd"}"#);
501        let pair = br#"{"keyID":"\ud83d\ude00"}"#;
502        assert!(matches!(
503            sanitize_lone_surrogates(pair),
504            std::borrow::Cow::Borrowed(_)
505        ));
506        let plain = br#"{"keyID":"plain \\u0041"}"#;
507        assert!(matches!(
508            sanitize_lone_surrogates(plain),
509            std::borrow::Cow::Borrowed(_)
510        ));
511        let fixed = sanitize_lone_surrogates(lead);
512        let v: serde_json::Value = serde_json::from_slice(&fixed).unwrap();
513        assert_eq!(v["keyID"], "ab \u{fffd} cd");
514    }
515}