Skip to main content

bsv_wallet_cli/server/
mod.rs

1pub mod audit;
2pub mod handlers;
3pub mod types;
4
5use anyhow::Result;
6use axum::extract::{DefaultBodyLimit, Request};
7use axum::http::{HeaderMap, StatusCode};
8use axum::middleware::{self, Next};
9use axum::response::{IntoResponse, Response};
10use axum::routing::{get, post};
11use axum::{Json, Router};
12use bsv_wallet_toolbox::{Chain, Services, StorageSqlx, Wallet};
13use serde_json::json;
14use std::net::SocketAddr;
15use std::sync::Arc;
16use tower_http::cors::CorsLayer;
17use tower_http::trace::TraceLayer;
18
19use handlers::WalletState;
20
21/// Lock that serializes spending operations (createAction).
22/// Non-spending endpoints (encrypt, getPublicKey, listOutputs, etc.) are unaffected.
23///
24/// Why: With limited UTXOs, concurrent createAction calls race on SQLite's write lock
25/// and the loser gets SQLITE_BUSY_SNAPSHOT instead of waiting for the winner's change
26/// output. This lock queues them so each spending request sees the previous one's change.
27pub type SpendingLock = Arc<tokio::sync::Mutex<()>>;
28
29/// TLS configuration (cert + key paths).
30#[derive(Clone)]
31#[allow(dead_code)]
32pub struct TlsConfig {
33    pub cert_path: String,
34    pub key_path: String,
35}
36
37/// Server configuration (auth, TLS, etc.)
38#[derive(Clone)]
39pub struct ServerConfig {
40    /// Optional bearer token. When set, all requests must include
41    /// `Authorization: Bearer <token>`. When None, auth is disabled.
42    pub auth_token: Option<String>,
43    /// Optional TLS config. Requires `--features tls` at build time.
44    pub tls: Option<TlsConfig>,
45    /// Network the served wallet is on. Drives post-broadcast verification
46    /// (which ARC / WoC endpoints to probe). Defaults to `Main`.
47    pub chain: Chain,
48    /// Address to bind (default `127.0.0.1`). Set `BIND_ADDR=0.0.0.0` for the
49    /// tunnel/public-webhook case (`POST /arc-callback` behind cloudflared /
50    /// tailscale funnel / direct TLS).
51    pub bind_addr: std::net::IpAddr,
52    /// Per-wallet ARC/Arcade callback token. When set, `POST /arc-callback`
53    /// is enabled, authenticated by THIS token (`Authorization: Bearer` or
54    /// `X-CallbackToken`) and EXEMPT from the wallet bearer auth above.
55    pub callback_token: Option<String>,
56}
57
58impl Default for ServerConfig {
59    fn default() -> Self {
60        Self {
61            auth_token: None,
62            tls: None,
63            chain: Chain::Main,
64            bind_addr: std::net::IpAddr::from([127, 0, 0, 1]),
65            callback_token: None,
66        }
67    }
68}
69
70/// Auth middleware — checks Bearer token if configured.
71async fn auth_middleware(headers: HeaderMap, request: Request, next: Next) -> Response {
72    // /arc-callback is EXEMPT from wallet bearer auth: it is authenticated by
73    // the per-wallet callback token inside its own handler (ARC/Arcade call it
74    // with `Authorization: Bearer <callback-token>`, not the wallet token).
75    if request.uri().path() == "/arc-callback" {
76        return next.run(request).await;
77    }
78
79    // Extract config from request extensions
80    let token = request
81        .extensions()
82        .get::<ServerConfig>()
83        .and_then(|c| c.auth_token.clone());
84
85    if let Some(expected) = token {
86        let provided = headers
87            .get("authorization")
88            .and_then(|v| v.to_str().ok())
89            .and_then(|v| v.strip_prefix("Bearer "));
90
91        match provided {
92            Some(t) if t == expected => {}
93            _ => {
94                return (
95                    StatusCode::UNAUTHORIZED,
96                    Json(json!({"code": "UNAUTHORIZED", "message": "Invalid or missing bearer token"})),
97                )
98                    .into_response();
99            }
100        }
101    }
102
103    next.run(request).await
104}
105
106/// Build the axum Router with all 28 WalletInterface endpoints.
107pub fn make_router(wallet: WalletState, config: ServerConfig) -> Router {
108    let cors = CorsLayer::very_permissive();
109    let cfg = config.clone();
110    let spending_lock: SpendingLock = Arc::new(tokio::sync::Mutex::new(()));
111    // Post-broadcast verifier — lets `/createAction` fail loudly when ARC
112    // silently dropped an immediate broadcast (e.g. 465 fee-too-low on a deep
113    // unconfirmed BEEF) instead of returning a phantom txid.
114    let verifier = crate::broadcast_verify::BroadcastVerifier::from_env(config.chain);
115
116    Router::new()
117        // Existing 5 endpoints
118        // Status endpoints accept BOTH GET and POST: @bsv/sdk's HTTPWalletJSON substrate
119        // POSTs every method (incl. these), while curl/CLI clients GET them. Supporting
120        // both makes the server wire-compatible with browser WalletClient connections.
121        .route(
122            "/isAuthenticated",
123            get(handlers::is_authenticated).post(handlers::is_authenticated),
124        )
125        .route("/getPublicKey", post(handlers::get_public_key))
126        // PERMISSION AUDIT (not BRC-100): what a wallet with a human behind it
127        // would have prompted for. See `audit.rs` — this daemon grants everything
128        // silently, which is what makes it headless AND blind.
129        .route("/permission-audit", get(permission_audit))
130        .route("/permission-audit/reset", post(permission_audit_reset))
131        .route("/createSignature", post(handlers::create_signature))
132        .route("/createAction", post(handlers::create_action))
133        .route("/internalizeAction", post(handlers::internalize_action))
134        // Batch 1: Status (GET + POST for HTTPWalletJSON compat)
135        .route(
136            "/getHeight",
137            get(handlers::get_height).post(handlers::get_height),
138        )
139        .route(
140            "/getNetwork",
141            get(handlers::get_network).post(handlers::get_network),
142        )
143        .route(
144            "/getVersion",
145            get(handlers::get_version).post(handlers::get_version),
146        )
147        .route(
148            "/waitForAuthentication",
149            get(handlers::wait_for_authentication).post(handlers::wait_for_authentication),
150        )
151        // Batch 2: Header
152        .route("/getHeaderForHeight", post(handlers::get_header_for_height))
153        // Batch 3: Crypto
154        .route("/verifySignature", post(handlers::verify_signature))
155        .route("/encrypt", post(handlers::encrypt))
156        .route("/decrypt", post(handlers::decrypt))
157        .route("/createHmac", post(handlers::create_hmac))
158        .route("/verifyHmac", post(handlers::verify_hmac))
159        // Batch 4: Transaction workflow
160        .route("/signAction", post(handlers::sign_action))
161        .route("/abortAction", post(handlers::abort_action))
162        .route("/listActions", post(handlers::list_actions))
163        .route("/listOutputs", post(handlers::list_outputs))
164        .route("/relinquishOutput", post(handlers::relinquish_output))
165        // Batch 5: Certificates
166        .route("/acquireCertificate", post(handlers::acquire_certificate))
167        .route("/listCertificates", post(handlers::list_certificates))
168        .route("/proveCertificate", post(handlers::prove_certificate))
169        .route(
170            "/relinquishCertificate",
171            post(handlers::relinquish_certificate),
172        )
173        // Batch 6: Discovery + Key linkage
174        .route(
175            "/discoverByIdentityKey",
176            post(handlers::discover_by_identity_key),
177        )
178        .route(
179            "/discoverByAttributes",
180            post(handlers::discover_by_attributes),
181        )
182        .route(
183            "/revealCounterpartyKeyLinkage",
184            post(handlers::reveal_counterparty_key_linkage),
185        )
186        .route(
187            "/revealSpecificKeyLinkage",
188            post(handlers::reveal_specific_key_linkage),
189        )
190        // ARC/Arcade proof-delivery webhook (callback-token auth, exempt from
191        // wallet bearer auth — see auth_middleware).
192        .route("/arc-callback", post(arc_callback))
193        // Layer ordering: CORS (outermost) → auth → trace → body limit
194        .layer(cors)
195        .layer(middleware::from_fn(auth_middleware))
196        .layer(axum::Extension(cfg))
197        .layer(axum::Extension(spending_lock))
198        .layer(axum::Extension(verifier))
199        .layer(TraceLayer::new_for_http())
200        .layer(DefaultBodyLimit::max(50 * 1024 * 1024))
201        .with_state(wallet)
202}
203
204/// `POST /arc-callback` — ARC/Arcade status webhook receiving push status
205/// updates and (on MINED) the merkle path, straight into wallet storage.
206///
207/// Authenticated by the per-wallet callback token: ARC-convention
208/// `Authorization: Bearer <callback-token>` or an `X-CallbackToken` header
209/// (both accepted — broadcaster implementations vary). Returns 404 when no
210/// callback token is configured (route effectively disabled).
211async fn arc_callback(
212    axum::extract::State(wallet): axum::extract::State<WalletState>,
213    request: Request,
214) -> Response {
215    let expected = request
216        .extensions()
217        .get::<ServerConfig>()
218        .and_then(|c| c.callback_token.clone());
219    let Some(expected) = expected else {
220        return (
221            StatusCode::NOT_FOUND,
222            Json(json!({"code": "CALLBACK_DISABLED", "message": "no callback token configured"})),
223        )
224            .into_response();
225    };
226
227    let headers = request.headers().clone();
228    let provided = headers
229        .get("authorization")
230        .and_then(|v| v.to_str().ok())
231        .and_then(|v| v.strip_prefix("Bearer "))
232        .map(str::to_string)
233        .or_else(|| {
234            headers
235                .get("x-callbacktoken")
236                .and_then(|v| v.to_str().ok())
237                .map(str::to_string)
238        });
239
240    if provided.as_deref() != Some(expected.as_str()) {
241        // Arcade authenticates its webhook POSTs as `Authorization: Bearer
242        // <X-CallbackToken>` — a 401 here means a token mismatch and Arcade
243        // will burn its (~10) retries, then permanently drop the submission.
244        tracing::warn!(
245            has_authorization = headers.contains_key("authorization"),
246            has_x_callbacktoken = headers.contains_key("x-callbacktoken"),
247            "arc-callback: rejected POST with invalid/missing callback token"
248        );
249        return (
250            StatusCode::UNAUTHORIZED,
251            Json(json!({"code": "UNAUTHORIZED", "message": "invalid or missing callback token"})),
252        )
253            .into_response();
254    }
255
256    let bytes = match axum::body::to_bytes(request.into_body(), 1_000_000).await {
257        Ok(b) => b,
258        Err(_) => {
259            return (
260                StatusCode::PAYLOAD_TOO_LARGE,
261                Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
262            )
263                .into_response();
264        }
265    };
266    let payload: serde_json::Value = match serde_json::from_slice(&bytes) {
267        Ok(v) => v,
268        Err(e) => {
269            return (
270                StatusCode::BAD_REQUEST,
271                Json(json!({"code": "BAD_JSON", "message": e.to_string()})),
272            )
273                .into_response();
274        }
275    };
276
277    match crate::arc_ingest::ingest_arc_payload(wallet.storage(), &payload).await {
278        Ok(action) => (
279            StatusCode::OK,
280            Json(json!({"ok": true, "action": format!("{:?}", action)})),
281        )
282            .into_response(),
283        Err(e) => (
284            StatusCode::BAD_REQUEST,
285            Json(json!({"code": "BAD_PAYLOAD", "message": e.to_string()})),
286        )
287            .into_response(),
288    }
289}
290
291pub async fn run(wallet: WalletState, port: u16, config: ServerConfig) -> Result<()> {
292    let tls = config.tls.clone();
293    let bind_addr = config.bind_addr;
294    let app = make_router(wallet, config);
295    let addr = SocketAddr::from((bind_addr, port));
296
297    #[cfg(feature = "tls")]
298    if let Some(tls_cfg) = tls {
299        use axum_server::tls_rustls::RustlsConfig;
300        let rustls = RustlsConfig::from_pem_file(&tls_cfg.cert_path, &tls_cfg.key_path).await?;
301        tracing::info!("HTTPS server listening on {addr}");
302        eprintln!("HTTPS server listening on {addr}");
303        axum_server::bind_rustls(addr, rustls)
304            .serve(app.into_make_service())
305            .await?;
306        return Ok(());
307    }
308
309    #[cfg(not(feature = "tls"))]
310    if tls.is_some() {
311        anyhow::bail!("TLS requested but binary was built without `--features tls`");
312    }
313
314    tracing::info!("HTTP server listening on {addr}");
315    eprintln!("HTTP server listening on {addr}");
316
317    let listener = tokio::net::TcpListener::bind(addr).await?;
318    axum::serve(listener, app)
319        .with_graceful_shutdown(shutdown_signal())
320        .await?;
321
322    Ok(())
323}
324
325pub fn make_wallet_state(wallet: Wallet<StorageSqlx, Services>) -> WalletState {
326    Arc::new(wallet)
327}
328
329/// Build a `WalletState` from an existing `Arc<Wallet>`.
330///
331/// Use this when the caller is already holding an `Arc<Wallet>` and wants the
332/// HTTP server to share the *same* wallet instance — same identity key, same
333/// balance, same UTXO set. Avoids opening a second `Wallet` handle against
334/// the same SQLite DB (which would work via busy_timeout but adds write
335/// contention and a second instance of any in-memory caches).
336#[allow(dead_code)] // public API for external callers; not used inside this binary
337pub fn make_wallet_state_from_arc(wallet: Arc<Wallet<StorageSqlx, Services>>) -> WalletState {
338    wallet
339}
340
341async fn shutdown_signal() {
342    tokio::signal::ctrl_c()
343        .await
344        .expect("failed to listen for ctrl-c");
345    eprintln!("\nShutting down...");
346}
347
348/// GET /permission-audit — every permissioned request since start or reset.
349async fn permission_audit() -> Json<serde_json::Value> {
350    let entries = audit::snapshot();
351    Json(json!({ "count": entries.len(), "entries": entries }))
352}
353
354/// POST /permission-audit/reset — call immediately before the flow you measure.
355async fn permission_audit_reset() -> Json<serde_json::Value> {
356    audit::reset();
357    Json(json!({ "ok": true }))
358}