Skip to main content

webledgers_teller/
ledger.rs

1//! The ledger: a Web Ledger document whose identity is the hash of its
2//! genesis, and the three moves that change its balances, each applied once
3//! (`teller.mjs newLedger`, `ledgerHash`, `checkLedger`, `balance`, `credit`,
4//! `transfer`, `debit`, `total`, `jcs`).
5//!
6//! The genesis (operator, name, currency, created, confirmations) is fixed at
7//! creation, and the ledger's hash is `sha256(JCS(genesis))`: balances move,
8//! the hash does not. Amounts are integers inside and strings of digits in
9//! the document, as Web Ledgers writes them. Every move takes the time from
10//! the caller (`now`, Unix seconds): the crate reads no clock.
11//!
12//! ```
13//! use webledgers_teller::ledger::{balance, check_ledger, credit, new_ledger, transfer, Credit, LedgerParams, Transfer};
14//!
15//! let alice = format!("did:nostr:{}", "aa".repeat(32));
16//! let bob = format!("did:nostr:{}", "bb".repeat(32));
17//! let mut l = new_ledger(&LedgerParams::new(&alice, "Table 7", 1_759_300_000)).unwrap();
18//! check_ledger(&l).unwrap();
19//!
20//! let deposit = Credit { account: alice.clone(), txid: "ab".repeat(32), vout: 0, value: 50_000, height: Some(152_100) };
21//! assert!(credit(&mut l, &deposit, 1_759_300_001).unwrap().applied());
22//! assert!(!credit(&mut l, &deposit, 1_759_300_002).unwrap().applied()); // the outpoint is the receipt
23//!
24//! let t = Transfer { id: "r1".into(), from: alice.clone(), to: bob.clone(), amount: 20_000 };
25//! assert!(transfer(&mut l, &t, 1_759_300_003).unwrap().applied());
26//! assert_eq!((balance(&l, &alice).unwrap(), balance(&l, &bob).unwrap()), (30_000, 20_000));
27//! assert_eq!(l.entries[0].amount, "30000");
28//! check_ledger(&l).unwrap(); // the hash did not move
29//! ```
30
31use bitcoin::secp256k1::SecretKey;
32use serde::{Deserialize, Serialize};
33use serde_json::{Map, Value};
34use sha2::{Digest, Sha256};
35use sidestr_nostr::event::{Event, UnsignedEvent};
36
37use crate::account::{account_of, check_sats, sats, x_of};
38use crate::error::{Error, Result};
39use crate::{CONTEXT, DEFAULT_CURRENCY, LEDGER_KIND};
40
41/// The longest name a ledger may have, in UTF-16 code units (JavaScript's
42/// `length`).
43pub const MAX_NAME: usize = 80;
44
45/// What the teller's page tags a ledger event with (`t`), beside `d` = the
46/// ledger's hash.
47pub const LEDGER_TOPIC: &str = "webledgers";
48
49/// JCS (RFC 8785) for the plain JSON these documents are (`teller.mjs
50/// jcs`): object keys sorted at every level by UTF-16 code units, arrays in
51/// order, strings and other scalars as `JSON.stringify` writes them.
52///
53/// Integers are exact. A number with a fraction is outside what the teller
54/// writes; it is printed as an integer when it is one below `1e21`, and
55/// otherwise as `serde_json` prints it, which is not guaranteed to be
56/// ECMAScript's form.
57///
58/// ```
59/// use webledgers_teller::ledger::jcs;
60///
61/// let v = serde_json::json!({ "b": 1, "a": { "d": "x", "c": [2, { "f": 0, "e": null }] } });
62/// assert_eq!(jcs(&v), r#"{"a":{"c":[2,{"e":null,"f":0}],"d":"x"},"b":1}"#);
63/// ```
64pub fn jcs(v: &Value) -> String {
65    let mut out = String::new();
66    write_jcs(v, &mut out);
67    out
68}
69
70fn write_jcs(v: &Value, out: &mut String) {
71    match v {
72        Value::Object(m) => {
73            let mut keys: Vec<&String> = m.keys().collect();
74            keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
75            out.push('{');
76            for (i, k) in keys.into_iter().enumerate() {
77                if i > 0 {
78                    out.push(',');
79                }
80                out.push_str(&serde_json::to_string(k).expect("a string serialises"));
81                out.push(':');
82                write_jcs(&m[k], out);
83            }
84            out.push('}');
85        }
86        Value::Array(a) => {
87            out.push('[');
88            for (i, x) in a.iter().enumerate() {
89                if i > 0 {
90                    out.push(',');
91                }
92                write_jcs(x, out);
93            }
94            out.push(']');
95        }
96        Value::Number(n) if n.as_u64().is_none() && n.as_i64().is_none() => {
97            let f = n.as_f64().expect("a JSON number is finite");
98            if f.fract() == 0.0 && f.abs() < 1e21 {
99                out.push_str(&(f as i128).to_string());
100            } else {
101                out.push_str(&n.to_string());
102            }
103        }
104        other => out.push_str(&serde_json::to_string(other).expect("a scalar serialises")),
105    }
106}
107
108/// A ledger's genesis: what is fixed at creation and hashed into its
109/// identity. Fields another writer adds are kept in `extra`, so a ledger
110/// written elsewhere hashes here as it hashes there.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct Genesis {
113    /// The operator's account, `did:nostr:<x>`: who holds the deposits and
114    /// writes the ledger.
115    pub operator: String,
116    /// The ledger's name, 1 to [`MAX_NAME`] characters.
117    pub name: String,
118    /// The chain the amounts are on (`txbt4`).
119    pub currency: String,
120    /// Unix seconds at creation.
121    pub created: u64,
122    /// How deep a deposit must be before it is credited.
123    pub confirmations: u64,
124    /// Fields this crate does not name, kept for the hash.
125    #[serde(flatten)]
126    pub extra: Map<String, Value>,
127}
128
129/// One account's balance: `{ "type": "Entry", "url": <account>, "amount": "<sats>" }`.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct Entry {
132    /// `"Entry"`.
133    pub r#type: String,
134    /// The account, `did:nostr:<x>`.
135    pub url: String,
136    /// The balance in satoshis, as a string of digits.
137    pub amount: String,
138    /// Fields this crate does not name, kept.
139    #[serde(flatten)]
140    pub extra: Map<String, Value>,
141}
142
143/// A deposit credited: its outpoint is the receipt.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct Deposit {
146    /// `<txid>:<vout>`.
147    pub outpoint: String,
148    /// The account credited.
149    pub account: String,
150    /// Satoshis.
151    pub value: u64,
152    /// The block height it was seen at, or `null`.
153    #[serde(default)]
154    pub height: Option<u64>,
155    /// Unix seconds when it was credited.
156    pub at: u64,
157}
158
159/// A withdrawal paid out: the payout's txid is the receipt.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct Payout {
162    /// The request's id.
163    pub id: String,
164    /// The account debited.
165    pub account: String,
166    /// Satoshis.
167    pub value: u64,
168    /// Where it was paid (an address).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub to: Option<String>,
171    /// The payout transaction's id.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub txid: Option<String>,
174    /// Unix seconds when it was debited.
175    pub at: u64,
176}
177
178/// A Web Ledger as the teller writes it, field for field and in its order:
179/// `@context`, `type`, `id`, `hash`, `name`, `defaultCurrency`, `genesis`,
180/// `updated`, `entries`, `deposits`, `applied`, `payouts`. A ledger the
181/// JavaScript teller wrote reads into this and serialises back to the same
182/// JSON, and its genesis hashes the same.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub struct Ledger {
185    /// [`CONTEXT`].
186    #[serde(rename = "@context")]
187    pub context: String,
188    /// `"WebLedger"`.
189    pub r#type: String,
190    /// `urn:webledgers:<hash>`.
191    pub id: String,
192    /// `hex(sha256(JCS(genesis)))`.
193    pub hash: String,
194    /// The genesis's name.
195    pub name: String,
196    /// The genesis's currency.
197    #[serde(rename = "defaultCurrency")]
198    pub default_currency: String,
199    /// What the hash is of.
200    pub genesis: Genesis,
201    /// Unix seconds of the last change.
202    pub updated: u64,
203    /// Balances, one per account.
204    pub entries: Vec<Entry>,
205    /// Every deposit credited.
206    pub deposits: Vec<Deposit>,
207    /// The ids of every request applied (transfers and withdrawals).
208    pub applied: Vec<String>,
209    /// Every withdrawal paid.
210    pub payouts: Vec<Payout>,
211    /// Fields this crate does not name, kept.
212    #[serde(flatten)]
213    pub extra: Map<String, Value>,
214}
215
216/// What [`new_ledger`] is given (`newLedger`'s second argument). The
217/// defaults are the teller's: currency `txbt4`, one confirmation.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct LedgerParams<'a> {
220    /// The operator, any form [`account_of`] reads.
221    pub operator: &'a str,
222    /// The name, 1 to [`MAX_NAME`] characters.
223    pub name: &'a str,
224    /// The currency.
225    pub currency: &'a str,
226    /// Unix seconds at creation (the teller takes the clock; this crate
227    /// takes it from the caller).
228    pub created: u64,
229    /// Confirmations before a deposit is credited.
230    pub confirmations: u64,
231}
232
233impl<'a> LedgerParams<'a> {
234    /// The teller's defaults: currency [`DEFAULT_CURRENCY`], one confirmation.
235    pub fn new(operator: &'a str, name: &'a str, created: u64) -> Self {
236        Self {
237            operator,
238            name,
239            currency: DEFAULT_CURRENCY,
240            created,
241            confirmations: 1,
242        }
243    }
244}
245
246/// `hex(sha256(utf8(JCS(genesis))))`.
247pub fn genesis_hash(genesis: &Genesis) -> String {
248    let v = serde_json::to_value(genesis).expect("a genesis serialises");
249    hex::encode(Sha256::digest(jcs(&v).as_bytes()))
250}
251
252/// `newLedger`: a ledger with its genesis fixed and no balances.
253///
254/// Refuses an operator that is not an account ([`Error::Account`]) and a
255/// name that is empty or longer than [`MAX_NAME`] ([`Error::Name`]).
256pub fn new_ledger(p: &LedgerParams<'_>) -> Result<Ledger> {
257    let genesis = Genesis {
258        operator: account_of(p.operator)?,
259        name: p.name.to_owned(),
260        currency: p.currency.to_owned(),
261        created: p.created,
262        confirmations: p.confirmations,
263        extra: Map::new(),
264    };
265    if genesis.name.is_empty() || genesis.name.encode_utf16().count() > MAX_NAME {
266        return Err(Error::Name);
267    }
268    let h = genesis_hash(&genesis);
269    Ok(Ledger {
270        context: CONTEXT.to_owned(),
271        r#type: "WebLedger".to_owned(),
272        id: format!("urn:webledgers:{h}"),
273        hash: h,
274        name: genesis.name.clone(),
275        default_currency: genesis.currency.clone(),
276        updated: genesis.created,
277        genesis,
278        entries: Vec::new(),
279        deposits: Vec::new(),
280        applied: Vec::new(),
281        payouts: Vec::new(),
282        extra: Map::new(),
283    })
284}
285
286/// `ledgerHash`: the hash recomputed from the genesis.
287pub fn ledger_hash(ledger: &Ledger) -> String {
288    genesis_hash(&ledger.genesis)
289}
290
291/// `checkLedger`: a `WebLedger` whose stored hash is its genesis's hash, or
292/// [`Error::NotALedger`] / [`Error::HashMismatch`].
293pub fn check_ledger(ledger: &Ledger) -> Result<()> {
294    if ledger.r#type != "WebLedger" {
295        return Err(Error::NotALedger);
296    }
297    if ledger_hash(ledger) != ledger.hash {
298        return Err(Error::HashMismatch);
299    }
300    Ok(())
301}
302
303/// A ledger document read from JSON and checked: [`Error::NotALedger`] for
304/// anything that is not an object of type `WebLedger` with a genesis,
305/// [`Error::LedgerContent`] for one whose fields do not fit, then
306/// [`check_ledger`].
307pub fn parse_ledger(json: &str) -> Result<Ledger> {
308    let v: Value = serde_json::from_str(json).map_err(|_| Error::NotALedger)?;
309    if v.get("type").and_then(Value::as_str) != Some("WebLedger")
310        || v.get("genesis").is_none_or(Value::is_null)
311    {
312        return Err(Error::NotALedger);
313    }
314    let ledger: Ledger =
315        serde_json::from_value(v).map_err(|e| Error::LedgerContent(e.to_string()))?;
316    check_ledger(&ledger)?;
317    Ok(ledger)
318}
319
320fn entry_index(ledger: &Ledger, account: &str) -> Option<usize> {
321    ledger.entries.iter().position(|e| e.url == account)
322}
323
324/// `balance`: the account's satoshis, 0 if it has no entry.
325pub fn balance(ledger: &Ledger, account: &str) -> Result<u64> {
326    let a = account_of(account)?;
327    match entry_index(ledger, &a) {
328        Some(i) => sats(&ledger.entries[i].amount),
329        None => Ok(0),
330    }
331}
332
333fn set_balance(ledger: &mut Ledger, account: &str, n: u64) {
334    match entry_index(ledger, account) {
335        Some(i) => ledger.entries[i].amount = n.to_string(),
336        None => ledger.entries.push(Entry {
337            r#type: "Entry".to_owned(),
338            url: account.to_owned(),
339            amount: n.to_string(),
340            extra: Map::new(),
341        }),
342    }
343}
344
345fn touch(ledger: &mut Ledger, now: u64) {
346    ledger.updated = ledger.updated.max(now);
347}
348
349/// `total`: every balance added up.
350pub fn total(ledger: &Ledger) -> Result<u64> {
351    ledger.entries.iter().try_fold(0u64, |s, e| {
352        s.checked_add(sats(&e.amount)?).ok_or(Error::Amount)
353    })
354}
355
356/// Whether a move changed the ledger (`{ applied, why }`).
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub enum Outcome {
359    /// It was applied.
360    Applied,
361    /// It had been applied before and changed nothing; why, in the teller's
362    /// words (`"already credited"`, `"already applied"`).
363    Skipped(&'static str),
364}
365
366impl Outcome {
367    /// `applied`.
368    pub fn applied(&self) -> bool {
369        matches!(self, Outcome::Applied)
370    }
371
372    /// `why`, for a move that changed nothing.
373    pub fn why(&self) -> Option<&'static str> {
374        match self {
375            Outcome::Applied => None,
376            Outcome::Skipped(w) => Some(w),
377        }
378    }
379}
380
381/// A deposit seen on-chain (`credit`'s second argument).
382#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct Credit {
384    /// The account whose deposit address it paid.
385    pub account: String,
386    /// The paying transaction, 64 lowercase hex.
387    pub txid: String,
388    /// The output's index.
389    pub vout: u32,
390    /// Satoshis.
391    pub value: u64,
392    /// The block height it was seen at.
393    pub height: Option<u64>,
394}
395
396/// `credit`: a deposit credits its account once; the outpoint is the
397/// receipt. A second credit of the same outpoint is
398/// `Skipped("already credited")`.
399pub fn credit(ledger: &mut Ledger, c: &Credit, now: u64) -> Result<Outcome> {
400    let a = account_of(&c.account)?;
401    let key = format!("{}:{}", c.txid, c.vout);
402    let lower_hex = c.txid.len() == 64
403        && c.txid
404            .bytes()
405            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
406    if !lower_hex {
407        return Err(Error::Outpoint);
408    }
409    if ledger.deposits.iter().any(|d| d.outpoint == key) {
410        return Ok(Outcome::Skipped("already credited"));
411    }
412    let v = check_sats(c.value)?;
413    let b = balance(ledger, &a)?;
414    set_balance(ledger, &a, b + v);
415    ledger.deposits.push(Deposit {
416        outpoint: key,
417        account: a,
418        value: v,
419        height: c.height,
420        at: now,
421    });
422    touch(ledger, now);
423    Ok(Outcome::Applied)
424}
425
426/// A transfer between accounts, from a request already verified
427/// ([`crate::parse_request`]).
428#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct Transfer {
430    /// The request's id: the transfer is applied once by it.
431    pub id: String,
432    /// The paying account.
433    pub from: String,
434    /// The receiving account.
435    pub to: String,
436    /// Satoshis, at least one.
437    pub amount: u64,
438}
439
440fn short(account: &str) -> String {
441    account.chars().take(20).collect()
442}
443
444/// `transfer`: whole satoshis between accounts, applied once by the
445/// request's id, never overdrawing ([`Error::Insufficient`]).
446pub fn transfer(ledger: &mut Ledger, t: &Transfer, now: u64) -> Result<Outcome> {
447    let f = account_of(&t.from)?;
448    let to = account_of(&t.to)?;
449    let v = check_sats(t.amount)?;
450    if v == 0 {
451        return Err(Error::ZeroTransfer);
452    }
453    if ledger.applied.contains(&t.id) {
454        return Ok(Outcome::Skipped("already applied"));
455    }
456    let has = balance(ledger, &f)?;
457    if has < v {
458        return Err(Error::Insufficient {
459            account: short(&f),
460            has,
461            wanted: v,
462        });
463    }
464    set_balance(ledger, &f, has - v);
465    let b = balance(ledger, &to)?;
466    set_balance(ledger, &to, b + v);
467    ledger.applied.push(t.id.clone());
468    touch(ledger, now);
469    Ok(Outcome::Applied)
470}
471
472/// A withdrawal paid out (`debit`'s second argument).
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct Debit {
475    /// The request's id: the debit is applied once by it.
476    pub id: String,
477    /// The account debited.
478    pub account: String,
479    /// Satoshis, at least [`crate::MIN_PAY`].
480    pub amount: u64,
481    /// Where it was paid.
482    pub to: String,
483    /// The payout transaction's id.
484    pub txid: String,
485}
486
487/// `debit`: a withdrawal debits the account when the operator pays it out;
488/// at least [`crate::MIN_PAY`], once by its id, never overdrawing. The payout
489/// is recorded with its txid as the receipt.
490pub fn debit(ledger: &mut Ledger, d: &Debit, now: u64) -> Result<Outcome> {
491    let a = account_of(&d.account)?;
492    let v = check_sats(d.amount)?;
493    if v < crate::MIN_PAY {
494        return Err(Error::WithdrawalTooSmall);
495    }
496    if ledger.applied.contains(&d.id) {
497        return Ok(Outcome::Skipped("already applied"));
498    }
499    let has = balance(ledger, &a)?;
500    if has < v {
501        return Err(Error::Insufficient {
502            account: short(&a),
503            has,
504            wanted: v,
505        });
506    }
507    set_balance(ledger, &a, has - v);
508    ledger.applied.push(d.id.clone());
509    ledger.payouts.push(Payout {
510        id: d.id.clone(),
511        account: a,
512        value: v,
513        to: Some(d.to.clone()),
514        txid: Some(d.txid.clone()),
515        at: now,
516    });
517    touch(ledger, now);
518    Ok(Outcome::Applied)
519}
520
521// ---- the ledger on Nostr (the teller's page: teller.js ledgerEvent and the reader beside it)
522
523/// The ledger as an addressable Nostr event template (kind
524/// [`LEDGER_KIND`], `d` = its hash), as the teller's page publishes it:
525/// tags `d`, `t` = [`LEDGER_TOPIC`], `name`, `alt`; content the ledger's
526/// JSON. `pubkey` is filled in by the signer.
527pub fn ledger_template(ledger: &Ledger, created_at: u64) -> UnsignedEvent {
528    UnsignedEvent {
529        pubkey: String::new(),
530        created_at,
531        kind: LEDGER_KIND,
532        tags: vec![
533            vec!["d".into(), ledger.hash.clone()],
534            vec!["t".into(), LEDGER_TOPIC.into()],
535            vec!["name".into(), ledger.name.clone()],
536            vec![
537                "alt".into(),
538                format!("Web Ledger {} ({})", ledger.name, ledger.default_currency),
539            ],
540        ],
541        content: serde_json::to_string(ledger).expect("a ledger serialises"),
542    }
543}
544
545/// [`ledger_template`] signed with the operator's secret.
546pub fn ledger_event(
547    operator_secret: &SecretKey,
548    ledger: &Ledger,
549    created_at: u64,
550) -> Result<Event> {
551    crate::sign::sign_event(operator_secret, ledger_template(ledger, created_at))
552}
553
554/// A ledger event read back as the teller's page reads one: kind
555/// [`LEDGER_KIND`], verified, its content a ledger that passes
556/// [`check_ledger`], for the ledger `hash`, and signed by that ledger's
557/// operator — "the operator's own word only".
558pub fn read_ledger_event(ev: &Event, hash: &str) -> Result<Ledger> {
559    if ev.kind != LEDGER_KIND {
560        return Err(Error::NotALedgerEvent);
561    }
562    ev.verify().map_err(|e| Error::Event(e.to_string()))?;
563    let doc = parse_ledger(&ev.content).map_err(|e| match e {
564        Error::HashMismatch => e,
565        other => Error::LedgerContent(other.to_string()),
566    })?;
567    if doc.hash != hash || x_of(&doc.genesis.operator)? != ev.pubkey {
568        return Err(Error::NotTheOperators);
569    }
570    Ok(doc)
571}
572
573/// Of several ledger events, the newest the operator signed for ledger
574/// `hash` (the page's choice: the greatest `created_at`, the first seen on a
575/// tie); events that do not pass [`read_ledger_event`] are passed over.
576pub fn latest_ledger<'e>(
577    events: impl IntoIterator<Item = &'e Event>,
578    hash: &str,
579) -> Option<(Ledger, &'e Event)> {
580    let mut best: Option<(Ledger, &'e Event)> = None;
581    for ev in events {
582        let Ok(doc) = read_ledger_event(ev, hash) else {
583            continue;
584        };
585        if best
586            .as_ref()
587            .is_none_or(|(_, b)| ev.created_at > b.created_at)
588        {
589            best = Some((doc, ev));
590        }
591    }
592    best
593}