Skip to main content

bsv_wallet_cli/server/
handlers.rs

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