Skip to main content

bsv_wallet_cli/server/
handlers.rs

1use anyhow::{Context, Result};
2use axum::extract::State;
3use axum::http::{HeaderMap, StatusCode};
4use axum::response::{IntoResponse, Response};
5use axum::Json;
6use bsv_sdk::primitives::{to_hex, PublicKey};
7use bsv_sdk::wallet::{
8    // Batch 4: Transaction types (have serde — pass through)
9    AbortActionArgs,
10    AbortActionResult,
11    // Batch 5: Certificate types (have serde — pass through)
12    AcquireCertificateArgs,
13    // Batch 1: Status result types
14    AuthenticatedResult,
15    // Existing endpoint types
16    BasketInsertion,
17    // Core types
18    Counterparty,
19    CreateActionArgs,
20    CreateActionInput,
21    CreateActionOptions,
22    CreateActionOutput,
23    CreateHmacArgs,
24    CreateSignatureArgs,
25    // Batch 3: Crypto arg types (no serde — manual construction)
26    DecryptArgs,
27    // Batch 6: Discovery types (have serde — pass through)
28    DiscoverByAttributesArgs,
29    DiscoverByIdentityKeyArgs,
30    DiscoverCertificatesResult,
31    EncryptArgs,
32    // Batch 2: Header types
33    GetHeaderArgs,
34    GetHeaderResult,
35    GetHeightResult,
36    GetNetworkResult,
37    GetPublicKeyArgs,
38    GetVersionResult,
39    InternalizeActionArgs,
40    InternalizeOutput,
41    ListActionsArgs,
42    ListActionsResult,
43    ListCertificatesArgs,
44    ListCertificatesResult,
45    // Output types
46    ListOutputsArgs,
47    ListOutputsResult,
48    Outpoint,
49    Protocol,
50    ProveCertificateArgs,
51    ProveCertificateResult,
52    RelinquishCertificateArgs,
53    RelinquishCertificateResult,
54    RelinquishOutputArgs,
55    RelinquishOutputResult,
56    // Key linkage types
57    RevealCounterpartyKeyLinkageResult,
58    RevealSpecificKeyLinkageResult,
59    SecurityLevel,
60    SignActionArgs,
61    TrustSelf,
62    VerifyHmacArgs,
63    VerifySignatureArgs,
64    WalletCertificate,
65    WalletInterface,
66    WalletPayment,
67    WalletRevealCounterpartyArgs,
68    WalletRevealSpecificArgs,
69};
70use bsv_wallet_toolbox::{Services, StorageSqlx, Wallet};
71use serde_json::json;
72use std::sync::Arc;
73
74use super::types::*;
75
76pub type WalletState = Arc<Wallet<StorageSqlx, Services>>;
77
78use super::audit;
79
80/// BRC-43 security level as its number. The enum names are the spec's own
81/// meanings: Silent = 0 (never prompts), App = 1 (once per protocol),
82/// Counterparty = 2 (once per protocol AND counterparty).
83fn cp_str(c: &Counterparty) -> String {
84    // The wire spelling, not Rust's Debug. This field exists so a caller can
85    // compare a level-2 counterparty against a manifest entry; `Other(PublicKey
86    // { compressed: "02…" })` cannot be compared with anything.
87    match c {
88        Counterparty::Self_ => "self".to_string(),
89        Counterparty::Anyone => "anyone".to_string(),
90        Counterparty::Other(pk) => pk.to_string().to_lowercase(),
91    }
92}
93
94fn level_num(l: SecurityLevel) -> u8 {
95    match l {
96        SecurityLevel::Silent => 0,
97        SecurityLevel::App => 1,
98        SecurityLevel::Counterparty => 2,
99    }
100}
101
102/// Extract originator from Origin or Originator header
103fn extract_originator(
104    headers: &HeaderMap,
105) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
106    if let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) {
107        if let Ok(url) = url::Url::parse(origin) {
108            if let Some(host) = url.host_str() {
109                return Ok(host.to_string());
110            }
111        }
112    }
113    if let Some(originator) = headers.get("originator").and_then(|v| v.to_str().ok()) {
114        return Ok(originator.to_string());
115    }
116    Err((
117        StatusCode::BAD_REQUEST,
118        Json(json!({"message": "Origin header required"})),
119    ))
120}
121
122/// Classified error response with proper HTTP status codes.
123pub struct AppError {
124    status: StatusCode,
125    code: &'static str,
126    message: String,
127}
128
129impl AppError {
130    /// Classify a wallet error message into an appropriate HTTP status + code.
131    fn classify(msg: &str) -> (StatusCode, &'static str) {
132        // Strip "wallet error: " prefix the SDK wraps errors with
133        let clean = msg.strip_prefix("wallet error: ").unwrap_or(msg);
134
135        if clean.starts_with("Insufficient funds:") || clean.starts_with("insufficient funds:") {
136            (StatusCode::PAYMENT_REQUIRED, "INSUFFICIENT_FUNDS")
137        } else if clean.starts_with("Authentication required")
138            || clean.starts_with("Invalid identity key:")
139        {
140            (StatusCode::UNAUTHORIZED, "AUTHENTICATION_REQUIRED")
141        } else if clean.starts_with("Access denied:") {
142            (StatusCode::FORBIDDEN, "ACCESS_DENIED")
143        } else if clean.starts_with("Entity not found:") || clean.starts_with("not found:") {
144            (StatusCode::NOT_FOUND, "NOT_FOUND")
145        } else if clean.starts_with("Duplicate entity:") || clean.starts_with("Invalid operation:")
146        {
147            (StatusCode::CONFLICT, "CONFLICT")
148        } else if clean.starts_with("Invalid BEEF at byte") {
149            // The toolbox's streaming reader: the offset and the kind of
150            // the invalid byte (never a size or a count).
151            (StatusCode::BAD_REQUEST, "INVALID_BEEF")
152        } else if clean.starts_with("Validation error:")
153            || clean.starts_with("Invalid argument:")
154            || clean.starts_with("Origin header required")
155        {
156            (StatusCode::BAD_REQUEST, "VALIDATION_ERROR")
157        } else if clean.starts_with("Storage error:")
158            || clean.starts_with("Database error:")
159            || clean.starts_with("Internal error:")
160        {
161            (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR")
162        } else if clean.starts_with("Service error:")
163            || clean.starts_with("Network error:")
164            || clean.starts_with("Broadcast failed:")
165        {
166            (StatusCode::BAD_GATEWAY, "SERVICE_ERROR")
167        } else {
168            (StatusCode::BAD_REQUEST, "ERROR")
169        }
170    }
171
172    /// Create from a wallet/SDK error (the most common path).
173    fn from_wallet_error(e: impl std::fmt::Display) -> Self {
174        let message = e.to_string();
175        let (status, code) = Self::classify(&message);
176        Self {
177            status,
178            code,
179            message,
180        }
181    }
182
183    fn message(&self) -> &str {
184        &self.message
185    }
186
187    /// Append an operator-facing recovery hint to the error message.
188    fn with_hint(mut self, hint: &str) -> Self {
189        self.message = format!("{} (hint: {})", self.message, hint);
190        self
191    }
192
193    /// A broadcast no broadcaster took (`sendWithResults` `sending`), absent
194    /// from the broadcaster it was submitted to and from an independent chain
195    /// index after the full probe window, which this wallet then retired (the
196    /// host's act on a template never announced). The retire releases an
197    /// input only where the chain vouches it unspent; the message promises
198    /// nothing more.
199    fn broadcast_rejected(txid: &str) -> Self {
200        Self {
201            status: StatusCode::BAD_GATEWAY,
202            code: "BROADCAST_REJECTED",
203            message: format!(
204                "broadcast rejected: no broadcaster accepted transaction {txid}, and it is \
205                 absent from the broadcaster it was submitted to and from an independent chain \
206                 index after the full probe window; this wallet retired it (failed). An input \
207                 it spent is released only where the chain vouches it unspent; any other stays \
208                 locked and is re-checked."
209            ),
210        }
211    }
212}
213
214impl From<anyhow::Error> for AppError {
215    fn from(err: anyhow::Error) -> Self {
216        Self::from_wallet_error(err)
217    }
218}
219
220impl From<(StatusCode, Json<serde_json::Value>)> for AppError {
221    fn from((status, json): (StatusCode, Json<serde_json::Value>)) -> Self {
222        let message = json
223            .0
224            .get("message")
225            .and_then(|m| m.as_str())
226            .unwrap_or_default()
227            .to_string();
228        let (_, code) = Self::classify(&message);
229        Self {
230            status,
231            code,
232            message,
233        }
234    }
235}
236
237impl IntoResponse for AppError {
238    fn into_response(self) -> Response {
239        tracing::warn!(
240            status = self.status.as_u16(),
241            code = self.code,
242            message = %self.message,
243            "request error"
244        );
245        (
246            self.status,
247            Json(json!({"code": self.code, "message": self.message})),
248        )
249            .into_response()
250    }
251}
252
253/// GET /isAuthenticated
254pub async fn is_authenticated() -> Json<serde_json::Value> {
255    Json(json!({"authenticated": true}))
256}
257
258/// POST /getPublicKey
259pub async fn get_public_key(
260    State(wallet): State<WalletState>,
261    headers: HeaderMap,
262    Json(req): Json<McGetPublicKeyReq>,
263) -> Result<Json<McGetPublicKeyRes>, AppError> {
264    let originator = extract_originator(&headers)?;
265
266    let args = GetPublicKeyArgs {
267        identity_key: req.identity_key,
268        protocol_id: req.protocol_id.map(|v| parse_protocol_id(&v)).transpose()?,
269        key_id: req.key_id,
270        counterparty: req
271            .counterparty
272            .map(|s| parse_counterparty(&s))
273            .transpose()?,
274        for_self: req.for_self,
275    };
276
277    if let Some(p) = args.protocol_id.as_ref() {
278        audit::record(audit::protocol_entry(
279            "getPublicKey",
280            level_num(p.security_level),
281            &p.protocol_name,
282            args.counterparty.as_ref().map(cp_str),
283            args.key_id.clone(),
284            &originator,
285        ));
286    }
287    let result = wallet
288        .get_public_key(args, &originator)
289        .await
290        .map_err(AppError::from_wallet_error)?;
291
292    Ok(Json(McGetPublicKeyRes {
293        public_key: result.public_key,
294    }))
295}
296
297/// POST /createSignature
298pub async fn create_signature(
299    State(wallet): State<WalletState>,
300    headers: HeaderMap,
301    Json(req): Json<McCreateSignatureReq>,
302) -> Result<Json<McCreateSignatureRes>, AppError> {
303    let originator = extract_originator(&headers)?;
304
305    // ts-sdk sends exactly one of `data` (hash-then-sign) or
306    // `hashToDirectlySign` (sign the 32-byte digest as-is).
307    let hash_to_directly_sign = req
308        .hash_to_directly_sign
309        .map(|h| {
310            <[u8; 32]>::try_from(h.as_slice()).map_err(|_| {
311                AppError::from_wallet_error("Validation error: hashToDirectlySign must be 32 bytes")
312            })
313        })
314        .transpose()?;
315    if req.data.is_none() && hash_to_directly_sign.is_none() {
316        return Err(AppError::from_wallet_error(
317            "Validation error: one of data or hashToDirectlySign is required",
318        ));
319    }
320
321    let args = CreateSignatureArgs {
322        data: req.data,
323        hash_to_directly_sign,
324        protocol_id: parse_protocol_id(&req.protocol_id)?,
325        key_id: req.key_id,
326        counterparty: Some(parse_counterparty(&req.counterparty)?),
327    };
328
329    audit::record(audit::protocol_entry(
330        "createSignature",
331        level_num(args.protocol_id.security_level),
332        &args.protocol_id.protocol_name,
333        args.counterparty.as_ref().map(cp_str),
334        Some(args.key_id.clone()),
335        &originator,
336    ));
337    let result = wallet
338        .create_signature(args, &originator)
339        .await
340        .map_err(AppError::from_wallet_error)?;
341
342    Ok(Json(McCreateSignatureRes {
343        signature: result.signature,
344    }))
345}
346
347/// POST /createAction
348///
349/// Spending operations are serialized via `SpendingLock` so that concurrent
350/// requests queue up ("bus stop" pattern) rather than racing on SQLite's write
351/// lock. Non-spending endpoints remain fully concurrent.
352pub async fn create_action(
353    State(wallet): State<WalletState>,
354    axum::Extension(spending_lock): axum::Extension<super::SpendingLock>,
355    axum::Extension(verifier): axum::Extension<crate::broadcast_verify::BroadcastVerifier>,
356    headers: HeaderMap,
357    Json(req): Json<McCreateActionReq>,
358) -> Result<Json<McCreateActionRes>, AppError> {
359    let originator = extract_originator(&headers)?;
360
361    // Whether this request performs an immediate broadcast (vs. no-send /
362    // delayed / external-signing). Only immediate broadcasts are verified.
363    let (opt_no_send, opt_accept_delayed) = req
364        .options
365        .as_ref()
366        .map(|o| {
367            (
368                o.no_send.unwrap_or(false),
369                o.accept_delayed_broadcast.unwrap_or(false),
370            )
371        })
372        .unwrap_or((false, false));
373
374    let outputs = req
375        .outputs
376        .map(|outs| {
377            outs.into_iter()
378                .map(|o| {
379                    Ok(CreateActionOutput {
380                        locking_script: hex::decode(&o.locking_script)?,
381                        satoshis: o.satoshis,
382                        output_description: o.output_description,
383                        basket: o.basket,
384                        custom_instructions: o.custom_instructions,
385                        tags: o.tags,
386                    })
387                })
388                .collect::<Result<Vec<_>>>()
389        })
390        .transpose()?;
391
392    // Map caller-provided explicit inputs (e.g. a covenant spend carrying a full
393    // unlockingScript). An absent/empty list stays `Some(vec![])` so the wallet
394    // auto-selects its own funding UTXOs — the prior behaviour for plain sends.
395    // Before this, the handler discarded `inputs`/`inputBEEF`/`lockTime`, so any
396    // covenant input was silently dropped and the tx was funded from change.
397    let inputs = match req.inputs {
398        Some(reqs) if !reqs.is_empty() => {
399            let mut mapped = Vec::with_capacity(reqs.len());
400            for i in reqs {
401                let outpoint = Outpoint::from_string(&i.outpoint)
402                    .map_err(|e| anyhow::anyhow!("invalid input outpoint '{}': {e}", i.outpoint))?;
403                let unlocking_script = match i.unlocking_script {
404                    Some(h) => Some(hex::decode(&h).context("invalid unlockingScript hex")?),
405                    None => None,
406                };
407                mapped.push(CreateActionInput {
408                    outpoint,
409                    input_description: i.input_description.unwrap_or_else(|| "input".to_string()),
410                    unlocking_script,
411                    unlocking_script_length: i.unlocking_script_length,
412                    sequence_number: i.sequence_number,
413                });
414            }
415            Some(mapped)
416        }
417        _ => Some(vec![]),
418    };
419
420    let args = CreateActionArgs {
421        description: req.description,
422        input_beef: req.input_beef,
423        inputs,
424        outputs,
425        lock_time: req.lock_time,
426        version: None,
427        labels: req.labels,
428        options: req.options.map(|o| CreateActionOptions {
429            accept_delayed_broadcast: o.accept_delayed_broadcast,
430            randomize_outputs: o.randomize_outputs,
431            sign_and_process: o.sign_and_process,
432            no_send: o.no_send,
433            trust_self: match o.trust_self.as_deref() {
434                Some("known") => Some(TrustSelf::Known),
435                _ => None,
436            },
437            ..Default::default()
438        }),
439    };
440
441    let output_count = args.outputs.as_ref().map(|o| o.len()).unwrap_or(0);
442    let total_sats: u64 = args
443        .outputs
444        .as_ref()
445        .map(|o| o.iter().map(|x| x.satoshis).sum())
446        .unwrap_or(0);
447
448    // A real wallet prompts here unless the app declared a BRC-73
449    // `spendingAuthorization` budget — including for a FEE-ONLY action (an
450    // OP_RETURN marker, total_sats == 0) which still costs a miner fee.
451    audit::record(audit::spend_entry(
452        "createAction",
453        total_sats,
454        Some(args.description.clone()),
455        &originator,
456    ));
457
458    // Acquire spending lock — queues behind any in-flight createAction.
459    // Once the previous tx completes (and its change UTXO exists), we proceed.
460    let _guard = spending_lock.lock().await;
461    // A coin whose transaction drew no accepting word on its immediate post,
462    // with no status source holding it, is not selected (`spend_guard`).
463    crate::spend_guard::run(wallet.storage().pool())
464        .await
465        .map_err(|e| AppError::from_wallet_error(format!("Storage error: spend guard: {e}")))?;
466    let result = wallet
467        .create_action(args, &originator)
468        .await
469        .map_err(AppError::from_wallet_error)?;
470    drop(_guard);
471
472    tracing::info!(
473        originator = %originator,
474        outputs = output_count,
475        total_satoshis = total_sats,
476        txid = ?result.txid.as_ref().map(|t| to_hex(t)),
477        "createAction"
478    );
479
480    // Build AtomicBEEF from result.beef (which has ancestors) + txid.
481    // result.tx is raw tx bytes; clients expect AtomicBEEF.
482    let atomic_beef = match (result.beef.as_ref(), result.txid.as_ref()) {
483        (Some(beef_bytes), Some(txid_bytes)) => {
484            let txid_hex = to_hex(txid_bytes);
485            match bsv_sdk::transaction::Beef::from_binary(beef_bytes) {
486                Ok(mut beef) => match beef.to_binary_atomic(&txid_hex) {
487                    Ok(ab) => Some(ab),
488                    Err(e) => {
489                        tracing::warn!(error = %e, "Failed to build AtomicBEEF, falling back to raw tx");
490                        result.tx.clone()
491                    }
492                },
493                Err(e) => {
494                    tracing::warn!(error = %e, "Failed to parse BEEF, falling back to raw tx");
495                    result.tx.clone()
496                }
497            }
498        }
499        _ => result.tx.clone(),
500    };
501
502    // Post-broadcast follow-up (see `broadcast_follow_up`). From the toolbox
503    // 0.7.4 a broadcaster's refusal on the immediate post is a hint, not an
504    // error (bsv-stack-lean #66): `create_action` returns the txid with
505    // `sending`, the refusal on the request's history, the inputs locked.
506    //   * ACCEPTED by the broadcaster (`sendWithResults: unproven`): answer now;
507    //     the presence probe runs in the background and records evidence; it
508    //     never retires a transaction a broadcaster took.
509    //   * No accepting word (`sending`: a refusal, a transient word, no
510    //     broadcaster reached): verify inline. A definitive absence of a
511    //     transaction no broadcaster took retires it (the host's act on
512    //     `built`), 502 BROADCAST_REJECTED; otherwise the answer carries the
513    //     word `built` and the toolbox's hint (`broadcast`).
514    //   * Not broadcast (noSend / delayed / deferred signing): nothing.
515    let disposition =
516        super::broadcast_follow_up::disposition(&result, opt_no_send, opt_accept_delayed);
517    if let Some(txid_bytes) = result.txid.as_ref() {
518        let txid_hex = to_hex(txid_bytes);
519        let probe = verifier.clone();
520        let report_wallet = wallet.clone();
521        // The unproven ancestors the package carried: network presence of
522        // the new tx vouches for them too (the broadcast memory's `seen`).
523        let ancestors: Vec<String> = result
524            .beef
525            .as_deref()
526            .map(|beef| bsv_wallet_toolbox::unproven_ancestors_in_beef(beef, &txid_hex))
527            .unwrap_or_default();
528        let outcome = super::broadcast_follow_up::follow_up(
529            disposition,
530            txid_hex.clone(),
531            move |txid| async move { probe.verify_report(&txid).await },
532            move |txid, report| async move {
533                super::broadcast_follow_up::apply_presence_report(
534                    report_wallet.storage(),
535                    report_wallet.services(),
536                    &txid,
537                    &ancestors,
538                    &report,
539                )
540                .await;
541            },
542        )
543        .await;
544        if outcome == super::broadcast_follow_up::FollowUp::Rejected
545            && super::broadcast_follow_up::tx_status(wallet.storage(), &txid_hex).await
546                == Some("failed".to_string())
547        {
548            return Err(AppError::broadcast_rejected(&txid_hex));
549        }
550    }
551    let broadcast = match result.txid.as_ref() {
552        Some(t) if super::broadcast_follow_up::sending(&result.send_with_results, t) => {
553            super::post_word::post_word(wallet.storage(), &to_hex(t)).await
554        }
555        _ => None,
556    };
557
558    Ok(Json(McCreateActionRes {
559        txid: result.txid.map(|t| to_hex(&t)),
560        tx: atomic_beef,
561        send_with_results: result
562            .send_with_results
563            .and_then(|r| serde_json::to_value(r).ok()),
564        signable_transaction: result.signable_transaction.map(|st| {
565            // The SDK stores reference as Vec<u8> from String.into_bytes().
566            // Reverse that to get the original reference string that
567            // signAction/abortAction can look up in the pending tx cache.
568            let reference =
569                String::from_utf8(st.reference).unwrap_or_else(|e| hex::encode(e.into_bytes()));
570            McSignableTransaction {
571                tx: st.tx,
572                reference,
573            }
574        }),
575        no_send_change: result
576            .no_send_change
577            .and_then(|v| serde_json::to_value(v).ok()),
578        broadcast,
579    }))
580}
581
582/// POST /internalizeAction
583pub async fn internalize_action(
584    State(wallet): State<WalletState>,
585    headers: HeaderMap,
586    Json(req): Json<McInternalizeActionReq>,
587) -> Result<Json<McInternalizeActionRes>, AppError> {
588    let originator = extract_originator(&headers)?;
589
590    let args = InternalizeActionArgs {
591        tx: req.tx,
592        outputs: req
593            .outputs
594            .into_iter()
595            .map(|o| InternalizeOutput {
596                output_index: o.output_index,
597                protocol: o.protocol,
598                payment_remittance: o.payment_remittance.map(|p| WalletPayment {
599                    derivation_prefix: p.derivation_prefix,
600                    derivation_suffix: p.derivation_suffix,
601                    sender_identity_key: p.sender_identity_key,
602                }),
603                insertion_remittance: o.insertion_remittance.map(|i| BasketInsertion {
604                    basket: i.basket,
605                    custom_instructions: i.custom_instructions,
606                    tags: i.tags,
607                }),
608            })
609            .collect(),
610        description: req.description,
611        labels: req.labels,
612        seek_permission: None,
613    };
614
615    let output_count = args.outputs.len();
616    let subject = super::post_word::atomic_subject(&args.tx);
617    let result = wallet
618        .internalize_action(args, &originator)
619        .await
620        .map_err(AppError::from_wallet_error)?;
621
622    tracing::info!(
623        originator = %originator,
624        outputs = output_count,
625        accepted = result.accepted,
626        "internalizeAction"
627    );
628    // From the toolbox 0.7.4 a refusal of the internalized transaction's
629    // post is a hint and the answer is `accepted`; the word says so.
630    let broadcast = match subject {
631        Some(txid) => super::post_word::post_word(wallet.storage(), &txid).await,
632        None => None,
633    };
634
635    Ok(Json(McInternalizeActionRes {
636        accepted: result.accepted,
637        broadcast,
638    }))
639}
640
641// --- Helpers ---
642
643/// Parse [2, "protocol_name"] → Protocol
644fn parse_protocol_id(v: &serde_json::Value) -> Result<Protocol> {
645    let arr = v.as_array().context("protocolID must be an array")?;
646    let level = arr
647        .first()
648        .and_then(|v| v.as_u64())
649        .context("protocolID[0] must be an integer")?;
650    let name = arr
651        .get(1)
652        .and_then(|v| v.as_str())
653        .context("protocolID[1] must be a string")?;
654    Ok(Protocol::new(
655        match level {
656            0 => SecurityLevel::Silent,
657            1 => SecurityLevel::App,
658            2 => SecurityLevel::Counterparty,
659            _ => anyhow::bail!("invalid security level: {level}"),
660        },
661        name,
662    ))
663}
664
665/// Parse "03abc..." → Counterparty::Other(PublicKey)
666fn parse_counterparty(s: &str) -> Result<Counterparty> {
667    match s {
668        "self" => Ok(Counterparty::Self_),
669        "anyone" => Ok(Counterparty::Anyone),
670        hex => Ok(Counterparty::Other(PublicKey::from_hex(hex)?)),
671    }
672}
673
674// =============================================================================
675// Batch 1: Status (GET endpoints)
676// =============================================================================
677
678/// GET /getHeight
679pub async fn get_height(
680    State(wallet): State<WalletState>,
681) -> Result<Json<GetHeightResult>, AppError> {
682    let result = wallet
683        .get_height("localhost")
684        .await
685        .map_err(AppError::from_wallet_error)?;
686    Ok(Json(result))
687}
688
689/// GET /getNetwork
690pub async fn get_network(
691    State(wallet): State<WalletState>,
692) -> Result<Json<GetNetworkResult>, AppError> {
693    let result = wallet
694        .get_network("localhost")
695        .await
696        .map_err(AppError::from_wallet_error)?;
697    Ok(Json(result))
698}
699
700/// GET /getVersion
701pub async fn get_version(
702    State(wallet): State<WalletState>,
703) -> Result<Json<GetVersionResult>, AppError> {
704    let result = wallet
705        .get_version("localhost")
706        .await
707        .map_err(AppError::from_wallet_error)?;
708    Ok(Json(result))
709}
710
711/// GET /waitForAuthentication
712pub async fn wait_for_authentication(
713    State(wallet): State<WalletState>,
714) -> Result<Json<AuthenticatedResult>, AppError> {
715    let result = wallet
716        .wait_for_authentication("localhost")
717        .await
718        .map_err(AppError::from_wallet_error)?;
719    Ok(Json(result))
720}
721
722// =============================================================================
723// Batch 2: Header
724// =============================================================================
725
726/// POST /getHeaderForHeight
727pub async fn get_header_for_height(
728    State(wallet): State<WalletState>,
729    headers: HeaderMap,
730    Json(args): Json<GetHeaderArgs>,
731) -> Result<Json<GetHeaderResult>, AppError> {
732    let originator = extract_originator(&headers)?;
733    let result = wallet
734        .get_header_for_height(args, &originator)
735        .await
736        .map_err(AppError::from_wallet_error)?;
737    Ok(Json(result))
738}
739
740// =============================================================================
741// Batch 3: Crypto
742// =============================================================================
743
744/// POST /verifySignature
745pub async fn verify_signature(
746    State(wallet): State<WalletState>,
747    headers: HeaderMap,
748    Json(req): Json<McVerifySignatureReq>,
749) -> Result<Json<serde_json::Value>, AppError> {
750    let originator = extract_originator(&headers)?;
751    let args = VerifySignatureArgs {
752        data: req.data,
753        hash_to_directly_verify: None,
754        signature: req.signature,
755        protocol_id: parse_protocol_id(&req.protocol_id)?,
756        key_id: req.key_id,
757        counterparty: req
758            .counterparty
759            .map(|s| parse_counterparty(&s))
760            .transpose()?,
761        for_self: req.for_self,
762    };
763    let result = wallet
764        .verify_signature(args, &originator)
765        .await
766        .map_err(AppError::from_wallet_error)?;
767    Ok(Json(json!({ "valid": result.valid })))
768}
769
770/// POST /encrypt
771pub async fn encrypt(
772    State(wallet): State<WalletState>,
773    headers: HeaderMap,
774    Json(req): Json<McEncryptReq>,
775) -> Result<Json<serde_json::Value>, AppError> {
776    let originator = extract_originator(&headers)?;
777    let args = EncryptArgs {
778        plaintext: req.plaintext,
779        protocol_id: parse_protocol_id(&req.protocol_id)?,
780        key_id: req.key_id,
781        counterparty: req
782            .counterparty
783            .map(|s| parse_counterparty(&s))
784            .transpose()?,
785    };
786    audit::record(audit::protocol_entry(
787        "encrypt",
788        level_num(args.protocol_id.security_level),
789        &args.protocol_id.protocol_name,
790        args.counterparty.as_ref().map(cp_str),
791        Some(args.key_id.clone()),
792        &originator,
793    ));
794    let result = wallet
795        .encrypt(args, &originator)
796        .await
797        .map_err(AppError::from_wallet_error)?;
798    Ok(Json(json!({ "ciphertext": result.ciphertext })))
799}
800
801/// POST /decrypt
802pub async fn decrypt(
803    State(wallet): State<WalletState>,
804    headers: HeaderMap,
805    Json(req): Json<McDecryptReq>,
806) -> Result<Json<serde_json::Value>, AppError> {
807    let originator = extract_originator(&headers)?;
808    let args = DecryptArgs {
809        ciphertext: req.ciphertext,
810        protocol_id: parse_protocol_id(&req.protocol_id)?,
811        key_id: req.key_id,
812        counterparty: req
813            .counterparty
814            .map(|s| parse_counterparty(&s))
815            .transpose()?,
816    };
817    audit::record(audit::protocol_entry(
818        "decrypt",
819        level_num(args.protocol_id.security_level),
820        &args.protocol_id.protocol_name,
821        args.counterparty.as_ref().map(cp_str),
822        Some(args.key_id.clone()),
823        &originator,
824    ));
825    let result = wallet
826        .decrypt(args, &originator)
827        .await
828        .map_err(AppError::from_wallet_error)?;
829    Ok(Json(json!({ "plaintext": result.plaintext })))
830}
831
832/// POST /createHmac
833pub async fn create_hmac(
834    State(wallet): State<WalletState>,
835    headers: HeaderMap,
836    Json(req): Json<McCreateHmacReq>,
837) -> Result<Json<serde_json::Value>, AppError> {
838    let originator = extract_originator(&headers)?;
839    let args = CreateHmacArgs {
840        data: req.data,
841        protocol_id: parse_protocol_id(&req.protocol_id)?,
842        key_id: req.key_id,
843        counterparty: req
844            .counterparty
845            .map(|s| parse_counterparty(&s))
846            .transpose()?,
847    };
848    audit::record(audit::protocol_entry(
849        "createHmac",
850        level_num(args.protocol_id.security_level),
851        &args.protocol_id.protocol_name,
852        args.counterparty.as_ref().map(cp_str),
853        Some(args.key_id.clone()),
854        &originator,
855    ));
856    let result = wallet
857        .create_hmac(args, &originator)
858        .await
859        .map_err(AppError::from_wallet_error)?;
860    Ok(Json(json!({ "hmac": result.hmac.to_vec() })))
861}
862
863/// POST /verifyHmac
864pub async fn verify_hmac(
865    State(wallet): State<WalletState>,
866    headers: HeaderMap,
867    Json(req): Json<McVerifyHmacReq>,
868) -> Result<Json<serde_json::Value>, AppError> {
869    let originator = extract_originator(&headers)?;
870    let hmac: [u8; 32] = req
871        .hmac
872        .try_into()
873        .map_err(|_| anyhow::anyhow!("hmac must be exactly 32 bytes"))?;
874    let args = VerifyHmacArgs {
875        data: req.data,
876        hmac,
877        protocol_id: parse_protocol_id(&req.protocol_id)?,
878        key_id: req.key_id,
879        counterparty: req
880            .counterparty
881            .map(|s| parse_counterparty(&s))
882            .transpose()?,
883    };
884    let result = wallet
885        .verify_hmac(args, &originator)
886        .await
887        .map_err(AppError::from_wallet_error)?;
888    Ok(Json(json!({ "valid": result.valid })))
889}
890
891// =============================================================================
892// Batch 4: Transaction Workflow
893// =============================================================================
894
895/// POST /signAction
896pub async fn sign_action(
897    State(wallet): State<WalletState>,
898    headers: HeaderMap,
899    Json(args): Json<SignActionArgs>,
900) -> Result<Json<McSignActionRes>, AppError> {
901    let originator = extract_originator(&headers)?;
902    let reference = args.reference.clone();
903    let result = wallet
904        .sign_action(args, &originator)
905        .await
906        .map_err(AppError::from_wallet_error)?;
907    tracing::info!(originator = %originator, reference = %reference, "signAction");
908    let broadcast = match result.txid.as_ref() {
909        Some(t) if super::broadcast_follow_up::sending(&result.send_with_results, t) => {
910            super::post_word::post_word(wallet.storage(), &to_hex(t)).await
911        }
912        _ => None,
913    };
914    // Same wire shape as create_action: txid as hex, tx (AtomicBEEF) as a
915    // number array — see McSignActionRes for why the toolbox struct cannot be
916    // serialized directly.
917    Ok(Json(McSignActionRes {
918        txid: result.txid.map(|t| to_hex(&t)),
919        tx: result.tx,
920        send_with_results: result
921            .send_with_results
922            .and_then(|r| serde_json::to_value(r).ok()),
923        broadcast,
924    }))
925}
926
927/// POST /abortAction
928pub async fn abort_action(
929    State(wallet): State<WalletState>,
930    headers: HeaderMap,
931    Json(args): Json<AbortActionArgs>,
932) -> Result<Json<AbortActionResult>, AppError> {
933    let originator = extract_originator(&headers)?;
934    // A transaction a broadcaster took is never aborted here, whatever the
935    // caller knows: it stays until a proof, a node verdict or a competitor's
936    // checked proof (bsv-stack-lean #66; #24). One no broadcaster took is
937    // the toolbox's to abort.
938    if let Some(txid) =
939        crate::retire_guard::abort_refused_for(wallet.storage().pool(), &args.reference)
940            .await
941            .map_err(AppError::from_wallet_error)?
942    {
943        return Err(AppError::from_wallet_error(format!(
944            "Invalid operation: a broadcaster took transaction {txid}; it is not aborted. It stays, \
945             re-asked, until a proof, a node verdict or a competitor's checked proof decides it"
946        )));
947    }
948    let result = wallet.abort_action(args, &originator).await.map_err(|e| {
949        // Since toolbox 0.3.60 a broadcast ('unproven' / 'sending') tx is
950        // aborted on the caller's word when the wallet holds NO chain
951        // evidence for it: inputs released, its unproven descendants retired,
952        // the broadcast memory told never to skip it again. It is refused
953        // when the chain index, a proof, or a mined report vouches for the
954        // tx: failing it then would fabricate a double spend.
955        let mut err = AppError::from_wallet_error(e);
956        if err.message().contains("the network has transaction") {
957            err = err.with_hint(
958                "the wallet holds chain evidence for this transaction, so it \
959                 cannot be aborted; if it is a phantom after all, \
960                 `bsv-wallet reconcile-broadcasts --execute` re-checks it \
961                 against the chain index",
962            );
963        }
964        err
965    })?;
966    Ok(Json(result))
967}
968
969/// POST /listActions
970pub async fn list_actions(
971    State(wallet): State<WalletState>,
972    headers: HeaderMap,
973    Json(args): Json<ListActionsArgs>,
974) -> Result<Json<ListActionsResult>, AppError> {
975    let originator = extract_originator(&headers)?;
976    let result = wallet
977        .list_actions(args, &originator)
978        .await
979        .map_err(AppError::from_wallet_error)?;
980    Ok(Json(result))
981}
982
983/// POST /listOutputs
984pub async fn list_outputs(
985    State(wallet): State<WalletState>,
986    headers: HeaderMap,
987    Json(args): Json<ListOutputsArgs>,
988) -> Result<Json<ListOutputsResult>, AppError> {
989    let originator = extract_originator(&headers)?;
990    let result = wallet
991        .list_outputs(args, &originator)
992        .await
993        .map_err(AppError::from_wallet_error)?;
994    Ok(Json(result))
995}
996
997/// POST /relinquishOutput
998///
999/// Acquires SpendingLock because relinquish modifies UTXO state and can race
1000/// with createAction on SQLite writes (previously caused SQLITE_BUSY errors).
1001pub async fn relinquish_output(
1002    State(wallet): State<WalletState>,
1003    axum::Extension(spending_lock): axum::Extension<super::SpendingLock>,
1004    headers: HeaderMap,
1005    Json(args): Json<RelinquishOutputArgs>,
1006) -> Result<Json<RelinquishOutputResult>, AppError> {
1007    let originator = extract_originator(&headers)?;
1008    let _guard = spending_lock.lock().await;
1009    let result = wallet
1010        .relinquish_output(args, &originator)
1011        .await
1012        .map_err(AppError::from_wallet_error)?;
1013    Ok(Json(result))
1014}
1015
1016// =============================================================================
1017// Batch 5: Certificates
1018// =============================================================================
1019
1020/// POST /acquireCertificate
1021pub async fn acquire_certificate(
1022    State(wallet): State<WalletState>,
1023    headers: HeaderMap,
1024    Json(args): Json<AcquireCertificateArgs>,
1025) -> Result<Json<WalletCertificate>, AppError> {
1026    let originator = extract_originator(&headers)?;
1027    let result = wallet
1028        .acquire_certificate(args, &originator)
1029        .await
1030        .map_err(AppError::from_wallet_error)?;
1031    Ok(Json(result))
1032}
1033
1034/// POST /listCertificates
1035pub async fn list_certificates(
1036    State(wallet): State<WalletState>,
1037    headers: HeaderMap,
1038    Json(args): Json<ListCertificatesArgs>,
1039) -> Result<Json<ListCertificatesResult>, AppError> {
1040    let originator = extract_originator(&headers)?;
1041    let result = wallet
1042        .list_certificates(args, &originator)
1043        .await
1044        .map_err(AppError::from_wallet_error)?;
1045    Ok(Json(result))
1046}
1047
1048/// POST /proveCertificate
1049pub async fn prove_certificate(
1050    State(wallet): State<WalletState>,
1051    headers: HeaderMap,
1052    Json(args): Json<ProveCertificateArgs>,
1053) -> Result<Json<ProveCertificateResult>, AppError> {
1054    let originator = extract_originator(&headers)?;
1055    let result = wallet
1056        .prove_certificate(args, &originator)
1057        .await
1058        .map_err(AppError::from_wallet_error)?;
1059    Ok(Json(result))
1060}
1061
1062/// POST /relinquishCertificate
1063pub async fn relinquish_certificate(
1064    State(wallet): State<WalletState>,
1065    headers: HeaderMap,
1066    Json(args): Json<RelinquishCertificateArgs>,
1067) -> Result<Json<RelinquishCertificateResult>, AppError> {
1068    let originator = extract_originator(&headers)?;
1069    let result = wallet
1070        .relinquish_certificate(args, &originator)
1071        .await
1072        .map_err(AppError::from_wallet_error)?;
1073    Ok(Json(result))
1074}
1075
1076// =============================================================================
1077// Batch 6: Discovery + Key Linkage
1078// =============================================================================
1079
1080/// POST /discoverByIdentityKey
1081pub async fn discover_by_identity_key(
1082    State(wallet): State<WalletState>,
1083    headers: HeaderMap,
1084    Json(args): Json<DiscoverByIdentityKeyArgs>,
1085) -> Result<Json<DiscoverCertificatesResult>, AppError> {
1086    let originator = extract_originator(&headers)?;
1087    let result = wallet
1088        .discover_by_identity_key(args, &originator)
1089        .await
1090        .map_err(AppError::from_wallet_error)?;
1091    Ok(Json(result))
1092}
1093
1094/// POST /discoverByAttributes
1095pub async fn discover_by_attributes(
1096    State(wallet): State<WalletState>,
1097    headers: HeaderMap,
1098    Json(args): Json<DiscoverByAttributesArgs>,
1099) -> Result<Json<DiscoverCertificatesResult>, AppError> {
1100    let originator = extract_originator(&headers)?;
1101    let result = wallet
1102        .discover_by_attributes(args, &originator)
1103        .await
1104        .map_err(AppError::from_wallet_error)?;
1105    Ok(Json(result))
1106}
1107
1108/// POST /revealCounterpartyKeyLinkage
1109pub async fn reveal_counterparty_key_linkage(
1110    State(wallet): State<WalletState>,
1111    headers: HeaderMap,
1112    Json(req): Json<McRevealCounterpartyKeyLinkageReq>,
1113) -> Result<Json<RevealCounterpartyKeyLinkageResult>, AppError> {
1114    let originator = extract_originator(&headers)?;
1115    let args = WalletRevealCounterpartyArgs {
1116        counterparty: PublicKey::from_hex(&req.counterparty)
1117            .map_err(|e| anyhow::anyhow!("{}", e))?,
1118        verifier: PublicKey::from_hex(&req.verifier).map_err(|e| anyhow::anyhow!("{}", e))?,
1119        privileged: req.privileged,
1120        privileged_reason: req.privileged_reason,
1121    };
1122    let result = wallet
1123        .reveal_counterparty_key_linkage(args, &originator)
1124        .await
1125        .map_err(AppError::from_wallet_error)?;
1126    Ok(Json(result))
1127}
1128
1129/// POST /revealSpecificKeyLinkage
1130pub async fn reveal_specific_key_linkage(
1131    State(wallet): State<WalletState>,
1132    headers: HeaderMap,
1133    Json(req): Json<McRevealSpecificKeyLinkageReq>,
1134) -> Result<Json<RevealSpecificKeyLinkageResult>, AppError> {
1135    let originator = extract_originator(&headers)?;
1136    let args = WalletRevealSpecificArgs {
1137        counterparty: parse_counterparty(&req.counterparty)?,
1138        verifier: PublicKey::from_hex(&req.verifier).map_err(|e| anyhow::anyhow!("{}", e))?,
1139        protocol_id: parse_protocol_id(&req.protocol_id)?,
1140        key_id: req.key_id,
1141        privileged: req.privileged,
1142        privileged_reason: req.privileged_reason,
1143    };
1144    let result = wallet
1145        .reveal_specific_key_linkage(args, &originator)
1146        .await
1147        .map_err(AppError::from_wallet_error)?;
1148    Ok(Json(result))
1149}