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("Validation error:")
149            || clean.starts_with("Invalid argument:")
150            || clean.starts_with("Origin header required")
151        {
152            (StatusCode::BAD_REQUEST, "VALIDATION_ERROR")
153        } else if clean.starts_with("Storage error:")
154            || clean.starts_with("Database error:")
155            || clean.starts_with("Internal error:")
156        {
157            (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR")
158        } else if clean.starts_with("Transaction broadcast failed") {
159            // The toolbox's DEFINITIVE broadcast rejection (465 fee too low,
160            // Arcade REJECTED / DOUBLE_SPEND_ATTEMPTED, ...): the tx is already
161            // failed and its inputs released. Same code the inline presence
162            // verification uses, so clients handle both the same way.
163            (StatusCode::BAD_GATEWAY, "BROADCAST_REJECTED")
164        } else if clean.starts_with("Service error:")
165            || clean.starts_with("Network error:")
166            || clean.starts_with("Broadcast failed:")
167        {
168            (StatusCode::BAD_GATEWAY, "SERVICE_ERROR")
169        } else {
170            (StatusCode::BAD_REQUEST, "ERROR")
171        }
172    }
173
174    /// Create from a wallet/SDK error (the most common path).
175    fn from_wallet_error(e: impl std::fmt::Display) -> Self {
176        let message = e.to_string();
177        let (status, code) = Self::classify(&message);
178        Self {
179            status,
180            code,
181            message,
182        }
183    }
184
185    fn message(&self) -> &str {
186        &self.message
187    }
188
189    /// Append an operator-facing recovery hint to the error message.
190    fn with_hint(mut self, hint: &str) -> Self {
191        self.message = format!("{} (hint: {})", self.message, hint);
192        self
193    }
194
195    /// A broadcast that `create_action` reported as `Ok`, but which the network
196    /// confirms never landed (silently dropped — typically ARC 465 fee-too-low
197    /// on a deep unconfirmed BEEF). Surfaced as a gateway error so callers see a
198    /// failure instead of a phantom txid.
199    fn broadcast_rejected(txid: &str) -> Self {
200        Self {
201            status: StatusCode::BAD_GATEWAY,
202            code: "BROADCAST_REJECTED",
203            message: format!(
204                "broadcast rejected: transaction {txid} is absent from BOTH the broadcaster \
205                 it was submitted to AND an independent chain index, after the full probe \
206                 window. The broadcaster dropped it — most likely error 465 \"fee too low\" \
207                 on a deep unconfirmed BEEF. The funds were NOT sent."
208            ),
209        }
210    }
211}
212
213impl From<anyhow::Error> for AppError {
214    fn from(err: anyhow::Error) -> Self {
215        Self::from_wallet_error(err)
216    }
217}
218
219impl From<(StatusCode, Json<serde_json::Value>)> for AppError {
220    fn from((status, json): (StatusCode, Json<serde_json::Value>)) -> Self {
221        let message = json
222            .0
223            .get("message")
224            .and_then(|m| m.as_str())
225            .unwrap_or_default()
226            .to_string();
227        let (_, code) = Self::classify(&message);
228        Self {
229            status,
230            code,
231            message,
232        }
233    }
234}
235
236impl IntoResponse for AppError {
237    fn into_response(self) -> Response {
238        tracing::warn!(
239            status = self.status.as_u16(),
240            code = self.code,
241            message = %self.message,
242            "request error"
243        );
244        (
245            self.status,
246            Json(json!({"code": self.code, "message": self.message})),
247        )
248            .into_response()
249    }
250}
251
252/// GET /isAuthenticated
253pub async fn is_authenticated() -> Json<serde_json::Value> {
254    Json(json!({"authenticated": true}))
255}
256
257/// POST /getPublicKey
258pub async fn get_public_key(
259    State(wallet): State<WalletState>,
260    headers: HeaderMap,
261    Json(req): Json<McGetPublicKeyReq>,
262) -> Result<Json<McGetPublicKeyRes>, AppError> {
263    let originator = extract_originator(&headers)?;
264
265    let args = GetPublicKeyArgs {
266        identity_key: req.identity_key,
267        protocol_id: req.protocol_id.map(|v| parse_protocol_id(&v)).transpose()?,
268        key_id: req.key_id,
269        counterparty: req
270            .counterparty
271            .map(|s| parse_counterparty(&s))
272            .transpose()?,
273        for_self: req.for_self,
274    };
275
276    if let Some(p) = args.protocol_id.as_ref() {
277        audit::record(audit::protocol_entry(
278            "getPublicKey",
279            level_num(p.security_level),
280            &p.protocol_name,
281            args.counterparty.as_ref().map(cp_str),
282            args.key_id.clone(),
283            &originator,
284        ));
285    }
286    let result = wallet
287        .get_public_key(args, &originator)
288        .await
289        .map_err(AppError::from_wallet_error)?;
290
291    Ok(Json(McGetPublicKeyRes {
292        public_key: result.public_key,
293    }))
294}
295
296/// POST /createSignature
297pub async fn create_signature(
298    State(wallet): State<WalletState>,
299    headers: HeaderMap,
300    Json(req): Json<McCreateSignatureReq>,
301) -> Result<Json<McCreateSignatureRes>, AppError> {
302    let originator = extract_originator(&headers)?;
303
304    // ts-sdk sends exactly one of `data` (hash-then-sign) or
305    // `hashToDirectlySign` (sign the 32-byte digest as-is).
306    let hash_to_directly_sign = req
307        .hash_to_directly_sign
308        .map(|h| {
309            <[u8; 32]>::try_from(h.as_slice()).map_err(|_| {
310                AppError::from_wallet_error("Validation error: hashToDirectlySign must be 32 bytes")
311            })
312        })
313        .transpose()?;
314    if req.data.is_none() && hash_to_directly_sign.is_none() {
315        return Err(AppError::from_wallet_error(
316            "Validation error: one of data or hashToDirectlySign is required",
317        ));
318    }
319
320    let args = CreateSignatureArgs {
321        data: req.data,
322        hash_to_directly_sign,
323        protocol_id: parse_protocol_id(&req.protocol_id)?,
324        key_id: req.key_id,
325        counterparty: Some(parse_counterparty(&req.counterparty)?),
326    };
327
328    audit::record(audit::protocol_entry(
329        "createSignature",
330        level_num(args.protocol_id.security_level),
331        &args.protocol_id.protocol_name,
332        args.counterparty.as_ref().map(cp_str),
333        Some(args.key_id.clone()),
334        &originator,
335    ));
336    let result = wallet
337        .create_signature(args, &originator)
338        .await
339        .map_err(AppError::from_wallet_error)?;
340
341    Ok(Json(McCreateSignatureRes {
342        signature: result.signature,
343    }))
344}
345
346/// POST /createAction
347///
348/// Spending operations are serialized via `SpendingLock` so that concurrent
349/// requests queue up ("bus stop" pattern) rather than racing on SQLite's write
350/// lock. Non-spending endpoints remain fully concurrent.
351pub async fn create_action(
352    State(wallet): State<WalletState>,
353    axum::Extension(spending_lock): axum::Extension<super::SpendingLock>,
354    axum::Extension(verifier): axum::Extension<crate::broadcast_verify::BroadcastVerifier>,
355    headers: HeaderMap,
356    Json(req): Json<McCreateActionReq>,
357) -> Result<Json<McCreateActionRes>, AppError> {
358    let originator = extract_originator(&headers)?;
359
360    // Whether this request performs an immediate broadcast (vs. no-send /
361    // delayed / external-signing). Only immediate broadcasts are verified.
362    let (opt_no_send, opt_accept_delayed) = req
363        .options
364        .as_ref()
365        .map(|o| {
366            (
367                o.no_send.unwrap_or(false),
368                o.accept_delayed_broadcast.unwrap_or(false),
369            )
370        })
371        .unwrap_or((false, false));
372
373    let outputs = req
374        .outputs
375        .map(|outs| {
376            outs.into_iter()
377                .map(|o| {
378                    Ok(CreateActionOutput {
379                        locking_script: hex::decode(&o.locking_script)?,
380                        satoshis: o.satoshis,
381                        output_description: o.output_description,
382                        basket: o.basket,
383                        custom_instructions: o.custom_instructions,
384                        tags: o.tags,
385                    })
386                })
387                .collect::<Result<Vec<_>>>()
388        })
389        .transpose()?;
390
391    // Map caller-provided explicit inputs (e.g. a covenant spend carrying a full
392    // unlockingScript). An absent/empty list stays `Some(vec![])` so the wallet
393    // auto-selects its own funding UTXOs — the prior behaviour for plain sends.
394    // Before this, the handler discarded `inputs`/`inputBEEF`/`lockTime`, so any
395    // covenant input was silently dropped and the tx was funded from change.
396    let inputs = match req.inputs {
397        Some(reqs) if !reqs.is_empty() => {
398            let mut mapped = Vec::with_capacity(reqs.len());
399            for i in reqs {
400                let outpoint = Outpoint::from_string(&i.outpoint)
401                    .map_err(|e| anyhow::anyhow!("invalid input outpoint '{}': {e}", i.outpoint))?;
402                let unlocking_script = match i.unlocking_script {
403                    Some(h) => Some(hex::decode(&h).context("invalid unlockingScript hex")?),
404                    None => None,
405                };
406                mapped.push(CreateActionInput {
407                    outpoint,
408                    input_description: i.input_description.unwrap_or_else(|| "input".to_string()),
409                    unlocking_script,
410                    unlocking_script_length: i.unlocking_script_length,
411                    sequence_number: i.sequence_number,
412                });
413            }
414            Some(mapped)
415        }
416        _ => Some(vec![]),
417    };
418
419    let args = CreateActionArgs {
420        description: req.description,
421        input_beef: req.input_beef,
422        inputs,
423        outputs,
424        lock_time: req.lock_time,
425        version: None,
426        labels: req.labels,
427        options: req.options.map(|o| CreateActionOptions {
428            accept_delayed_broadcast: o.accept_delayed_broadcast,
429            randomize_outputs: o.randomize_outputs,
430            sign_and_process: o.sign_and_process,
431            no_send: o.no_send,
432            trust_self: match o.trust_self.as_deref() {
433                Some("known") => Some(TrustSelf::Known),
434                _ => None,
435            },
436            ..Default::default()
437        }),
438    };
439
440    let output_count = args.outputs.as_ref().map(|o| o.len()).unwrap_or(0);
441    let total_sats: u64 = args
442        .outputs
443        .as_ref()
444        .map(|o| o.iter().map(|x| x.satoshis).sum())
445        .unwrap_or(0);
446
447    // A real wallet prompts here unless the app declared a BRC-73
448    // `spendingAuthorization` budget — including for a FEE-ONLY action (an
449    // OP_RETURN marker, total_sats == 0) which still costs a miner fee.
450    audit::record(audit::spend_entry(
451        "createAction",
452        total_sats,
453        Some(args.description.clone()),
454        &originator,
455    ));
456
457    // Acquire spending lock — queues behind any in-flight createAction.
458    // Once the previous tx completes (and its change UTXO exists), we proceed.
459    let _guard = spending_lock.lock().await;
460    let result = wallet
461        .create_action(args, &originator)
462        .await
463        .map_err(AppError::from_wallet_error)?;
464    drop(_guard);
465
466    tracing::info!(
467        originator = %originator,
468        outputs = output_count,
469        total_satoshis = total_sats,
470        txid = ?result.txid.as_ref().map(|t| to_hex(t)),
471        "createAction"
472    );
473
474    // Build AtomicBEEF from result.beef (which has ancestors) + txid.
475    // result.tx is raw tx bytes; clients expect AtomicBEEF.
476    let atomic_beef = match (result.beef.as_ref(), result.txid.as_ref()) {
477        (Some(beef_bytes), Some(txid_bytes)) => {
478            let txid_hex = to_hex(txid_bytes);
479            match bsv_sdk::transaction::Beef::from_binary(beef_bytes) {
480                Ok(mut beef) => match beef.to_binary_atomic(&txid_hex) {
481                    Ok(ab) => Some(ab),
482                    Err(e) => {
483                        tracing::warn!(error = %e, "Failed to build AtomicBEEF, falling back to raw tx");
484                        result.tx.clone()
485                    }
486                },
487                Err(e) => {
488                    tracing::warn!(error = %e, "Failed to parse BEEF, falling back to raw tx");
489                    result.tx.clone()
490                }
491            }
492        }
493        _ => result.tx.clone(),
494    };
495
496    // Post-broadcast follow-up (see `broadcast_follow_up`). A definitive
497    // rejection (465 fee too low, Arcade REJECTED, ...) has already surfaced
498    // above as an error from `create_action` — the toolbox fails the tx and
499    // releases its inputs. What remains:
500    //   * ACCEPTED by the broadcaster (`sendWithResults: unproven`): answer now;
501    //     the presence probe runs in the background and, on a definitive
502    //     absence, retires the tx through the toolbox's RELEASE-RULE path.
503    //   * Ambiguous (a transient service fault, `sending`): verify inline —
504    //     the one case where the client must not be told "sent" on a coin flip.
505    //     A definitive absence → tx retired, 502 BROADCAST_REJECTED.
506    //   * Not broadcast (noSend / delayed / deferred signing): nothing.
507    let disposition =
508        super::broadcast_follow_up::disposition(&result, opt_no_send, opt_accept_delayed);
509    if let Some(txid_bytes) = result.txid.as_ref() {
510        let txid_hex = to_hex(txid_bytes);
511        let probe = verifier.clone();
512        let retire_wallet = wallet.clone();
513        let outcome = super::broadcast_follow_up::follow_up(
514            disposition,
515            txid_hex.clone(),
516            move |txid| async move { probe.verify(&txid).await },
517            move |txid| async move {
518                super::broadcast_follow_up::retire_rejected_broadcast(
519                    retire_wallet.storage(),
520                    retire_wallet.services(),
521                    &txid,
522                )
523                .await;
524            },
525        )
526        .await;
527        if outcome == super::broadcast_follow_up::FollowUp::Rejected {
528            return Err(AppError::broadcast_rejected(&txid_hex));
529        }
530    }
531
532    Ok(Json(McCreateActionRes {
533        txid: result.txid.map(|t| to_hex(&t)),
534        tx: atomic_beef,
535        send_with_results: result
536            .send_with_results
537            .and_then(|r| serde_json::to_value(r).ok()),
538        signable_transaction: result.signable_transaction.map(|st| {
539            // The SDK stores reference as Vec<u8> from String.into_bytes().
540            // Reverse that to get the original reference string that
541            // signAction/abortAction can look up in the pending tx cache.
542            let reference =
543                String::from_utf8(st.reference).unwrap_or_else(|e| hex::encode(e.into_bytes()));
544            McSignableTransaction {
545                tx: st.tx,
546                reference,
547            }
548        }),
549        no_send_change: result
550            .no_send_change
551            .and_then(|v| serde_json::to_value(v).ok()),
552    }))
553}
554
555/// POST /internalizeAction
556pub async fn internalize_action(
557    State(wallet): State<WalletState>,
558    headers: HeaderMap,
559    Json(req): Json<McInternalizeActionReq>,
560) -> Result<Json<McInternalizeActionRes>, AppError> {
561    let originator = extract_originator(&headers)?;
562
563    let args = InternalizeActionArgs {
564        tx: req.tx,
565        outputs: req
566            .outputs
567            .into_iter()
568            .map(|o| InternalizeOutput {
569                output_index: o.output_index,
570                protocol: o.protocol,
571                payment_remittance: o.payment_remittance.map(|p| WalletPayment {
572                    derivation_prefix: p.derivation_prefix,
573                    derivation_suffix: p.derivation_suffix,
574                    sender_identity_key: p.sender_identity_key,
575                }),
576                insertion_remittance: o.insertion_remittance.map(|i| BasketInsertion {
577                    basket: i.basket,
578                    custom_instructions: i.custom_instructions,
579                    tags: i.tags,
580                }),
581            })
582            .collect(),
583        description: req.description,
584        labels: req.labels,
585        seek_permission: None,
586    };
587
588    let output_count = args.outputs.len();
589    let result = wallet
590        .internalize_action(args, &originator)
591        .await
592        .map_err(AppError::from_wallet_error)?;
593
594    tracing::info!(
595        originator = %originator,
596        outputs = output_count,
597        accepted = result.accepted,
598        "internalizeAction"
599    );
600
601    Ok(Json(McInternalizeActionRes {
602        accepted: result.accepted,
603    }))
604}
605
606// --- Helpers ---
607
608/// Parse [2, "protocol_name"] → Protocol
609fn parse_protocol_id(v: &serde_json::Value) -> Result<Protocol> {
610    let arr = v.as_array().context("protocolID must be an array")?;
611    let level = arr
612        .first()
613        .and_then(|v| v.as_u64())
614        .context("protocolID[0] must be an integer")?;
615    let name = arr
616        .get(1)
617        .and_then(|v| v.as_str())
618        .context("protocolID[1] must be a string")?;
619    Ok(Protocol::new(
620        match level {
621            0 => SecurityLevel::Silent,
622            1 => SecurityLevel::App,
623            2 => SecurityLevel::Counterparty,
624            _ => anyhow::bail!("invalid security level: {level}"),
625        },
626        name,
627    ))
628}
629
630/// Parse "03abc..." → Counterparty::Other(PublicKey)
631fn parse_counterparty(s: &str) -> Result<Counterparty> {
632    match s {
633        "self" => Ok(Counterparty::Self_),
634        "anyone" => Ok(Counterparty::Anyone),
635        hex => Ok(Counterparty::Other(PublicKey::from_hex(hex)?)),
636    }
637}
638
639// =============================================================================
640// Batch 1: Status (GET endpoints)
641// =============================================================================
642
643/// GET /getHeight
644pub async fn get_height(
645    State(wallet): State<WalletState>,
646) -> Result<Json<GetHeightResult>, AppError> {
647    let result = wallet
648        .get_height("localhost")
649        .await
650        .map_err(AppError::from_wallet_error)?;
651    Ok(Json(result))
652}
653
654/// GET /getNetwork
655pub async fn get_network(
656    State(wallet): State<WalletState>,
657) -> Result<Json<GetNetworkResult>, AppError> {
658    let result = wallet
659        .get_network("localhost")
660        .await
661        .map_err(AppError::from_wallet_error)?;
662    Ok(Json(result))
663}
664
665/// GET /getVersion
666pub async fn get_version(
667    State(wallet): State<WalletState>,
668) -> Result<Json<GetVersionResult>, AppError> {
669    let result = wallet
670        .get_version("localhost")
671        .await
672        .map_err(AppError::from_wallet_error)?;
673    Ok(Json(result))
674}
675
676/// GET /waitForAuthentication
677pub async fn wait_for_authentication(
678    State(wallet): State<WalletState>,
679) -> Result<Json<AuthenticatedResult>, AppError> {
680    let result = wallet
681        .wait_for_authentication("localhost")
682        .await
683        .map_err(AppError::from_wallet_error)?;
684    Ok(Json(result))
685}
686
687// =============================================================================
688// Batch 2: Header
689// =============================================================================
690
691/// POST /getHeaderForHeight
692pub async fn get_header_for_height(
693    State(wallet): State<WalletState>,
694    headers: HeaderMap,
695    Json(args): Json<GetHeaderArgs>,
696) -> Result<Json<GetHeaderResult>, AppError> {
697    let originator = extract_originator(&headers)?;
698    let result = wallet
699        .get_header_for_height(args, &originator)
700        .await
701        .map_err(AppError::from_wallet_error)?;
702    Ok(Json(result))
703}
704
705// =============================================================================
706// Batch 3: Crypto
707// =============================================================================
708
709/// POST /verifySignature
710pub async fn verify_signature(
711    State(wallet): State<WalletState>,
712    headers: HeaderMap,
713    Json(req): Json<McVerifySignatureReq>,
714) -> Result<Json<serde_json::Value>, AppError> {
715    let originator = extract_originator(&headers)?;
716    let args = VerifySignatureArgs {
717        data: req.data,
718        hash_to_directly_verify: None,
719        signature: req.signature,
720        protocol_id: parse_protocol_id(&req.protocol_id)?,
721        key_id: req.key_id,
722        counterparty: req
723            .counterparty
724            .map(|s| parse_counterparty(&s))
725            .transpose()?,
726        for_self: req.for_self,
727    };
728    let result = wallet
729        .verify_signature(args, &originator)
730        .await
731        .map_err(AppError::from_wallet_error)?;
732    Ok(Json(json!({ "valid": result.valid })))
733}
734
735/// POST /encrypt
736pub async fn encrypt(
737    State(wallet): State<WalletState>,
738    headers: HeaderMap,
739    Json(req): Json<McEncryptReq>,
740) -> Result<Json<serde_json::Value>, AppError> {
741    let originator = extract_originator(&headers)?;
742    let args = EncryptArgs {
743        plaintext: req.plaintext,
744        protocol_id: parse_protocol_id(&req.protocol_id)?,
745        key_id: req.key_id,
746        counterparty: req
747            .counterparty
748            .map(|s| parse_counterparty(&s))
749            .transpose()?,
750    };
751    audit::record(audit::protocol_entry(
752        "encrypt",
753        level_num(args.protocol_id.security_level),
754        &args.protocol_id.protocol_name,
755        args.counterparty.as_ref().map(cp_str),
756        Some(args.key_id.clone()),
757        &originator,
758    ));
759    let result = wallet
760        .encrypt(args, &originator)
761        .await
762        .map_err(AppError::from_wallet_error)?;
763    Ok(Json(json!({ "ciphertext": result.ciphertext })))
764}
765
766/// POST /decrypt
767pub async fn decrypt(
768    State(wallet): State<WalletState>,
769    headers: HeaderMap,
770    Json(req): Json<McDecryptReq>,
771) -> Result<Json<serde_json::Value>, AppError> {
772    let originator = extract_originator(&headers)?;
773    let args = DecryptArgs {
774        ciphertext: req.ciphertext,
775        protocol_id: parse_protocol_id(&req.protocol_id)?,
776        key_id: req.key_id,
777        counterparty: req
778            .counterparty
779            .map(|s| parse_counterparty(&s))
780            .transpose()?,
781    };
782    audit::record(audit::protocol_entry(
783        "decrypt",
784        level_num(args.protocol_id.security_level),
785        &args.protocol_id.protocol_name,
786        args.counterparty.as_ref().map(cp_str),
787        Some(args.key_id.clone()),
788        &originator,
789    ));
790    let result = wallet
791        .decrypt(args, &originator)
792        .await
793        .map_err(AppError::from_wallet_error)?;
794    Ok(Json(json!({ "plaintext": result.plaintext })))
795}
796
797/// POST /createHmac
798pub async fn create_hmac(
799    State(wallet): State<WalletState>,
800    headers: HeaderMap,
801    Json(req): Json<McCreateHmacReq>,
802) -> Result<Json<serde_json::Value>, AppError> {
803    let originator = extract_originator(&headers)?;
804    let args = CreateHmacArgs {
805        data: req.data,
806        protocol_id: parse_protocol_id(&req.protocol_id)?,
807        key_id: req.key_id,
808        counterparty: req
809            .counterparty
810            .map(|s| parse_counterparty(&s))
811            .transpose()?,
812    };
813    audit::record(audit::protocol_entry(
814        "createHmac",
815        level_num(args.protocol_id.security_level),
816        &args.protocol_id.protocol_name,
817        args.counterparty.as_ref().map(cp_str),
818        Some(args.key_id.clone()),
819        &originator,
820    ));
821    let result = wallet
822        .create_hmac(args, &originator)
823        .await
824        .map_err(AppError::from_wallet_error)?;
825    Ok(Json(json!({ "hmac": result.hmac.to_vec() })))
826}
827
828/// POST /verifyHmac
829pub async fn verify_hmac(
830    State(wallet): State<WalletState>,
831    headers: HeaderMap,
832    Json(req): Json<McVerifyHmacReq>,
833) -> Result<Json<serde_json::Value>, AppError> {
834    let originator = extract_originator(&headers)?;
835    let hmac: [u8; 32] = req
836        .hmac
837        .try_into()
838        .map_err(|_| anyhow::anyhow!("hmac must be exactly 32 bytes"))?;
839    let args = VerifyHmacArgs {
840        data: req.data,
841        hmac,
842        protocol_id: parse_protocol_id(&req.protocol_id)?,
843        key_id: req.key_id,
844        counterparty: req
845            .counterparty
846            .map(|s| parse_counterparty(&s))
847            .transpose()?,
848    };
849    let result = wallet
850        .verify_hmac(args, &originator)
851        .await
852        .map_err(AppError::from_wallet_error)?;
853    Ok(Json(json!({ "valid": result.valid })))
854}
855
856// =============================================================================
857// Batch 4: Transaction Workflow
858// =============================================================================
859
860/// POST /signAction
861pub async fn sign_action(
862    State(wallet): State<WalletState>,
863    headers: HeaderMap,
864    Json(args): Json<SignActionArgs>,
865) -> Result<Json<McSignActionRes>, AppError> {
866    let originator = extract_originator(&headers)?;
867    let reference = args.reference.clone();
868    let result = wallet
869        .sign_action(args, &originator)
870        .await
871        .map_err(AppError::from_wallet_error)?;
872    tracing::info!(originator = %originator, reference = %reference, "signAction");
873    // Same wire shape as create_action: txid as hex, tx (AtomicBEEF) as a
874    // number array — see McSignActionRes for why the toolbox struct cannot be
875    // serialized directly.
876    Ok(Json(McSignActionRes {
877        txid: result.txid.map(|t| to_hex(&t)),
878        tx: result.tx,
879        send_with_results: result
880            .send_with_results
881            .and_then(|r| serde_json::to_value(r).ok()),
882    }))
883}
884
885/// POST /abortAction
886pub async fn abort_action(
887    State(wallet): State<WalletState>,
888    headers: HeaderMap,
889    Json(args): Json<AbortActionArgs>,
890) -> Result<Json<AbortActionResult>, AppError> {
891    let originator = extract_originator(&headers)?;
892    let result = wallet.abort_action(args, &originator).await.map_err(|e| {
893        // The toolbox (correctly) refuses to abort a broadcast tx blindly — it
894        // could be on-chain, and failing it here would fabricate a phantom
895        // double-spend. The sanctioned recovery for a broadcast-lie is the
896        // chain-checked sweep: point the operator at it.
897        let mut err = AppError::from_wallet_error(e);
898        if err.message().contains("unproven") {
899            err = err.with_hint(
900                "an 'unproven' tx may already be on-chain; if its broadcast \
901                 never landed, run `bsv-wallet cleanup-abandoned --execute` \
902                 (verifies chain absence before restoring inputs) or wait for \
903                 the daemon's auto-reconcile tick",
904            );
905        }
906        err
907    })?;
908    Ok(Json(result))
909}
910
911/// POST /listActions
912pub async fn list_actions(
913    State(wallet): State<WalletState>,
914    headers: HeaderMap,
915    Json(args): Json<ListActionsArgs>,
916) -> Result<Json<ListActionsResult>, AppError> {
917    let originator = extract_originator(&headers)?;
918    let result = wallet
919        .list_actions(args, &originator)
920        .await
921        .map_err(AppError::from_wallet_error)?;
922    Ok(Json(result))
923}
924
925/// POST /listOutputs
926pub async fn list_outputs(
927    State(wallet): State<WalletState>,
928    headers: HeaderMap,
929    Json(args): Json<ListOutputsArgs>,
930) -> Result<Json<ListOutputsResult>, AppError> {
931    let originator = extract_originator(&headers)?;
932    let result = wallet
933        .list_outputs(args, &originator)
934        .await
935        .map_err(AppError::from_wallet_error)?;
936    Ok(Json(result))
937}
938
939/// POST /relinquishOutput
940///
941/// Acquires SpendingLock because relinquish modifies UTXO state and can race
942/// with createAction on SQLite writes (previously caused SQLITE_BUSY errors).
943pub async fn relinquish_output(
944    State(wallet): State<WalletState>,
945    axum::Extension(spending_lock): axum::Extension<super::SpendingLock>,
946    headers: HeaderMap,
947    Json(args): Json<RelinquishOutputArgs>,
948) -> Result<Json<RelinquishOutputResult>, AppError> {
949    let originator = extract_originator(&headers)?;
950    let _guard = spending_lock.lock().await;
951    let result = wallet
952        .relinquish_output(args, &originator)
953        .await
954        .map_err(AppError::from_wallet_error)?;
955    Ok(Json(result))
956}
957
958// =============================================================================
959// Batch 5: Certificates
960// =============================================================================
961
962/// POST /acquireCertificate
963pub async fn acquire_certificate(
964    State(wallet): State<WalletState>,
965    headers: HeaderMap,
966    Json(args): Json<AcquireCertificateArgs>,
967) -> Result<Json<WalletCertificate>, AppError> {
968    let originator = extract_originator(&headers)?;
969    let result = wallet
970        .acquire_certificate(args, &originator)
971        .await
972        .map_err(AppError::from_wallet_error)?;
973    Ok(Json(result))
974}
975
976/// POST /listCertificates
977pub async fn list_certificates(
978    State(wallet): State<WalletState>,
979    headers: HeaderMap,
980    Json(args): Json<ListCertificatesArgs>,
981) -> Result<Json<ListCertificatesResult>, AppError> {
982    let originator = extract_originator(&headers)?;
983    let result = wallet
984        .list_certificates(args, &originator)
985        .await
986        .map_err(AppError::from_wallet_error)?;
987    Ok(Json(result))
988}
989
990/// POST /proveCertificate
991pub async fn prove_certificate(
992    State(wallet): State<WalletState>,
993    headers: HeaderMap,
994    Json(args): Json<ProveCertificateArgs>,
995) -> Result<Json<ProveCertificateResult>, AppError> {
996    let originator = extract_originator(&headers)?;
997    let result = wallet
998        .prove_certificate(args, &originator)
999        .await
1000        .map_err(AppError::from_wallet_error)?;
1001    Ok(Json(result))
1002}
1003
1004/// POST /relinquishCertificate
1005pub async fn relinquish_certificate(
1006    State(wallet): State<WalletState>,
1007    headers: HeaderMap,
1008    Json(args): Json<RelinquishCertificateArgs>,
1009) -> Result<Json<RelinquishCertificateResult>, AppError> {
1010    let originator = extract_originator(&headers)?;
1011    let result = wallet
1012        .relinquish_certificate(args, &originator)
1013        .await
1014        .map_err(AppError::from_wallet_error)?;
1015    Ok(Json(result))
1016}
1017
1018// =============================================================================
1019// Batch 6: Discovery + Key Linkage
1020// =============================================================================
1021
1022/// POST /discoverByIdentityKey
1023pub async fn discover_by_identity_key(
1024    State(wallet): State<WalletState>,
1025    headers: HeaderMap,
1026    Json(args): Json<DiscoverByIdentityKeyArgs>,
1027) -> Result<Json<DiscoverCertificatesResult>, AppError> {
1028    let originator = extract_originator(&headers)?;
1029    let result = wallet
1030        .discover_by_identity_key(args, &originator)
1031        .await
1032        .map_err(AppError::from_wallet_error)?;
1033    Ok(Json(result))
1034}
1035
1036/// POST /discoverByAttributes
1037pub async fn discover_by_attributes(
1038    State(wallet): State<WalletState>,
1039    headers: HeaderMap,
1040    Json(args): Json<DiscoverByAttributesArgs>,
1041) -> Result<Json<DiscoverCertificatesResult>, AppError> {
1042    let originator = extract_originator(&headers)?;
1043    let result = wallet
1044        .discover_by_attributes(args, &originator)
1045        .await
1046        .map_err(AppError::from_wallet_error)?;
1047    Ok(Json(result))
1048}
1049
1050/// POST /revealCounterpartyKeyLinkage
1051pub async fn reveal_counterparty_key_linkage(
1052    State(wallet): State<WalletState>,
1053    headers: HeaderMap,
1054    Json(req): Json<McRevealCounterpartyKeyLinkageReq>,
1055) -> Result<Json<RevealCounterpartyKeyLinkageResult>, AppError> {
1056    let originator = extract_originator(&headers)?;
1057    let args = WalletRevealCounterpartyArgs {
1058        counterparty: PublicKey::from_hex(&req.counterparty)
1059            .map_err(|e| anyhow::anyhow!("{}", e))?,
1060        verifier: PublicKey::from_hex(&req.verifier).map_err(|e| anyhow::anyhow!("{}", e))?,
1061        privileged: req.privileged,
1062        privileged_reason: req.privileged_reason,
1063    };
1064    let result = wallet
1065        .reveal_counterparty_key_linkage(args, &originator)
1066        .await
1067        .map_err(AppError::from_wallet_error)?;
1068    Ok(Json(result))
1069}
1070
1071/// POST /revealSpecificKeyLinkage
1072pub async fn reveal_specific_key_linkage(
1073    State(wallet): State<WalletState>,
1074    headers: HeaderMap,
1075    Json(req): Json<McRevealSpecificKeyLinkageReq>,
1076) -> Result<Json<RevealSpecificKeyLinkageResult>, AppError> {
1077    let originator = extract_originator(&headers)?;
1078    let args = WalletRevealSpecificArgs {
1079        counterparty: parse_counterparty(&req.counterparty)?,
1080        verifier: PublicKey::from_hex(&req.verifier).map_err(|e| anyhow::anyhow!("{}", e))?,
1081        protocol_id: parse_protocol_id(&req.protocol_id)?,
1082        key_id: req.key_id,
1083        privileged: req.privileged,
1084        privileged_reason: req.privileged_reason,
1085    };
1086    let result = wallet
1087        .reveal_specific_key_linkage(args, &originator)
1088        .await
1089        .map_err(AppError::from_wallet_error)?;
1090    Ok(Json(result))
1091}