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
157impl From<anyhow::Error> for AppError {
158    fn from(err: anyhow::Error) -> Self {
159        Self::from_wallet_error(err)
160    }
161}
162
163impl From<(StatusCode, Json<serde_json::Value>)> for AppError {
164    fn from((status, json): (StatusCode, Json<serde_json::Value>)) -> Self {
165        let message = json
166            .0
167            .get("message")
168            .and_then(|m| m.as_str())
169            .unwrap_or_default()
170            .to_string();
171        let (_, code) = Self::classify(&message);
172        Self {
173            status,
174            code,
175            message,
176        }
177    }
178}
179
180impl IntoResponse for AppError {
181    fn into_response(self) -> Response {
182        tracing::warn!(
183            status = self.status.as_u16(),
184            code = self.code,
185            message = %self.message,
186            "request error"
187        );
188        (
189            self.status,
190            Json(json!({"code": self.code, "message": self.message})),
191        )
192            .into_response()
193    }
194}
195
196/// GET /isAuthenticated
197pub async fn is_authenticated() -> Json<serde_json::Value> {
198    Json(json!({"authenticated": true}))
199}
200
201/// POST /getPublicKey
202pub async fn get_public_key(
203    State(wallet): State<WalletState>,
204    headers: HeaderMap,
205    Json(req): Json<McGetPublicKeyReq>,
206) -> Result<Json<McGetPublicKeyRes>, AppError> {
207    let originator = extract_originator(&headers)?;
208
209    let args = GetPublicKeyArgs {
210        identity_key: req.identity_key,
211        protocol_id: req.protocol_id.map(|v| parse_protocol_id(&v)).transpose()?,
212        key_id: req.key_id,
213        counterparty: req
214            .counterparty
215            .map(|s| parse_counterparty(&s))
216            .transpose()?,
217        for_self: req.for_self,
218    };
219
220    let result = wallet
221        .get_public_key(args, &originator)
222        .await
223        .map_err(AppError::from_wallet_error)?;
224
225    Ok(Json(McGetPublicKeyRes {
226        public_key: result.public_key,
227    }))
228}
229
230/// POST /createSignature
231pub async fn create_signature(
232    State(wallet): State<WalletState>,
233    headers: HeaderMap,
234    Json(req): Json<McCreateSignatureReq>,
235) -> Result<Json<McCreateSignatureRes>, AppError> {
236    let originator = extract_originator(&headers)?;
237
238    // ts-sdk sends exactly one of `data` (hash-then-sign) or
239    // `hashToDirectlySign` (sign the 32-byte digest as-is).
240    let hash_to_directly_sign = req
241        .hash_to_directly_sign
242        .map(|h| {
243            <[u8; 32]>::try_from(h.as_slice()).map_err(|_| {
244                AppError::from_wallet_error("Validation error: hashToDirectlySign must be 32 bytes")
245            })
246        })
247        .transpose()?;
248    if req.data.is_none() && hash_to_directly_sign.is_none() {
249        return Err(AppError::from_wallet_error(
250            "Validation error: one of data or hashToDirectlySign is required",
251        ));
252    }
253
254    let args = CreateSignatureArgs {
255        data: req.data,
256        hash_to_directly_sign,
257        protocol_id: parse_protocol_id(&req.protocol_id)?,
258        key_id: req.key_id,
259        counterparty: Some(parse_counterparty(&req.counterparty)?),
260    };
261
262    let result = wallet
263        .create_signature(args, &originator)
264        .await
265        .map_err(AppError::from_wallet_error)?;
266
267    Ok(Json(McCreateSignatureRes {
268        signature: result.signature,
269    }))
270}
271
272/// POST /createAction
273///
274/// Spending operations are serialized via `SpendingLock` so that concurrent
275/// requests queue up ("bus stop" pattern) rather than racing on SQLite's write
276/// lock. Non-spending endpoints remain fully concurrent.
277pub async fn create_action(
278    State(wallet): State<WalletState>,
279    axum::Extension(spending_lock): axum::Extension<super::SpendingLock>,
280    headers: HeaderMap,
281    Json(req): Json<McCreateActionReq>,
282) -> Result<Json<McCreateActionRes>, AppError> {
283    let originator = extract_originator(&headers)?;
284
285    let outputs = req
286        .outputs
287        .map(|outs| {
288            outs.into_iter()
289                .map(|o| {
290                    Ok(CreateActionOutput {
291                        locking_script: hex::decode(&o.locking_script)?,
292                        satoshis: o.satoshis,
293                        output_description: o.output_description,
294                        basket: o.basket,
295                        custom_instructions: o.custom_instructions,
296                        tags: o.tags,
297                    })
298                })
299                .collect::<Result<Vec<_>>>()
300        })
301        .transpose()?;
302
303    // Map caller-provided explicit inputs (e.g. a covenant spend carrying a full
304    // unlockingScript). An absent/empty list stays `Some(vec![])` so the wallet
305    // auto-selects its own funding UTXOs — the prior behaviour for plain sends.
306    // Before this, the handler discarded `inputs`/`inputBEEF`/`lockTime`, so any
307    // covenant input was silently dropped and the tx was funded from change.
308    let inputs = match req.inputs {
309        Some(reqs) if !reqs.is_empty() => {
310            let mut mapped = Vec::with_capacity(reqs.len());
311            for i in reqs {
312                let outpoint = Outpoint::from_string(&i.outpoint)
313                    .map_err(|e| anyhow::anyhow!("invalid input outpoint '{}': {e}", i.outpoint))?;
314                let unlocking_script = match i.unlocking_script {
315                    Some(h) => Some(hex::decode(&h).context("invalid unlockingScript hex")?),
316                    None => None,
317                };
318                mapped.push(CreateActionInput {
319                    outpoint,
320                    input_description: i.input_description.unwrap_or_else(|| "input".to_string()),
321                    unlocking_script,
322                    unlocking_script_length: i.unlocking_script_length,
323                    sequence_number: i.sequence_number,
324                });
325            }
326            Some(mapped)
327        }
328        _ => Some(vec![]),
329    };
330
331    let args = CreateActionArgs {
332        description: req.description,
333        input_beef: req.input_beef,
334        inputs,
335        outputs,
336        lock_time: req.lock_time,
337        version: None,
338        labels: req.labels,
339        options: req.options.map(|o| CreateActionOptions {
340            accept_delayed_broadcast: o.accept_delayed_broadcast,
341            randomize_outputs: o.randomize_outputs,
342            sign_and_process: o.sign_and_process,
343            no_send: o.no_send,
344            trust_self: match o.trust_self.as_deref() {
345                Some("known") => Some(TrustSelf::Known),
346                _ => None,
347            },
348            ..Default::default()
349        }),
350    };
351
352    let output_count = args.outputs.as_ref().map(|o| o.len()).unwrap_or(0);
353    let total_sats: u64 = args
354        .outputs
355        .as_ref()
356        .map(|o| o.iter().map(|x| x.satoshis).sum())
357        .unwrap_or(0);
358
359    // Acquire spending lock — queues behind any in-flight createAction.
360    // Once the previous tx completes (and its change UTXO exists), we proceed.
361    let _guard = spending_lock.lock().await;
362    let result = wallet
363        .create_action(args, &originator)
364        .await
365        .map_err(AppError::from_wallet_error)?;
366    drop(_guard);
367
368    tracing::info!(
369        originator = %originator,
370        outputs = output_count,
371        total_satoshis = total_sats,
372        txid = ?result.txid.as_ref().map(|t| to_hex(t)),
373        "createAction"
374    );
375
376    // Build AtomicBEEF from result.beef (which has ancestors) + txid.
377    // result.tx is raw tx bytes; clients expect AtomicBEEF.
378    let atomic_beef = match (result.beef.as_ref(), result.txid.as_ref()) {
379        (Some(beef_bytes), Some(txid_bytes)) => {
380            let txid_hex = to_hex(txid_bytes);
381            match bsv_sdk::transaction::Beef::from_binary(beef_bytes) {
382                Ok(mut beef) => match beef.to_binary_atomic(&txid_hex) {
383                    Ok(ab) => Some(ab),
384                    Err(e) => {
385                        tracing::warn!(error = %e, "Failed to build AtomicBEEF, falling back to raw tx");
386                        result.tx.clone()
387                    }
388                },
389                Err(e) => {
390                    tracing::warn!(error = %e, "Failed to parse BEEF, falling back to raw tx");
391                    result.tx.clone()
392                }
393            }
394        }
395        _ => result.tx.clone(),
396    };
397
398    Ok(Json(McCreateActionRes {
399        txid: result.txid.map(|t| to_hex(&t)),
400        tx: atomic_beef,
401        send_with_results: result
402            .send_with_results
403            .and_then(|r| serde_json::to_value(r).ok()),
404        signable_transaction: result.signable_transaction.map(|st| {
405            // The SDK stores reference as Vec<u8> from String.into_bytes().
406            // Reverse that to get the original reference string that
407            // signAction/abortAction can look up in the pending tx cache.
408            let reference =
409                String::from_utf8(st.reference).unwrap_or_else(|e| hex::encode(e.into_bytes()));
410            McSignableTransaction {
411                tx: st.tx,
412                reference,
413            }
414        }),
415        no_send_change: result
416            .no_send_change
417            .and_then(|v| serde_json::to_value(v).ok()),
418    }))
419}
420
421/// POST /internalizeAction
422pub async fn internalize_action(
423    State(wallet): State<WalletState>,
424    headers: HeaderMap,
425    Json(req): Json<McInternalizeActionReq>,
426) -> Result<Json<McInternalizeActionRes>, AppError> {
427    let originator = extract_originator(&headers)?;
428
429    let args = InternalizeActionArgs {
430        tx: req.tx,
431        outputs: req
432            .outputs
433            .into_iter()
434            .map(|o| InternalizeOutput {
435                output_index: o.output_index,
436                protocol: o.protocol,
437                payment_remittance: o.payment_remittance.map(|p| WalletPayment {
438                    derivation_prefix: p.derivation_prefix,
439                    derivation_suffix: p.derivation_suffix,
440                    sender_identity_key: p.sender_identity_key,
441                }),
442                insertion_remittance: o.insertion_remittance.map(|i| BasketInsertion {
443                    basket: i.basket,
444                    custom_instructions: i.custom_instructions,
445                    tags: i.tags,
446                }),
447            })
448            .collect(),
449        description: req.description,
450        labels: None,
451        seek_permission: None,
452    };
453
454    let output_count = args.outputs.len();
455    let result = wallet
456        .internalize_action(args, &originator)
457        .await
458        .map_err(AppError::from_wallet_error)?;
459
460    tracing::info!(
461        originator = %originator,
462        outputs = output_count,
463        accepted = result.accepted,
464        "internalizeAction"
465    );
466
467    Ok(Json(McInternalizeActionRes {
468        accepted: result.accepted,
469    }))
470}
471
472// --- Helpers ---
473
474/// Parse [2, "protocol_name"] → Protocol
475fn parse_protocol_id(v: &serde_json::Value) -> Result<Protocol> {
476    let arr = v.as_array().context("protocolID must be an array")?;
477    let level = arr
478        .first()
479        .and_then(|v| v.as_u64())
480        .context("protocolID[0] must be an integer")?;
481    let name = arr
482        .get(1)
483        .and_then(|v| v.as_str())
484        .context("protocolID[1] must be a string")?;
485    Ok(Protocol::new(
486        match level {
487            0 => SecurityLevel::Silent,
488            1 => SecurityLevel::App,
489            2 => SecurityLevel::Counterparty,
490            _ => anyhow::bail!("invalid security level: {level}"),
491        },
492        name,
493    ))
494}
495
496/// Parse "03abc..." → Counterparty::Other(PublicKey)
497fn parse_counterparty(s: &str) -> Result<Counterparty> {
498    match s {
499        "self" => Ok(Counterparty::Self_),
500        "anyone" => Ok(Counterparty::Anyone),
501        hex => Ok(Counterparty::Other(PublicKey::from_hex(hex)?)),
502    }
503}
504
505// =============================================================================
506// Batch 1: Status (GET endpoints)
507// =============================================================================
508
509/// GET /getHeight
510pub async fn get_height(
511    State(wallet): State<WalletState>,
512) -> Result<Json<GetHeightResult>, AppError> {
513    let result = wallet
514        .get_height("localhost")
515        .await
516        .map_err(AppError::from_wallet_error)?;
517    Ok(Json(result))
518}
519
520/// GET /getNetwork
521pub async fn get_network(
522    State(wallet): State<WalletState>,
523) -> Result<Json<GetNetworkResult>, AppError> {
524    let result = wallet
525        .get_network("localhost")
526        .await
527        .map_err(AppError::from_wallet_error)?;
528    Ok(Json(result))
529}
530
531/// GET /getVersion
532pub async fn get_version(
533    State(wallet): State<WalletState>,
534) -> Result<Json<GetVersionResult>, AppError> {
535    let result = wallet
536        .get_version("localhost")
537        .await
538        .map_err(AppError::from_wallet_error)?;
539    Ok(Json(result))
540}
541
542/// GET /waitForAuthentication
543pub async fn wait_for_authentication(
544    State(wallet): State<WalletState>,
545) -> Result<Json<AuthenticatedResult>, AppError> {
546    let result = wallet
547        .wait_for_authentication("localhost")
548        .await
549        .map_err(AppError::from_wallet_error)?;
550    Ok(Json(result))
551}
552
553// =============================================================================
554// Batch 2: Header
555// =============================================================================
556
557/// POST /getHeaderForHeight
558pub async fn get_header_for_height(
559    State(wallet): State<WalletState>,
560    headers: HeaderMap,
561    Json(args): Json<GetHeaderArgs>,
562) -> Result<Json<GetHeaderResult>, AppError> {
563    let originator = extract_originator(&headers)?;
564    let result = wallet
565        .get_header_for_height(args, &originator)
566        .await
567        .map_err(AppError::from_wallet_error)?;
568    Ok(Json(result))
569}
570
571// =============================================================================
572// Batch 3: Crypto
573// =============================================================================
574
575/// POST /verifySignature
576pub async fn verify_signature(
577    State(wallet): State<WalletState>,
578    headers: HeaderMap,
579    Json(req): Json<McVerifySignatureReq>,
580) -> Result<Json<serde_json::Value>, AppError> {
581    let originator = extract_originator(&headers)?;
582    let args = VerifySignatureArgs {
583        data: req.data,
584        hash_to_directly_verify: None,
585        signature: req.signature,
586        protocol_id: parse_protocol_id(&req.protocol_id)?,
587        key_id: req.key_id,
588        counterparty: req
589            .counterparty
590            .map(|s| parse_counterparty(&s))
591            .transpose()?,
592        for_self: req.for_self,
593    };
594    let result = wallet
595        .verify_signature(args, &originator)
596        .await
597        .map_err(AppError::from_wallet_error)?;
598    Ok(Json(json!({ "valid": result.valid })))
599}
600
601/// POST /encrypt
602pub async fn encrypt(
603    State(wallet): State<WalletState>,
604    headers: HeaderMap,
605    Json(req): Json<McEncryptReq>,
606) -> Result<Json<serde_json::Value>, AppError> {
607    let originator = extract_originator(&headers)?;
608    let args = EncryptArgs {
609        plaintext: req.plaintext,
610        protocol_id: parse_protocol_id(&req.protocol_id)?,
611        key_id: req.key_id,
612        counterparty: req
613            .counterparty
614            .map(|s| parse_counterparty(&s))
615            .transpose()?,
616    };
617    let result = wallet
618        .encrypt(args, &originator)
619        .await
620        .map_err(AppError::from_wallet_error)?;
621    Ok(Json(json!({ "ciphertext": result.ciphertext })))
622}
623
624/// POST /decrypt
625pub async fn decrypt(
626    State(wallet): State<WalletState>,
627    headers: HeaderMap,
628    Json(req): Json<McDecryptReq>,
629) -> Result<Json<serde_json::Value>, AppError> {
630    let originator = extract_originator(&headers)?;
631    let args = DecryptArgs {
632        ciphertext: req.ciphertext,
633        protocol_id: parse_protocol_id(&req.protocol_id)?,
634        key_id: req.key_id,
635        counterparty: req
636            .counterparty
637            .map(|s| parse_counterparty(&s))
638            .transpose()?,
639    };
640    let result = wallet
641        .decrypt(args, &originator)
642        .await
643        .map_err(AppError::from_wallet_error)?;
644    Ok(Json(json!({ "plaintext": result.plaintext })))
645}
646
647/// POST /createHmac
648pub async fn create_hmac(
649    State(wallet): State<WalletState>,
650    headers: HeaderMap,
651    Json(req): Json<McCreateHmacReq>,
652) -> Result<Json<serde_json::Value>, AppError> {
653    let originator = extract_originator(&headers)?;
654    let args = CreateHmacArgs {
655        data: req.data,
656        protocol_id: parse_protocol_id(&req.protocol_id)?,
657        key_id: req.key_id,
658        counterparty: req
659            .counterparty
660            .map(|s| parse_counterparty(&s))
661            .transpose()?,
662    };
663    let result = wallet
664        .create_hmac(args, &originator)
665        .await
666        .map_err(AppError::from_wallet_error)?;
667    Ok(Json(json!({ "hmac": result.hmac.to_vec() })))
668}
669
670/// POST /verifyHmac
671pub async fn verify_hmac(
672    State(wallet): State<WalletState>,
673    headers: HeaderMap,
674    Json(req): Json<McVerifyHmacReq>,
675) -> Result<Json<serde_json::Value>, AppError> {
676    let originator = extract_originator(&headers)?;
677    let hmac: [u8; 32] = req
678        .hmac
679        .try_into()
680        .map_err(|_| anyhow::anyhow!("hmac must be exactly 32 bytes"))?;
681    let args = VerifyHmacArgs {
682        data: req.data,
683        hmac,
684        protocol_id: parse_protocol_id(&req.protocol_id)?,
685        key_id: req.key_id,
686        counterparty: req
687            .counterparty
688            .map(|s| parse_counterparty(&s))
689            .transpose()?,
690    };
691    let result = wallet
692        .verify_hmac(args, &originator)
693        .await
694        .map_err(AppError::from_wallet_error)?;
695    Ok(Json(json!({ "valid": result.valid })))
696}
697
698// =============================================================================
699// Batch 4: Transaction Workflow
700// =============================================================================
701
702/// POST /signAction
703pub async fn sign_action(
704    State(wallet): State<WalletState>,
705    headers: HeaderMap,
706    Json(args): Json<SignActionArgs>,
707) -> Result<Json<SignActionResult>, AppError> {
708    let originator = extract_originator(&headers)?;
709    let reference = args.reference.clone();
710    let result = wallet
711        .sign_action(args, &originator)
712        .await
713        .map_err(AppError::from_wallet_error)?;
714    tracing::info!(originator = %originator, reference = %reference, "signAction");
715    Ok(Json(result))
716}
717
718/// POST /abortAction
719pub async fn abort_action(
720    State(wallet): State<WalletState>,
721    headers: HeaderMap,
722    Json(args): Json<AbortActionArgs>,
723) -> Result<Json<AbortActionResult>, AppError> {
724    let originator = extract_originator(&headers)?;
725    let result = wallet
726        .abort_action(args, &originator)
727        .await
728        .map_err(AppError::from_wallet_error)?;
729    Ok(Json(result))
730}
731
732/// POST /listActions
733pub async fn list_actions(
734    State(wallet): State<WalletState>,
735    headers: HeaderMap,
736    Json(args): Json<ListActionsArgs>,
737) -> Result<Json<ListActionsResult>, AppError> {
738    let originator = extract_originator(&headers)?;
739    let result = wallet
740        .list_actions(args, &originator)
741        .await
742        .map_err(AppError::from_wallet_error)?;
743    Ok(Json(result))
744}
745
746/// POST /listOutputs
747pub async fn list_outputs(
748    State(wallet): State<WalletState>,
749    headers: HeaderMap,
750    Json(args): Json<ListOutputsArgs>,
751) -> Result<Json<ListOutputsResult>, AppError> {
752    let originator = extract_originator(&headers)?;
753    let result = wallet
754        .list_outputs(args, &originator)
755        .await
756        .map_err(AppError::from_wallet_error)?;
757    Ok(Json(result))
758}
759
760/// POST /relinquishOutput
761///
762/// Acquires SpendingLock because relinquish modifies UTXO state and can race
763/// with createAction on SQLite writes (previously caused SQLITE_BUSY errors).
764pub async fn relinquish_output(
765    State(wallet): State<WalletState>,
766    axum::Extension(spending_lock): axum::Extension<super::SpendingLock>,
767    headers: HeaderMap,
768    Json(args): Json<RelinquishOutputArgs>,
769) -> Result<Json<RelinquishOutputResult>, AppError> {
770    let originator = extract_originator(&headers)?;
771    let _guard = spending_lock.lock().await;
772    let result = wallet
773        .relinquish_output(args, &originator)
774        .await
775        .map_err(AppError::from_wallet_error)?;
776    Ok(Json(result))
777}
778
779// =============================================================================
780// Batch 5: Certificates
781// =============================================================================
782
783/// POST /acquireCertificate
784pub async fn acquire_certificate(
785    State(wallet): State<WalletState>,
786    headers: HeaderMap,
787    Json(args): Json<AcquireCertificateArgs>,
788) -> Result<Json<WalletCertificate>, AppError> {
789    let originator = extract_originator(&headers)?;
790    let result = wallet
791        .acquire_certificate(args, &originator)
792        .await
793        .map_err(AppError::from_wallet_error)?;
794    Ok(Json(result))
795}
796
797/// POST /listCertificates
798pub async fn list_certificates(
799    State(wallet): State<WalletState>,
800    headers: HeaderMap,
801    Json(args): Json<ListCertificatesArgs>,
802) -> Result<Json<ListCertificatesResult>, AppError> {
803    let originator = extract_originator(&headers)?;
804    let result = wallet
805        .list_certificates(args, &originator)
806        .await
807        .map_err(AppError::from_wallet_error)?;
808    Ok(Json(result))
809}
810
811/// POST /proveCertificate
812pub async fn prove_certificate(
813    State(wallet): State<WalletState>,
814    headers: HeaderMap,
815    Json(args): Json<ProveCertificateArgs>,
816) -> Result<Json<ProveCertificateResult>, AppError> {
817    let originator = extract_originator(&headers)?;
818    let result = wallet
819        .prove_certificate(args, &originator)
820        .await
821        .map_err(AppError::from_wallet_error)?;
822    Ok(Json(result))
823}
824
825/// POST /relinquishCertificate
826pub async fn relinquish_certificate(
827    State(wallet): State<WalletState>,
828    headers: HeaderMap,
829    Json(args): Json<RelinquishCertificateArgs>,
830) -> Result<Json<RelinquishCertificateResult>, AppError> {
831    let originator = extract_originator(&headers)?;
832    let result = wallet
833        .relinquish_certificate(args, &originator)
834        .await
835        .map_err(AppError::from_wallet_error)?;
836    Ok(Json(result))
837}
838
839// =============================================================================
840// Batch 6: Discovery + Key Linkage
841// =============================================================================
842
843/// POST /discoverByIdentityKey
844pub async fn discover_by_identity_key(
845    State(wallet): State<WalletState>,
846    headers: HeaderMap,
847    Json(args): Json<DiscoverByIdentityKeyArgs>,
848) -> Result<Json<DiscoverCertificatesResult>, AppError> {
849    let originator = extract_originator(&headers)?;
850    let result = wallet
851        .discover_by_identity_key(args, &originator)
852        .await
853        .map_err(AppError::from_wallet_error)?;
854    Ok(Json(result))
855}
856
857/// POST /discoverByAttributes
858pub async fn discover_by_attributes(
859    State(wallet): State<WalletState>,
860    headers: HeaderMap,
861    Json(args): Json<DiscoverByAttributesArgs>,
862) -> Result<Json<DiscoverCertificatesResult>, AppError> {
863    let originator = extract_originator(&headers)?;
864    let result = wallet
865        .discover_by_attributes(args, &originator)
866        .await
867        .map_err(AppError::from_wallet_error)?;
868    Ok(Json(result))
869}
870
871/// POST /revealCounterpartyKeyLinkage
872pub async fn reveal_counterparty_key_linkage(
873    State(wallet): State<WalletState>,
874    headers: HeaderMap,
875    Json(req): Json<McRevealCounterpartyKeyLinkageReq>,
876) -> Result<Json<RevealCounterpartyKeyLinkageResult>, AppError> {
877    let originator = extract_originator(&headers)?;
878    let args = WalletRevealCounterpartyArgs {
879        counterparty: PublicKey::from_hex(&req.counterparty)
880            .map_err(|e| anyhow::anyhow!("{}", e))?,
881        verifier: PublicKey::from_hex(&req.verifier).map_err(|e| anyhow::anyhow!("{}", e))?,
882        privileged: req.privileged,
883        privileged_reason: req.privileged_reason,
884    };
885    let result = wallet
886        .reveal_counterparty_key_linkage(args, &originator)
887        .await
888        .map_err(AppError::from_wallet_error)?;
889    Ok(Json(result))
890}
891
892/// POST /revealSpecificKeyLinkage
893pub async fn reveal_specific_key_linkage(
894    State(wallet): State<WalletState>,
895    headers: HeaderMap,
896    Json(req): Json<McRevealSpecificKeyLinkageReq>,
897) -> Result<Json<RevealSpecificKeyLinkageResult>, AppError> {
898    let originator = extract_originator(&headers)?;
899    let args = WalletRevealSpecificArgs {
900        counterparty: parse_counterparty(&req.counterparty)?,
901        verifier: PublicKey::from_hex(&req.verifier).map_err(|e| anyhow::anyhow!("{}", e))?,
902        protocol_id: parse_protocol_id(&req.protocol_id)?,
903        key_id: req.key_id,
904        privileged: req.privileged,
905        privileged_reason: req.privileged_reason,
906    };
907    let result = wallet
908        .reveal_specific_key_linkage(args, &originator)
909        .await
910        .map_err(AppError::from_wallet_error)?;
911    Ok(Json(result))
912}