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}