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