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(
119    method: &str,
120    satoshis: u64,
121    description: Option<String>,
122    originator: &str,
123) -> AuditEntry {
124    AuditEntry {
125        method: method.to_string(),
126        protocol_level: None,
127        protocol_name: None,
128        counterparty: None,
129        key_id: None,
130        satoshis: Some(satoshis),
131        originator: originator.to_string(),
132        description,
133        seq: 0,
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    /// The log is process-global by design, so these cells would otherwise
142    /// race each other under vitest-style parallelism and fail for a reason
143    /// that has nothing to do with the code. Serialize them explicitly rather
144    /// than tuning the order and hoping.
145    fn serial() -> std::sync::MutexGuard<'static, ()> {
146        static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
147        GUARD
148            .get_or_init(|| Mutex::new(()))
149            .lock()
150            .unwrap_or_else(|e| e.into_inner())
151    }
152
153    #[test]
154    fn records_in_order_and_resets() {
155        let _s = serial();
156        reset();
157        record(protocol_entry(
158            "createSignature",
159            2,
160            "low settle",
161            Some("02ab".into()),
162            None,
163            "low.game",
164        ));
165        record(spend_entry(
166            "createAction",
167            20_000,
168            Some("LOW pot JOIN funding hop".into()),
169            "low.game",
170        ));
171        let s = snapshot();
172        assert_eq!(s.len(), 2);
173        assert_eq!(s[0].seq, 0);
174        assert_eq!(s[0].protocol_level, Some(2));
175        assert_eq!(s[1].seq, 1);
176        assert_eq!(s[1].satoshis, Some(20_000));
177        reset();
178        assert!(snapshot().is_empty());
179    }
180
181    #[test]
182    fn the_ring_is_bounded() {
183        let _s = serial();
184        reset();
185        for _ in 0..(MAX_ENTRIES + 10) {
186            record(spend_entry("createAction", 1, None, "low.game"));
187        }
188        assert_eq!(snapshot().len(), MAX_ENTRIES);
189        reset();
190    }
191}