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