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