Skip to main content

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}