bsv_wallet_cli/server/post_word.rs
1//! The served doors' word for a transaction whose immediate post drew no
2//! accepting word.
3//!
4//! From the toolbox 0.7.4 (bsv-stack-lean #66) a broadcaster's refusal on
5//! the immediate post of `createAction`, `signAction` or
6//! `internalizeAction` is a hint, never an error: the caller receives the
7//! txid with BRC-100's `sending` (or `accepted` from `internalizeAction`),
8//! the refusal goes on the proof request's history (`immediateBroadcastHint`:
9//! the outcome, the broadcasters' words, the next re-ask), the request is
10//! left `unsent` for the re-ask on the cadence, and the transaction's inputs
11//! stay locked until a proof, a competitor's checked proof or this wallet's
12//! retire. Nothing is released by the refusal.
13//!
14//! The doors answer with the tracker's word for that state, `built` (no
15//! broadcaster took it), the request's status and the hint the toolbox
16//! recorded, as `broadcast` beside the toolbox's own fields. A transaction a
17//! broadcaster took carries no `broadcast` field: `sendWithResults` already
18//! says `unproven`.
19
20use bsv_wallet_toolbox::StorageSqlx;
21use serde::Serialize;
22
23/// The note the toolbox 0.7.4 appends to a request's history when the
24/// immediate post drew no accepting word.
25pub const IMMEDIATE_HINT: &str = "immediateBroadcastHint";
26
27/// What the inputs of a transaction in this state are, in one word.
28pub const INPUTS_LOCKED: &str = "locked";
29
30/// The word for a transaction no broadcaster took on its immediate post.
31#[derive(Debug, Clone, PartialEq, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct PostWord {
34 /// The tracker's word: `built`, a template no broadcaster took.
35 pub word: &'static str,
36 /// The proof request's status (`unsent` for the re-ask on the cadence).
37 pub request: String,
38 /// The last immediate-post hint on the request's history, as the toolbox
39 /// wrote it (`outcome`, `words`, `attempts`, `nextReaskMinutes`).
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub hint: Option<serde_json::Value>,
42 /// `locked`: until a proof, a competitor's checked proof or this
43 /// wallet's retire. A refusal releases nothing.
44 pub inputs: &'static str,
45}
46
47/// The last note of `history` (the toolbox's `{"notes": [...]}`) whose
48/// `what` is `what`.
49pub fn last_note(history: &str, what: &str) -> Option<serde_json::Value> {
50 let h: serde_json::Value = serde_json::from_str(history).ok()?;
51 h.get("notes")?
52 .as_array()?
53 .iter()
54 .rev()
55 .find(|n| n.get("what").and_then(|w| w.as_str()) == Some(what))
56 .cloned()
57}
58
59/// The word for `txid` when its immediate post drew no accepting word and
60/// no broadcaster took it since; `None` otherwise (no request, a request
61/// past the post, a word a broadcaster gave, a read that failed).
62pub async fn post_word(storage: &StorageSqlx, txid: &str) -> Option<PostWord> {
63 let row: Option<(String, String)> =
64 sqlx::query_as("SELECT status, history FROM proven_tx_reqs WHERE txid = ? LIMIT 1")
65 .bind(txid)
66 .fetch_optional(storage.pool())
67 .await
68 .ok()?;
69 let (request, history) = row?;
70 if !matches!(request.as_str(), "unsent" | "sending" | "unprocessed") {
71 return None;
72 }
73 if crate::retire_guard::broadcaster_took(storage.pool(), txid)
74 .await
75 .unwrap_or(true)
76 {
77 return None;
78 }
79 Some(PostWord {
80 word: "built",
81 request,
82 hint: last_note(&history, IMMEDIATE_HINT),
83 inputs: INPUTS_LOCKED,
84 })
85}
86
87/// The subject txid of an Atomic BEEF (BRC-95: the prefix `0x01010101`,
88/// then the txid's 32 bytes in reverse order), without parsing the rest.
89pub fn atomic_subject(bytes: &[u8]) -> Option<String> {
90 if bytes.len() < 36 || bytes[..4] != [0x01, 0x01, 0x01, 0x01] {
91 return None;
92 }
93 let mut txid = bytes[4..36].to_vec();
94 txid.reverse();
95 Some(hex::encode(txid))
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn the_last_hint_is_read_from_the_toolbox_history() {
104 let h = r#"{"notes":[{"what":"immediateBroadcastHint","outcome":"serviceError"},{"what":"other"},{"what":"immediateBroadcastHint","outcome":"invalidTx","nextReaskMinutes":2}]}"#;
105 let n = last_note(h, IMMEDIATE_HINT).unwrap();
106 assert_eq!(n["outcome"], "invalidTx");
107 assert!(last_note("{}", IMMEDIATE_HINT).is_none());
108 assert!(last_note("not json", IMMEDIATE_HINT).is_none());
109 }
110
111 #[test]
112 fn the_subject_of_an_atomic_beef_is_read_from_its_prefix() {
113 let mut b = vec![1, 1, 1, 1];
114 let mut id: Vec<u8> = (0u8..32).collect();
115 b.extend(&id);
116 b.extend([2, 0, 0xbe, 0xef]);
117 id.reverse();
118 assert_eq!(atomic_subject(&b), Some(hex::encode(id)));
119 assert_eq!(atomic_subject(&[2, 0, 0xbe, 0xef]), None);
120 }
121}