Skip to main content

bsv_wallet_cli/server/
audit.rs

1//! PERMISSION AUDIT — record what a REAL wallet would have prompted the user for.
2//!
3//! This daemon grants everything silently, which is what makes it usable
4//! headlessly — and also what makes it blind. A BRC-100 wallet with a human
5//! behind it prompts on two axes:
6//!
7//!   • PROTOCOL (BRC-43): security level 1 asks once per protocol, level 2 once
8//!     per protocol AND counterparty. Level 0 never asks. An app can pre-grant
9//!     these in its `manifest.json` (BRC-73 `groupPermissions.protocolPermissions`)
10//!     — but only for a level-2 counterparty it can name in advance.
11//!   • SPENDING: every action that moves satoshis, unless the app declared a
12//!     `spendingAuthorization` budget.
13//!
14//! An app can therefore be correct, fast, fully tested — and still interrupt a
15//! player a dozen times a hand, with nothing in any test suite noticing. Worse,
16//! a dismissed prompt fails SILENTLY at the call site, so a missing manifest
17//! entry looks like a mysteriously absent marker rather than a permission bug
18//! (bsv-low #386 was found exactly that way).
19//!
20//! So: record the raw facts here and let the CALLER judge them. This module
21//! deliberately does NOT know what a manifest is or which protocols are
22//! pre-granted — that belongs with the app being tested, which owns its own
23//! manifest. Here we only answer "what was asked for, in what order, at what
24//! level, against which counterparty, for how many satoshis".
25//!
26//! Process-global on purpose: this daemon serves exactly one wallet
27//! (`bsv-wallet serve --db … --port N`), and threading a sink through every
28//! handler signature would be a large diff for no extra fidelity.
29
30use serde::{Deserialize, Serialize};
31use std::sync::{Mutex, OnceLock};
32
33/// One permissioned request, as the wallet received it.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AuditEntry {
36    /// BRC-100 method: `createSignature`, `createAction`, `getPublicKey`, …
37    pub method: String,
38    /// BRC-43 security level, when the call carries a protocol ID.
39    #[serde(rename = "protocolLevel", skip_serializing_if = "Option::is_none")]
40    pub protocol_level: Option<u8>,
41    #[serde(rename = "protocolName", skip_serializing_if = "Option::is_none")]
42    pub protocol_name: Option<String>,
43    /// `self` / `anyone` / a 66-hex public key — what level 2 scopes on.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub counterparty: Option<String>,
46    #[serde(rename = "keyID", skip_serializing_if = "Option::is_none")]
47    pub key_id: Option<String>,
48    /// Satoshis the caller asked to move (sum of requested outputs). `None` for
49    /// non-spending calls; `Some(0)` for a fee-only action such as an
50    /// OP_RETURN marker, which still costs a miner fee and still prompts.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub satoshis: Option<u64>,
53    /// The requesting app (Origin / Originator header).
54    pub originator: String,
55    /// Caller-supplied description, which is the text a wallet shows a human.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub description: Option<String>,
58    /// Monotonic sequence — ordering matters when reading a hand back.
59    pub seq: u64,
60}
61
62fn log() -> &'static Mutex<Vec<AuditEntry>> {
63    static LOG: OnceLock<Mutex<Vec<AuditEntry>>> = OnceLock::new();
64    LOG.get_or_init(|| Mutex::new(Vec::new()))
65}
66
67/// Cap the ring so a long-lived daemon cannot grow without bound. A hand costs
68/// on the order of ten entries; this holds thousands of them.
69const MAX_ENTRIES: usize = 5000;
70
71/// Record one permissioned request. Never panics and never fails a request —
72/// an audit that can break the wallet it observes is worse than no audit.
73pub fn record(mut entry: AuditEntry) {
74    if let Ok(mut guard) = log().lock() {
75        entry.seq = guard.len() as u64;
76        if guard.len() >= MAX_ENTRIES {
77            guard.remove(0);
78        }
79        guard.push(entry);
80    }
81}
82
83/// Everything recorded since start or the last `reset`.
84pub fn snapshot() -> Vec<AuditEntry> {
85    log().lock().map(|g| g.clone()).unwrap_or_default()
86}
87
88/// Clear the log — a test calls this immediately before the flow it measures.
89pub fn reset() {
90    if let Ok(mut guard) = log().lock() {
91        guard.clear();
92    }
93}
94
95/// Build an entry for a protocol-bearing call.
96pub fn protocol_entry(
97    method: &str,
98    level: u8,
99    name: &str,
100    counterparty: Option<String>,
101    key_id: Option<String>,
102    originator: &str,
103) -> AuditEntry {
104    AuditEntry {
105        method: method.to_string(),
106        protocol_level: Some(level),
107        protocol_name: Some(name.to_string()),
108        counterparty,
109        key_id,
110        satoshis: None,
111        originator: originator.to_string(),
112        description: None,
113        seq: 0,
114    }
115}
116
117/// Build an entry for a spending call.
118pub fn spend_entry(method: &str, satoshis: u64, description: Option<String>, originator: &str) -> AuditEntry {
119    AuditEntry {
120        method: method.to_string(),
121        protocol_level: None,
122        protocol_name: None,
123        counterparty: None,
124        key_id: None,
125        satoshis: Some(satoshis),
126        originator: originator.to_string(),
127        description,
128        seq: 0,
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    /// The log is process-global by design, so these cells would otherwise
137    /// race each other under vitest-style parallelism and fail for a reason
138    /// that has nothing to do with the code. Serialize them explicitly rather
139    /// than tuning the order and hoping.
140    fn serial() -> std::sync::MutexGuard<'static, ()> {
141        static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
142        GUARD
143            .get_or_init(|| Mutex::new(()))
144            .lock()
145            .unwrap_or_else(|e| e.into_inner())
146    }
147
148    #[test]
149    fn records_in_order_and_resets() {
150        let _s = serial();
151        reset();
152        record(protocol_entry("createSignature", 2, "low settle", Some("02ab".into()), None, "low.game"));
153        record(spend_entry("createAction", 20_000, Some("LOW pot JOIN funding hop".into()), "low.game"));
154        let s = snapshot();
155        assert_eq!(s.len(), 2);
156        assert_eq!(s[0].seq, 0);
157        assert_eq!(s[0].protocol_level, Some(2));
158        assert_eq!(s[1].seq, 1);
159        assert_eq!(s[1].satoshis, Some(20_000));
160        reset();
161        assert!(snapshot().is_empty());
162    }
163
164    #[test]
165    fn the_ring_is_bounded() {
166        let _s = serial();
167        reset();
168        for _ in 0..(MAX_ENTRIES + 10) {
169            record(spend_entry("createAction", 1, None, "low.game"));
170        }
171        assert_eq!(snapshot().len(), MAX_ENTRIES);
172        reset();
173    }
174}