open-standards/wai/wai-ledger/src/ledger.rs
Transaction Science fedbf6a562 Tariff version 7 bills an instance once and a batch in one period, reads the leader's statement whole, and its service issues the month
A review reproduced four ways to bill against the extension's own text, and each is closed here in
wai-rs and in receipt_ledger_verify.py:

- One node schedule in two cache sources billed twice. A group or cache source's heads are now its
  claims, and an instance of a group that two sources of an invoice declare is refused.
- Overlapping batches billed across two invoices of a chain. An aggregated source is in a period
  when its batch is, read through its task's time_precision, so a verifier given one invoice sees it.
- A node billing the publisher's own instance beside the publisher: bearer_contradicted.
- wai-ledger could not issue the corpus's second week, since a group's head was its group receipt.
  It now issues the month and the week after it byte for byte, and a test holds it to that.

The leader's statement is read whole (statement_invalid, wrong_signer, batch_invalid), and the task
through its time_precision (task_invalid). Section 5.2 now says what is enforced, 5.3 holds a cache
source to the entries it bills, and 4.10 and 12 state what a period rule leaves unbilled and what a
declaration's bytes bound.

Two accepted vectors hold what no control did: two groups on one broadcast service, and a leader's
statement of version 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 08:35:01 -05:00

237 lines
11 KiB
Rust

//! The ledger's state: each tariff with its held sources and its chain of invoices
//! (extensions/receipt-ledger.md §6.9, §9). Every change is written to the directory before it
//! is answered, and [`Ledger::open`] rebuilds the state from the directory, verifying again
//! every tariff, source and invoice it reads.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use ed25519_dalek::SigningKey;
use serde_json::{json, Value};
use wai::receipt_ledger::{self as rl, Facts, Invoice, Reason, Refusal, Tariff};
use crate::store::{name, numbered, write_whole, Dir};
pub fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
pub fn unhex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) || !s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) {
return None;
}
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
}
/// Why a request was not done: a refusal with its reason and place, or a failure to write.
#[derive(Debug)]
pub enum Error {
Refused(Refusal),
Store(String),
}
impl From<Refusal> for Error {
fn from(r: Refusal) -> Error {
Error::Refused(r)
}
}
fn refuse(code: &'static str, at: Value) -> Error {
Error::Refused((code, at))
}
fn store(e: String) -> Error {
Error::Store(e)
}
/// A source the ledger holds: its document and what its verification gave.
#[derive(Clone)]
pub struct Held {
pub doc: Value,
pub facts: Facts,
}
/// One tariff and what is held under it.
pub struct Chain {
pub tariff: Tariff,
pub sources: Vec<Held>,
/// The chain, in order: each invoice's bytes and its verification document.
pub invoices: Vec<(Invoice, Vec<u8>, Value)>,
/// The records of every invoice of the chain (§6.9); of them, a source may not bring again
/// those its tariff's version bills once ([`rl::billed_once`]).
pub billed: BTreeSet<(u8, [u8; 32])>,
}
impl Chain {
fn billed(&self, h: &Held) -> bool {
h.facts.heads().iter().all(|r| self.billed.contains(r))
}
/// Whether the next invoice bills `h`: a source the chain has not billed, or, under a tariff
/// of version 4 or 5, a package the last invoice holds, whose storage each period bills (§4.7).
fn bills(&self, h: &Held) -> bool {
let held = || {
matches!(h.facts, Facts::Package(_)) && rl::bills_spending(self.tariff.version) && self.invoices.last().is_some_and(|(i, _, _)| h.facts.heads().iter().all(|r| i.records.contains(r)))
};
!self.billed(h) || held()
}
}
pub struct Ledger {
dir: Dir,
key: SigningKey,
pub chains: BTreeMap<[u8; 32], Chain>,
}
/// An issued invoice: its bytes, its verification document and the outcome a verifier gives it.
pub struct Issued {
pub invoice: Vec<u8>,
pub document: Value,
pub outcome: Value,
}
impl Ledger {
/// Open the directory and rebuild the state from it.
pub fn open(root: &Path, key: SigningKey) -> Result<Ledger, String> {
let dir = Dir::open(root)?;
let mut chains = BTreeMap::new();
let rd = std::fs::read_dir(dir.root.join("tariffs")).map_err(|e| e.to_string())?;
let mut dirs: Vec<_> = rd.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect();
dirs.sort();
for d in dirs {
let at = d.display().to_string();
let tb = std::fs::read(d.join("tariff.wlt")).map_err(|e| format!("{at}: {e}"))?;
let tariff = Tariff::parse(&tb).map_err(|(r, w)| format!("{at}: the tariff is refused {r} at {w}"))?;
if tariff.seller != key.verifying_key().to_bytes() {
return Err(format!("{at}: the tariff's seller is not this ledger's key"));
}
let mut chain = Chain { tariff, sources: Vec::new(), invoices: Vec::new(), billed: BTreeSet::new() };
for (_, p) in numbered(&d.join("sources"), "json")? {
let text = std::fs::read_to_string(&p).map_err(|e| format!("{}: {e}", p.display()))?;
let doc: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", p.display()))?;
let facts = rl::verify_source_of(&doc, &rl::signers(&chain.tariff), chain.tariff.version).map_err(|(r, w)| format!("{}: refused {r} at {w}", p.display()))?;
chain.sources.push(Held { doc, facts });
}
let mut prev = [0u8; 32];
for (n, p) in numbered(&d.join("invoices"), "wli")? {
let b = std::fs::read(&p).map_err(|e| format!("{}: {e}", p.display()))?;
let inv = Invoice::parse(&b).map_err(|r| format!("{}: refused {r}", p.display()))?;
if inv.prev != prev || inv.tariff != chain.tariff.digest() {
return Err(format!("{}: the chain is broken", p.display()));
}
let dp = d.join("invoices").join(name(n, "json"));
let doc: Value = std::fs::read_to_string(&dp).map_err(|e| format!("{}: {e}", dp.display())).and_then(|t| serde_json::from_str(&t).map_err(|e| format!("{}: {e}", dp.display())))?;
rl::verify(&doc).map_err(|(r, w)| format!("{}: refused {r} at {w}", dp.display()))?;
prev = inv.digest();
chain.billed.extend(inv.records.iter().copied());
chain.invoices.push((inv, b, doc));
}
chains.insert(chain.tariff.digest(), chain);
}
Ok(Ledger { dir, key, chains })
}
pub fn seller(&self) -> [u8; 32] {
self.key.verifying_key().to_bytes()
}
fn chain(&self, tariff: &[u8; 32]) -> Result<&Chain, Error> {
self.chains.get(tariff).ok_or(refuse("record_missing", json!(["tariff"])))
}
/// `POST /v1/tariffs`: a tariff whose seller is this ledger, both signatures verified.
pub fn add_tariff(&mut self, bytes: &[u8]) -> Result<[u8; 32], Error> {
let t = Tariff::parse(bytes)?;
if t.seller != self.seller() {
return Err(refuse(Reason::TariffMismatch.code(), json!(["tariff", "seller"])));
}
let d = t.digest();
if !self.chains.contains_key(&d) {
write_whole(&self.dir.tariff_dir(&hex(&d)).join("tariff.wlt"), bytes).map_err(store)?;
self.chains.insert(d, Chain { tariff: t, sources: Vec::new(), invoices: Vec::new(), billed: BTreeSet::new() });
}
Ok(d)
}
/// `POST /v1/sources`: the source verified (§7.2 step 5, against the tariff's own period)
/// and checked against everything held under the tariff (step 6), then kept.
pub fn add_source(&mut self, tariff: &[u8; 32], doc: Value) -> Result<Vec<(u8, [u8; 32])>, Error> {
let c = self.chain(tariff)?;
let facts = rl::verify_source_of(&doc, &rl::signers(&c.tariff), c.tariff.version).map_err(|(r, at)| {
let mut a = vec![json!("source")];
a.extend(at.as_array().cloned().unwrap_or_default());
Error::Refused((r, Value::Array(a)))
})?;
rl::check_source(&c.tariff, c.tariff.start, c.tariff.end, &facts).map_err(|r| refuse(r.code(), json!(["source"])))?;
let mut all: Vec<Facts> = c.sources.iter().map(|h| h.facts.clone()).collect();
all.push(facts.clone());
rl::check_across(c.tariff.version, &all, None).map_err(|(_, r)| refuse(r, json!(["source"])))?;
// A record the chain billed is billed never again; under a tariff of versions 3 to 5, a
// job's output receipt is cited again by a re-run, whose hop and route record are new
// (§6.9). A package source is brought once: the ledger holds it, and under versions 4 and
// 5 bills its storage each period from what it holds (§4.7).
let package_again = matches!(facts, Facts::Package(_)) && facts.heads().iter().all(|h| c.billed.contains(h));
if package_again || facts.records().iter().any(|r| rl::billed_once(c.tariff.version, r.0) && c.billed.contains(r)) {
return Err(refuse(Reason::RecordRebilled.code(), json!(["source"])));
}
let n = c.sources.len() as u64;
let path = self.dir.tariff_dir(&hex(tariff)).join("sources").join(name(n, "json"));
let text = serde_json::to_string(&doc).expect("JSON");
write_whole(&path, text.as_bytes()).map_err(store)?;
let heads = facts.heads();
self.chains.get_mut(tariff).expect("the chain").sources.push(Held { doc, facts });
Ok(heads)
}
/// `POST /v1/invoices`: the invoice of the period over the sources held and not yet billed
/// that lie in it (a package always does), chained, signed, verified and kept.
pub fn invoice(&mut self, tariff: &[u8; 32], start: u64, end: u64) -> Result<Issued, Error> {
let c = self.chain(tariff)?;
let t = &c.tariff;
let selected: Vec<Value> = c.sources.iter().filter(|h| c.bills(h) && rl::check_source(t, start, end, &h.facts).is_ok()).map(|h| h.doc.clone()).collect();
let prev = c.invoices.last().map(|(i, _, _)| i);
let inv = rl::make_invoice(&self.key, t, prev, start, end, &selected)?;
let bytes = inv.to_bytes();
let document = json!({
"tariff": hex(&t.to_bytes()),
"invoice": hex(&bytes),
"previous": c.invoices.last().map(|(_, b, _)| hex(b)),
"sources": selected,
});
// What a buyer will check: the document verifies, here, before it is kept.
let outcome = rl::outcome_json(&rl::verify(&document));
if outcome["outcome"] != "accept" {
return Err(store(format!("the invoice computed does not verify: {outcome}")));
}
let n = c.invoices.len() as u64;
let d = self.dir.tariff_dir(&hex(tariff)).join("invoices");
write_whole(&d.join(name(n, "json")), serde_json::to_string(&document).expect("JSON").as_bytes()).map_err(store)?;
write_whole(&d.join(name(n, "wli")), &bytes).map_err(store)?;
let c = self.chains.get_mut(tariff).expect("the chain");
c.billed.extend(inv.records.iter().copied());
c.invoices.push((inv, bytes.clone(), document.clone()));
Ok(Issued { invoice: bytes, document, outcome })
}
/// `GET /v1/invoices/{digest}`: the invoice and its verification document.
pub fn find_invoice(&self, digest: &[u8; 32]) -> Option<(&Vec<u8>, &Value)> {
self.chains.values().flat_map(|c| c.invoices.iter()).find(|(i, _, _)| i.digest() == *digest).map(|(_, b, d)| (b, d))
}
/// `GET /v1/tariffs/{digest}`: the tariff, its chain and what it holds.
pub fn describe_tariff(&self, digest: &[u8; 32]) -> Option<Value> {
let c = self.chains.get(digest)?;
let t = &c.tariff;
Some(json!({
"tariff": hex(digest),
"seller": hex(&t.seller),
"buyer": hex(&t.buyer),
"start": t.start,
"end": t.end,
"currency": String::from_utf8_lossy(&t.currency),
"invoices": c.invoices.iter().map(|(i, _, _)| json!({"digest": hex(&i.digest()), "start": i.start, "end": i.end, "amount": i.amount})).collect::<Vec<_>>(),
"sources_held": c.sources.len(),
"sources_unbilled": c.sources.iter().filter(|h| !c.billed(h)).count(),
}))
}
}