bsv-wallet-cli 0.2.24

Self-hosted BSV wallet CLI and BRC-100 server, wire-compatible with MetaNet Client
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
pub mod audit;
pub mod broadcast_follow_up;
pub mod handlers;
pub mod types;

use anyhow::Result;
use axum::extract::{DefaultBodyLimit, Request};
use axum::http::{HeaderMap, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use bsv_wallet_toolbox::{Chain, Services, StorageSqlx, Wallet};
use serde_json::json;
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;

use handlers::WalletState;

/// Lock that serializes spending operations (createAction).
/// Non-spending endpoints (encrypt, getPublicKey, listOutputs, etc.) are unaffected.
///
/// Why: With limited UTXOs, concurrent createAction calls race on SQLite's write lock
/// and the loser gets SQLITE_BUSY_SNAPSHOT instead of waiting for the winner's change
/// output. This lock queues them so each spending request sees the previous one's change.
pub type SpendingLock = Arc<tokio::sync::Mutex<()>>;

/// TLS configuration (cert + key paths).
#[derive(Clone)]
#[allow(dead_code)]
pub struct TlsConfig {
    pub cert_path: String,
    pub key_path: String,
}

/// Server configuration (auth, TLS, etc.)
#[derive(Clone)]
pub struct ServerConfig {
    /// Optional bearer token. When set, all requests must include
    /// `Authorization: Bearer <token>`. When None, auth is disabled.
    pub auth_token: Option<String>,
    /// Optional TLS config. Requires `--features tls` at build time.
    pub tls: Option<TlsConfig>,
    /// Network the served wallet is on. Drives post-broadcast verification
    /// (which ARC / WoC endpoints to probe). Defaults to `Main`.
    pub chain: Chain,
    /// Address to bind (default `127.0.0.1`). Set `BIND_ADDR=0.0.0.0` for the
    /// tunnel/public-webhook case (`POST /arc-callback` behind cloudflared /
    /// tailscale funnel / direct TLS).
    pub bind_addr: std::net::IpAddr,
    /// Per-wallet ARC/Arcade callback token. When set, `POST /arc-callback`
    /// is enabled, authenticated by THIS token (`Authorization: Bearer` or
    /// `X-CallbackToken`) and EXEMPT from the wallet bearer auth above.
    pub callback_token: Option<String>,
}

impl Default for ServerConfig {
    fn default() -> Self {
        Self {
            auth_token: None,
            tls: None,
            chain: Chain::Main,
            bind_addr: std::net::IpAddr::from([127, 0, 0, 1]),
            callback_token: None,
        }
    }
}

/// Auth middleware — checks Bearer token if configured.
async fn auth_middleware(headers: HeaderMap, request: Request, next: Next) -> Response {
    // /arc-callback is EXEMPT from wallet bearer auth: it is authenticated by
    // the per-wallet callback token inside its own handler (ARC/Arcade call it
    // with `Authorization: Bearer <callback-token>`, not the wallet token).
    if request.uri().path() == "/arc-callback" {
        return next.run(request).await;
    }

    // Extract config from request extensions
    let token = request
        .extensions()
        .get::<ServerConfig>()
        .and_then(|c| c.auth_token.clone());

    if let Some(expected) = token {
        let provided = headers
            .get("authorization")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "));

        match provided {
            Some(t) if t == expected => {}
            _ => {
                return (
                    StatusCode::UNAUTHORIZED,
                    Json(json!({"code": "UNAUTHORIZED", "message": "Invalid or missing bearer token"})),
                )
                    .into_response();
            }
        }
    }

    next.run(request).await
}

/// Build the axum Router with all 28 WalletInterface endpoints.
pub fn make_router(wallet: WalletState, config: ServerConfig) -> Router {
    let cors = CorsLayer::very_permissive();
    let cfg = config.clone();
    let spending_lock: SpendingLock = Arc::new(tokio::sync::Mutex::new(()));
    // Post-broadcast verifier — lets `/createAction` fail loudly when ARC
    // silently dropped an immediate broadcast (e.g. 465 fee-too-low on a deep
    // unconfirmed BEEF) instead of returning a phantom txid.
    let verifier = crate::broadcast_verify::BroadcastVerifier::from_env(config.chain);

    Router::new()
        // Existing 5 endpoints
        // Status endpoints accept BOTH GET and POST: @bsv/sdk's HTTPWalletJSON substrate
        // POSTs every method (incl. these), while curl/CLI clients GET them. Supporting
        // both makes the server wire-compatible with browser WalletClient connections.
        .route(
            "/isAuthenticated",
            get(handlers::is_authenticated).post(handlers::is_authenticated),
        )
        .route("/getPublicKey", post(handlers::get_public_key))
        // PERMISSION AUDIT (not BRC-100): what a wallet with a human behind it
        // would have prompted for. See `audit.rs` — this daemon grants everything
        // silently, which is what makes it headless AND blind.
        .route("/permission-audit", get(permission_audit))
        .route("/permission-audit/reset", post(permission_audit_reset))
        .route("/createSignature", post(handlers::create_signature))
        .route("/createAction", post(handlers::create_action))
        .route("/internalizeAction", post(handlers::internalize_action))
        // Batch 1: Status (GET + POST for HTTPWalletJSON compat)
        .route(
            "/getHeight",
            get(handlers::get_height).post(handlers::get_height),
        )
        .route(
            "/getNetwork",
            get(handlers::get_network).post(handlers::get_network),
        )
        .route(
            "/getVersion",
            get(handlers::get_version).post(handlers::get_version),
        )
        .route(
            "/waitForAuthentication",
            get(handlers::wait_for_authentication).post(handlers::wait_for_authentication),
        )
        // Batch 2: Header
        .route("/getHeaderForHeight", post(handlers::get_header_for_height))
        // Batch 3: Crypto
        .route("/verifySignature", post(handlers::verify_signature))
        .route("/encrypt", post(handlers::encrypt))
        .route("/decrypt", post(handlers::decrypt))
        .route("/createHmac", post(handlers::create_hmac))
        .route("/verifyHmac", post(handlers::verify_hmac))
        // Batch 4: Transaction workflow
        .route("/signAction", post(handlers::sign_action))
        .route("/abortAction", post(handlers::abort_action))
        .route("/listActions", post(handlers::list_actions))
        .route("/listOutputs", post(handlers::list_outputs))
        .route("/relinquishOutput", post(handlers::relinquish_output))
        // Batch 5: Certificates
        .route("/acquireCertificate", post(handlers::acquire_certificate))
        .route("/listCertificates", post(handlers::list_certificates))
        .route("/proveCertificate", post(handlers::prove_certificate))
        .route(
            "/relinquishCertificate",
            post(handlers::relinquish_certificate),
        )
        // Batch 6: Discovery + Key linkage
        .route(
            "/discoverByIdentityKey",
            post(handlers::discover_by_identity_key),
        )
        .route(
            "/discoverByAttributes",
            post(handlers::discover_by_attributes),
        )
        .route(
            "/revealCounterpartyKeyLinkage",
            post(handlers::reveal_counterparty_key_linkage),
        )
        .route(
            "/revealSpecificKeyLinkage",
            post(handlers::reveal_specific_key_linkage),
        )
        // ARC/Arcade proof-delivery webhook (callback-token auth, exempt from
        // wallet bearer auth — see auth_middleware).
        .route("/arc-callback", post(arc_callback))
        // Layer ordering: CORS (outermost) → auth → trace → body limit
        .layer(cors)
        .layer(middleware::from_fn(auth_middleware))
        .layer(middleware::from_fn(lenient_json_body))
        .layer(axum::Extension(cfg))
        .layer(axum::Extension(spending_lock))
        .layer(axum::Extension(verifier))
        .layer(TraceLayer::new_for_http())
        .layer(DefaultBodyLimit::max(50 * 1024 * 1024))
        .with_state(wallet)
}

/// `POST /arc-callback` — ARC/Arcade status webhook receiving push status
/// updates and (on MINED) the merkle path, straight into wallet storage.
///
/// Authenticated by the per-wallet callback token: ARC-convention
/// `Authorization: Bearer <callback-token>` or an `X-CallbackToken` header
/// (both accepted — broadcaster implementations vary). Returns 404 when no
/// callback token is configured (route effectively disabled).
async fn arc_callback(
    axum::extract::State(wallet): axum::extract::State<WalletState>,
    request: Request,
) -> Response {
    let expected = request
        .extensions()
        .get::<ServerConfig>()
        .and_then(|c| c.callback_token.clone());
    let Some(expected) = expected else {
        return (
            StatusCode::NOT_FOUND,
            Json(json!({"code": "CALLBACK_DISABLED", "message": "no callback token configured"})),
        )
            .into_response();
    };

    let headers = request.headers().clone();
    let provided = headers
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .map(str::to_string)
        .or_else(|| {
            headers
                .get("x-callbacktoken")
                .and_then(|v| v.to_str().ok())
                .map(str::to_string)
        });

    if provided.as_deref() != Some(expected.as_str()) {
        // Arcade authenticates its webhook POSTs as `Authorization: Bearer
        // <X-CallbackToken>` — a 401 here means a token mismatch and Arcade
        // will burn its (~10) retries, then permanently drop the submission.
        tracing::warn!(
            has_authorization = headers.contains_key("authorization"),
            has_x_callbacktoken = headers.contains_key("x-callbacktoken"),
            "arc-callback: rejected POST with invalid/missing callback token"
        );
        return (
            StatusCode::UNAUTHORIZED,
            Json(json!({"code": "UNAUTHORIZED", "message": "invalid or missing callback token"})),
        )
            .into_response();
    }

    let bytes = match axum::body::to_bytes(request.into_body(), 1_000_000).await {
        Ok(b) => b,
        Err(_) => {
            return (
                StatusCode::PAYLOAD_TOO_LARGE,
                Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
            )
                .into_response();
        }
    };
    let payload: serde_json::Value = match serde_json::from_slice(&bytes) {
        Ok(v) => v,
        Err(e) => {
            return (
                StatusCode::BAD_REQUEST,
                Json(json!({"code": "BAD_JSON", "message": e.to_string()})),
            )
                .into_response();
        }
    };

    match crate::arc_ingest::ingest_arc_payload(wallet.storage(), &payload).await {
        Ok(action) => (
            StatusCode::OK,
            Json(json!({"ok": true, "action": format!("{:?}", action)})),
        )
            .into_response(),
        Err(e) => (
            StatusCode::BAD_REQUEST,
            Json(json!({"code": "BAD_PAYLOAD", "message": e.to_string()})),
        )
            .into_response(),
    }
}

/// Browser wallets accept JSON whose strings carry lone UTF-16 surrogate escapes
/// (`"\ud83d"` without its pair); serde does not, so a page whose keyID or data
/// happened to contain one got `400 Failed to parse the request body as JSON:
/// keyID: lone leading surrogate` from this wallet while MetaNet Desktop served
/// the same call (beta soak, 2026-09-02, two logins lost). A browser's encoder
/// turns such a code unit into U+FFFD when it hashes the string, so the same
/// substitution here yields the same bytes the JS wallet would derive from.
pub fn sanitize_lone_surrogates(input: &[u8]) -> std::borrow::Cow<'_, [u8]> {
    if !input.windows(2).any(|w| w == b"\\u") {
        return std::borrow::Cow::Borrowed(input);
    }
    let hex4 = |b: &[u8]| -> Option<u32> {
        if b.len() < 4 {
            return None;
        }
        std::str::from_utf8(&b[..4])
            .ok()
            .and_then(|h| u32::from_str_radix(h, 16).ok())
    };
    let mut out = Vec::with_capacity(input.len());
    let mut i = 0;
    let mut changed = false;
    while i < input.len() {
        if input[i] == b'\\' && i + 1 < input.len() && input[i + 1] == b'u' {
            if let Some(cu) = hex4(&input[i + 2..]) {
                let is_lead = (0xD800..=0xDBFF).contains(&cu);
                let is_trail = (0xDC00..=0xDFFF).contains(&cu);
                if is_lead {
                    let next = &input[i + 6..];
                    let paired = next.len() >= 6
                        && next[0] == b'\\'
                        && next[1] == b'u'
                        && hex4(&next[2..]).is_some_and(|t| (0xDC00..=0xDFFF).contains(&t));
                    if paired {
                        out.extend_from_slice(&input[i..i + 12]);
                        i += 12;
                        continue;
                    }
                    out.extend_from_slice(b"\\ufffd");
                    changed = true;
                    i += 6;
                    continue;
                }
                if is_trail {
                    out.extend_from_slice(b"\\ufffd");
                    changed = true;
                    i += 6;
                    continue;
                }
                out.extend_from_slice(&input[i..i + 6]);
                i += 6;
                continue;
            }
        }
        out.push(input[i]);
        i += 1;
    }
    if changed {
        std::borrow::Cow::Owned(out)
    } else {
        std::borrow::Cow::Borrowed(input)
    }
}

/// Rewrite lone surrogate escapes in JSON request bodies before the `Json`
/// extractors see them (see [`sanitize_lone_surrogates`]).
async fn lenient_json_body(request: Request, next: Next) -> Response {
    let is_json = request
        .headers()
        .get(axum::http::header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .is_some_and(|ct| ct.to_ascii_lowercase().contains("json"));
    if !is_json {
        return next.run(request).await;
    }
    let (parts, body) = request.into_parts();
    let bytes = match axum::body::to_bytes(body, 50 * 1024 * 1024).await {
        Ok(b) => b,
        Err(_) => {
            return (
                StatusCode::PAYLOAD_TOO_LARGE,
                Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
            )
                .into_response();
        }
    };
    let body = match sanitize_lone_surrogates(&bytes) {
        std::borrow::Cow::Borrowed(_) => axum::body::Body::from(bytes),
        std::borrow::Cow::Owned(fixed) => {
            tracing::warn!(
                path = %parts.uri.path(),
                "json body carried a lone UTF-16 surrogate escape; substituted U+FFFD"
            );
            axum::body::Body::from(fixed)
        }
    };
    next.run(Request::from_parts(parts, body)).await
}

pub async fn run(wallet: WalletState, port: u16, config: ServerConfig) -> Result<()> {
    let tls = config.tls.clone();
    let bind_addr = config.bind_addr;
    let app = make_router(wallet, config);
    let addr = SocketAddr::from((bind_addr, port));

    #[cfg(feature = "tls")]
    if let Some(tls_cfg) = tls {
        use axum_server::tls_rustls::RustlsConfig;
        let rustls = RustlsConfig::from_pem_file(&tls_cfg.cert_path, &tls_cfg.key_path).await?;
        tracing::info!("HTTPS server listening on {addr}");
        eprintln!("HTTPS server listening on {addr}");
        axum_server::bind_rustls(addr, rustls)
            .serve(app.into_make_service())
            .await?;
        return Ok(());
    }

    #[cfg(not(feature = "tls"))]
    if tls.is_some() {
        anyhow::bail!("TLS requested but binary was built without `--features tls`");
    }

    tracing::info!("HTTP server listening on {addr}");
    eprintln!("HTTP server listening on {addr}");

    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;

    Ok(())
}

pub fn make_wallet_state(wallet: Wallet<StorageSqlx, Services>) -> WalletState {
    Arc::new(wallet)
}

/// Build a `WalletState` from an existing `Arc<Wallet>`.
///
/// Use this when the caller is already holding an `Arc<Wallet>` and wants the
/// HTTP server to share the *same* wallet instance — same identity key, same
/// balance, same UTXO set. Avoids opening a second `Wallet` handle against
/// the same SQLite DB (which would work via busy_timeout but adds write
/// contention and a second instance of any in-memory caches).
#[allow(dead_code)] // public API for external callers; not used inside this binary
pub fn make_wallet_state_from_arc(wallet: Arc<Wallet<StorageSqlx, Services>>) -> WalletState {
    wallet
}

async fn shutdown_signal() {
    tokio::signal::ctrl_c()
        .await
        .expect("failed to listen for ctrl-c");
    eprintln!("\nShutting down...");
}

/// GET /permission-audit — every permissioned request since start or reset.
async fn permission_audit() -> Json<serde_json::Value> {
    let entries = audit::snapshot();
    Json(json!({ "count": entries.len(), "entries": entries }))
}

/// POST /permission-audit/reset — call immediately before the flow you measure.
async fn permission_audit_reset() -> Json<serde_json::Value> {
    audit::reset();
    Json(json!({ "ok": true }))
}

#[cfg(test)]
mod lenient_json_tests {
    use super::sanitize_lone_surrogates;

    #[test]
    fn lone_lead_and_lone_trail_become_fffd_and_pairs_survive() {
        let lead = br#"{"keyID":"ab \ud83d cd"}"#;
        assert_eq!(
            &*sanitize_lone_surrogates(lead),
            br#"{"keyID":"ab \ufffd cd"}"#
        );
        let trail = br#"{"keyID":"\udc00"}"#;
        assert_eq!(&*sanitize_lone_surrogates(trail), br#"{"keyID":"\ufffd"}"#);
        let pair = br#"{"keyID":"\ud83d\ude00"}"#;
        assert!(matches!(
            sanitize_lone_surrogates(pair),
            std::borrow::Cow::Borrowed(_)
        ));
        let plain = br#"{"keyID":"plain \\u0041"}"#;
        assert!(matches!(
            sanitize_lone_surrogates(plain),
            std::borrow::Cow::Borrowed(_)
        ));
        let fixed = sanitize_lone_surrogates(lead);
        let v: serde_json::Value = serde_json::from_slice(&fixed).unwrap();
        assert_eq!(v["keyID"], "ab \u{fffd} cd");
    }
}