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