use bitcoin::secp256k1::SecretKey;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use sidestr_nostr::event::{Event, UnsignedEvent};
use crate::account::{account_of, check_sats, sats, x_of};
use crate::error::{Error, Result};
use crate::{CONTEXT, DEFAULT_CURRENCY, LEDGER_KIND};
pub const MAX_NAME: usize = 80;
pub const LEDGER_TOPIC: &str = "webledgers";
pub fn jcs(v: &Value) -> String {
let mut out = String::new();
write_jcs(v, &mut out);
out
}
fn write_jcs(v: &Value, out: &mut String) {
match v {
Value::Object(m) => {
let mut keys: Vec<&String> = m.keys().collect();
keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
out.push('{');
for (i, k) in keys.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&serde_json::to_string(k).expect("a string serialises"));
out.push(':');
write_jcs(&m[k], out);
}
out.push('}');
}
Value::Array(a) => {
out.push('[');
for (i, x) in a.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_jcs(x, out);
}
out.push(']');
}
Value::Number(n) if n.as_u64().is_none() && n.as_i64().is_none() => {
let f = n.as_f64().expect("a JSON number is finite");
if f.fract() == 0.0 && f.abs() < 1e21 {
out.push_str(&(f as i128).to_string());
} else {
out.push_str(&n.to_string());
}
}
other => out.push_str(&serde_json::to_string(other).expect("a scalar serialises")),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Genesis {
pub operator: String,
pub name: String,
pub currency: String,
pub created: u64,
pub confirmations: u64,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Entry {
pub r#type: String,
pub url: String,
pub amount: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Deposit {
pub outpoint: String,
pub account: String,
pub value: u64,
#[serde(default)]
pub height: Option<u64>,
pub at: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Payout {
pub id: String,
pub account: String,
pub value: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub txid: Option<String>,
pub at: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ledger {
#[serde(rename = "@context")]
pub context: String,
pub r#type: String,
pub id: String,
pub hash: String,
pub name: String,
#[serde(rename = "defaultCurrency")]
pub default_currency: String,
pub genesis: Genesis,
pub updated: u64,
pub entries: Vec<Entry>,
pub deposits: Vec<Deposit>,
pub applied: Vec<String>,
pub payouts: Vec<Payout>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LedgerParams<'a> {
pub operator: &'a str,
pub name: &'a str,
pub currency: &'a str,
pub created: u64,
pub confirmations: u64,
}
impl<'a> LedgerParams<'a> {
pub fn new(operator: &'a str, name: &'a str, created: u64) -> Self {
Self {
operator,
name,
currency: DEFAULT_CURRENCY,
created,
confirmations: 1,
}
}
}
pub fn genesis_hash(genesis: &Genesis) -> String {
let v = serde_json::to_value(genesis).expect("a genesis serialises");
hex::encode(Sha256::digest(jcs(&v).as_bytes()))
}
pub fn new_ledger(p: &LedgerParams<'_>) -> Result<Ledger> {
let genesis = Genesis {
operator: account_of(p.operator)?,
name: p.name.to_owned(),
currency: p.currency.to_owned(),
created: p.created,
confirmations: p.confirmations,
extra: Map::new(),
};
if genesis.name.is_empty() || genesis.name.encode_utf16().count() > MAX_NAME {
return Err(Error::Name);
}
let h = genesis_hash(&genesis);
Ok(Ledger {
context: CONTEXT.to_owned(),
r#type: "WebLedger".to_owned(),
id: format!("urn:webledgers:{h}"),
hash: h,
name: genesis.name.clone(),
default_currency: genesis.currency.clone(),
updated: genesis.created,
genesis,
entries: Vec::new(),
deposits: Vec::new(),
applied: Vec::new(),
payouts: Vec::new(),
extra: Map::new(),
})
}
pub fn ledger_hash(ledger: &Ledger) -> String {
genesis_hash(&ledger.genesis)
}
pub fn check_ledger(ledger: &Ledger) -> Result<()> {
if ledger.r#type != "WebLedger" {
return Err(Error::NotALedger);
}
if ledger_hash(ledger) != ledger.hash {
return Err(Error::HashMismatch);
}
Ok(())
}
pub fn parse_ledger(json: &str) -> Result<Ledger> {
let v: Value = serde_json::from_str(json).map_err(|_| Error::NotALedger)?;
if v.get("type").and_then(Value::as_str) != Some("WebLedger")
|| v.get("genesis").is_none_or(Value::is_null)
{
return Err(Error::NotALedger);
}
let ledger: Ledger =
serde_json::from_value(v).map_err(|e| Error::LedgerContent(e.to_string()))?;
check_ledger(&ledger)?;
Ok(ledger)
}
fn entry_index(ledger: &Ledger, account: &str) -> Option<usize> {
ledger.entries.iter().position(|e| e.url == account)
}
pub fn balance(ledger: &Ledger, account: &str) -> Result<u64> {
let a = account_of(account)?;
match entry_index(ledger, &a) {
Some(i) => sats(&ledger.entries[i].amount),
None => Ok(0),
}
}
fn set_balance(ledger: &mut Ledger, account: &str, n: u64) {
match entry_index(ledger, account) {
Some(i) => ledger.entries[i].amount = n.to_string(),
None => ledger.entries.push(Entry {
r#type: "Entry".to_owned(),
url: account.to_owned(),
amount: n.to_string(),
extra: Map::new(),
}),
}
}
fn touch(ledger: &mut Ledger, now: u64) {
ledger.updated = ledger.updated.max(now);
}
pub fn total(ledger: &Ledger) -> Result<u64> {
ledger.entries.iter().try_fold(0u64, |s, e| {
s.checked_add(sats(&e.amount)?).ok_or(Error::Amount)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Applied,
Skipped(&'static str),
}
impl Outcome {
pub fn applied(&self) -> bool {
matches!(self, Outcome::Applied)
}
pub fn why(&self) -> Option<&'static str> {
match self {
Outcome::Applied => None,
Outcome::Skipped(w) => Some(w),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Credit {
pub account: String,
pub txid: String,
pub vout: u32,
pub value: u64,
pub height: Option<u64>,
}
pub fn credit(ledger: &mut Ledger, c: &Credit, now: u64) -> Result<Outcome> {
let a = account_of(&c.account)?;
let key = format!("{}:{}", c.txid, c.vout);
let lower_hex = c.txid.len() == 64
&& c.txid
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
if !lower_hex {
return Err(Error::Outpoint);
}
if ledger.deposits.iter().any(|d| d.outpoint == key) {
return Ok(Outcome::Skipped("already credited"));
}
let v = check_sats(c.value)?;
let b = balance(ledger, &a)?;
set_balance(ledger, &a, b + v);
ledger.deposits.push(Deposit {
outpoint: key,
account: a,
value: v,
height: c.height,
at: now,
});
touch(ledger, now);
Ok(Outcome::Applied)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Transfer {
pub id: String,
pub from: String,
pub to: String,
pub amount: u64,
}
fn short(account: &str) -> String {
account.chars().take(20).collect()
}
pub fn transfer(ledger: &mut Ledger, t: &Transfer, now: u64) -> Result<Outcome> {
let f = account_of(&t.from)?;
let to = account_of(&t.to)?;
let v = check_sats(t.amount)?;
if v == 0 {
return Err(Error::ZeroTransfer);
}
if ledger.applied.contains(&t.id) {
return Ok(Outcome::Skipped("already applied"));
}
let has = balance(ledger, &f)?;
if has < v {
return Err(Error::Insufficient {
account: short(&f),
has,
wanted: v,
});
}
set_balance(ledger, &f, has - v);
let b = balance(ledger, &to)?;
set_balance(ledger, &to, b + v);
ledger.applied.push(t.id.clone());
touch(ledger, now);
Ok(Outcome::Applied)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Debit {
pub id: String,
pub account: String,
pub amount: u64,
pub to: String,
pub txid: String,
}
pub fn debit(ledger: &mut Ledger, d: &Debit, now: u64) -> Result<Outcome> {
let a = account_of(&d.account)?;
let v = check_sats(d.amount)?;
if v < crate::MIN_PAY {
return Err(Error::WithdrawalTooSmall);
}
if ledger.applied.contains(&d.id) {
return Ok(Outcome::Skipped("already applied"));
}
let has = balance(ledger, &a)?;
if has < v {
return Err(Error::Insufficient {
account: short(&a),
has,
wanted: v,
});
}
set_balance(ledger, &a, has - v);
ledger.applied.push(d.id.clone());
ledger.payouts.push(Payout {
id: d.id.clone(),
account: a,
value: v,
to: Some(d.to.clone()),
txid: Some(d.txid.clone()),
at: now,
});
touch(ledger, now);
Ok(Outcome::Applied)
}
pub fn ledger_template(ledger: &Ledger, created_at: u64) -> UnsignedEvent {
UnsignedEvent {
pubkey: String::new(),
created_at,
kind: LEDGER_KIND,
tags: vec![
vec!["d".into(), ledger.hash.clone()],
vec!["t".into(), LEDGER_TOPIC.into()],
vec!["name".into(), ledger.name.clone()],
vec![
"alt".into(),
format!("Web Ledger {} ({})", ledger.name, ledger.default_currency),
],
],
content: serde_json::to_string(ledger).expect("a ledger serialises"),
}
}
pub fn ledger_event(
operator_secret: &SecretKey,
ledger: &Ledger,
created_at: u64,
) -> Result<Event> {
crate::sign::sign_event(operator_secret, ledger_template(ledger, created_at))
}
pub fn read_ledger_event(ev: &Event, hash: &str) -> Result<Ledger> {
if ev.kind != LEDGER_KIND {
return Err(Error::NotALedgerEvent);
}
ev.verify().map_err(|e| Error::Event(e.to_string()))?;
let doc = parse_ledger(&ev.content).map_err(|e| match e {
Error::HashMismatch => e,
other => Error::LedgerContent(other.to_string()),
})?;
if doc.hash != hash || x_of(&doc.genesis.operator)? != ev.pubkey {
return Err(Error::NotTheOperators);
}
Ok(doc)
}
pub fn latest_ledger<'e>(
events: impl IntoIterator<Item = &'e Event>,
hash: &str,
) -> Option<(Ledger, &'e Event)> {
let mut best: Option<(Ledger, &'e Event)> = None;
for ev in events {
let Ok(doc) = read_ledger_event(ev, hash) else {
continue;
};
if best
.as_ref()
.is_none_or(|(_, b)| ev.created_at > b.created_at)
{
best = Some((doc, ev));
}
}
best
}