Skip to main content

webledgers_teller/
request.rs

1//! Requests: Nostr events of kind [`REQUEST_KIND`] signed by an account's
2//! key, each applied at most once by its id (`teller.mjs requestTags`,
3//! `requestEvent`, `parseRequest`).
4//!
5//! - `join`: be watched (the operator derives the account's deposit address);
6//! - `withdraw`: an amount to an address, paid out by the operator;
7//! - `transfer`: an amount to another account, applied on sight.
8//!
9//! The tags are `ledger` (the ledger's hash), `op`, `id`, and for a
10//! withdrawal or transfer `amount` (whole satoshis as a string) and `to`. The
11//! content is empty. The teller draws a missing id from the browser's random
12//! source; this crate does no I/O, so the caller supplies the id
13//! ([`request_id`] writes 16 random bytes the teller's way).
14//!
15//! ```
16//! use bitcoin::secp256k1::SecretKey;
17//! use webledgers_teller::{parse_request, pubkey_hex, request_event, request_id, Op, RequestParams};
18//!
19//! let alice = SecretKey::from_slice(&[0x22; 32]).unwrap();
20//! let ledger = "cd".repeat(32);
21//! let id = request_id([7; 16]);
22//! let ev = request_event(&alice, &RequestParams::transfer(&ledger, 5, "bb".repeat(32).as_str(), &id), 1_759_300_000).unwrap();
23//!
24//! let r = parse_request(&ev, &ledger).unwrap();
25//! assert_eq!(r.op, Op::Transfer);
26//! assert_eq!(r.account, format!("did:nostr:{}", pubkey_hex(&alice)));
27//! assert_eq!(r.amount, Some(5));
28//! assert_eq!(r.to.as_deref(), Some(format!("did:nostr:{}", "bb".repeat(32)).as_str()));
29//! ```
30
31use core::fmt;
32use core::str::FromStr;
33
34use bitcoin::secp256k1::SecretKey;
35use sidestr_nostr::event::{Event, UnsignedEvent};
36
37use crate::account::{account_of, check_sats, js_trim, sats};
38use crate::deposit::is_x;
39use crate::error::{Error, Result};
40use crate::REQUEST_KIND;
41
42/// What a request asks for.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum Op {
45    /// Be watched: the operator derives the account's deposit address.
46    Join,
47    /// An amount to an address, paid out by the operator.
48    Withdraw,
49    /// An amount to another account.
50    Transfer,
51}
52
53impl Op {
54    /// The tag value: `join`, `withdraw` or `transfer`.
55    pub fn as_str(self) -> &'static str {
56        match self {
57            Op::Join => "join",
58            Op::Withdraw => "withdraw",
59            Op::Transfer => "transfer",
60        }
61    }
62}
63
64impl fmt::Display for Op {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str(self.as_str())
67    }
68}
69
70impl FromStr for Op {
71    type Err = Error;
72
73    /// `join`, `withdraw` or `transfer` exactly, else [`Error::Op`].
74    fn from_str(s: &str) -> Result<Self> {
75        match s {
76            "join" => Ok(Op::Join),
77            "withdraw" => Ok(Op::Withdraw),
78            "transfer" => Ok(Op::Transfer),
79            _ => Err(Error::Op),
80        }
81    }
82}
83
84/// What a request carries (`requestTags`' argument).
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct RequestParams<'a> {
87    /// The ledger's hash, 64 lowercase hex.
88    pub ledger_hash: &'a str,
89    /// What is asked.
90    pub op: Op,
91    /// Satoshis, for a withdrawal or transfer.
92    pub amount: Option<u64>,
93    /// An address for a withdrawal, an account for a transfer.
94    pub to: Option<&'a str>,
95    /// The request's id. [`parse_request`] reads back only 8 to 64 lowercase
96    /// hex digits; the teller writes 32 ([`request_id`]).
97    pub id: &'a str,
98}
99
100impl<'a> RequestParams<'a> {
101    /// A `join`.
102    pub fn join(ledger_hash: &'a str, id: &'a str) -> Self {
103        Self {
104            ledger_hash,
105            op: Op::Join,
106            amount: None,
107            to: None,
108            id,
109        }
110    }
111
112    /// A `withdraw` of `amount` to the address `to`.
113    pub fn withdraw(ledger_hash: &'a str, amount: u64, to: &'a str, id: &'a str) -> Self {
114        Self {
115            ledger_hash,
116            op: Op::Withdraw,
117            amount: Some(amount),
118            to: Some(to),
119            id,
120        }
121    }
122
123    /// A `transfer` of `amount` to the account `to`.
124    pub fn transfer(ledger_hash: &'a str, amount: u64, to: &'a str, id: &'a str) -> Self {
125        Self {
126            ledger_hash,
127            op: Op::Transfer,
128            amount: Some(amount),
129            to: Some(to),
130            id,
131        }
132    }
133}
134
135/// A request id the teller's way: 16 bytes (from the caller's random
136/// source) as 32 lowercase hex digits.
137pub fn request_id(random: [u8; 16]) -> String {
138    hex::encode(random)
139}
140
141/// `requestTags`: `ledger`, `op`, `id`, and for a withdrawal or transfer
142/// `amount` and `to` (a transfer's `to` read as an account, a withdrawal's
143/// trimmed). Refuses a ledger hash that is not 64 lowercase hex
144/// ([`Error::RequestLedgerHash`]), a withdrawal or transfer with no amount
145/// or one beyond 21 million coins ([`Error::Amount`]) and one with no `to`
146/// ([`Error::NoDestination`]).
147pub fn request_tags(p: &RequestParams<'_>) -> Result<Vec<Vec<String>>> {
148    if !is_x(p.ledger_hash) {
149        return Err(Error::RequestLedgerHash);
150    }
151    let mut tags = vec![
152        vec!["ledger".to_owned(), p.ledger_hash.to_owned()],
153        vec!["op".to_owned(), p.op.as_str().to_owned()],
154        vec!["id".to_owned(), p.id.to_owned()],
155    ];
156    if p.op != Op::Join {
157        let amount = check_sats(p.amount.ok_or(Error::Amount)?)?;
158        tags.push(vec!["amount".to_owned(), amount.to_string()]);
159        let to = p.to.filter(|t| !t.is_empty()).ok_or(Error::NoDestination)?;
160        let to = if p.op == Op::Transfer {
161            account_of(to)?
162        } else {
163            js_trim(to).to_owned()
164        };
165        tags.push(vec!["to".to_owned(), to]);
166    }
167    Ok(tags)
168}
169
170/// The unsigned request (kind [`REQUEST_KIND`], empty content); its pubkey
171/// is the signer's, filled in when it is signed. For a key held elsewhere:
172/// sign [`UnsignedEvent::id_bytes`] and attach it with
173/// [`UnsignedEvent::with_signature`].
174pub fn request_template(p: &RequestParams<'_>, created_at: u64) -> Result<UnsignedEvent> {
175    Ok(UnsignedEvent {
176        pubkey: String::new(),
177        created_at,
178        kind: REQUEST_KIND,
179        tags: request_tags(p)?,
180        content: String::new(),
181    })
182}
183
184/// `requestEvent`: the request signed by the account's key (BIP 340, zero
185/// auxiliary randomness).
186pub fn request_event(
187    account_secret: &SecretKey,
188    p: &RequestParams<'_>,
189    created_at: u64,
190) -> Result<Event> {
191    crate::sign::sign_event(account_secret, request_template(p, created_at)?)
192}
193
194/// A request read back and verified (`parseRequest`'s result).
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct Request {
197    /// The request's id.
198    pub id: String,
199    /// What it asks.
200    pub op: Op,
201    /// Its author, `did:nostr:<pubkey>`: the account it acts for.
202    pub account: String,
203    /// The event's `created_at`.
204    pub created_at: u64,
205    /// Satoshis, for a withdrawal or transfer.
206    pub amount: Option<u64>,
207    /// The address (withdrawal) or account (transfer).
208    pub to: Option<String>,
209    /// The event it was read from.
210    pub event: Event,
211}
212
213/// `parseRequest`: a request for the ledger `ledger_hash`, verified, its
214/// account its author. Refusals in the teller's words: not kind 3700, does
215/// not verify, for another ledger, no valid op or id (8 to 64 lowercase
216/// hex), a bad amount, no destination; a transfer's `to` must be an account.
217pub fn parse_request(ev: &Event, ledger_hash: &str) -> Result<Request> {
218    if ev.kind != REQUEST_KIND {
219        return Err(Error::NotARequest);
220    }
221    if ev.verify().is_err() {
222        return Err(Error::Unverified);
223    }
224    let tag = |n: &str| {
225        ev.tags
226            .iter()
227            .find(|t| t.first().map(String::as_str) == Some(n))
228            .and_then(|t| t.get(1))
229            .map(String::as_str)
230    };
231    if tag("ledger") != Some(ledger_hash) {
232        return Err(Error::OtherLedger);
233    }
234    let op = tag("op").and_then(|o| o.parse::<Op>().ok());
235    let id = tag("id").filter(|id| {
236        (8..=64).contains(&id.len()) && id.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f'))
237    });
238    let (Some(op), Some(id)) = (op, id) else {
239        return Err(Error::OpAndId);
240    };
241    let mut r = Request {
242        id: id.to_owned(),
243        op,
244        account: format!("did:nostr:{}", ev.pubkey),
245        created_at: ev.created_at,
246        amount: None,
247        to: None,
248        event: ev.clone(),
249    };
250    if op != Op::Join {
251        r.amount = Some(sats(tag("amount").ok_or(Error::Amount)?)?);
252        let to = tag("to")
253            .filter(|t| !t.is_empty())
254            .ok_or(Error::NoDestination)?;
255        r.to = Some(if op == Op::Transfer {
256            account_of(to)?
257        } else {
258            to.to_owned()
259        });
260    }
261    Ok(r)
262}