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