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