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