use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
const TREASURY_LEDGER_DIR: &str = "treasury-ledger";
#[derive(Debug, Error)]
pub enum TreasuryError {
#[error("no treasury cap established for manager '{0}' — run `auths treasury open` first")]
NotOpened(String),
#[error("a treasury cap is already established for manager '{0}'")]
AlreadyOpen(String),
#[error("unsafe manager key for treasury ledger: '{0}'")]
UnsafeKey(String),
#[error("unknown sub-agent slice: '{0}'")]
UnknownSlice(String),
#[error("treasury ledger persistence failed: {0}")]
Persistence(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Slice {
pub agent_did: String,
pub amount: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct TreasuryRecord {
manager: String,
parent_cap: u64,
slices: Vec<Slice>,
}
impl TreasuryRecord {
fn committed(&self) -> u64 {
self.slices.iter().map(|s| s.amount).sum()
}
fn slice_mut(&mut self, did: &str) -> Option<&mut Slice> {
self.slices.iter_mut().find(|s| s.agent_did == did)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Opened {
parent_cap: u64,
},
Allotted {
agent_did: String,
amount: u64,
},
Reallocated {
from: String,
to: String,
amount: u64,
},
Subdelegated {
parent: String,
child: String,
amount: u64,
},
Reclaimed {
agent_did: String,
freed: u64,
},
NothingToReclaim,
AggregateCapExceeded {
parent_cap: u64,
committed: u64,
requested: u64,
},
}
impl Verdict {
pub fn status(&self) -> &'static str {
match self {
Verdict::Opened { .. } => "opened",
Verdict::Allotted { .. } => "allotted",
Verdict::Reallocated { .. } => "reallocated",
Verdict::Subdelegated { .. } => "subdelegated",
Verdict::Reclaimed { .. } => "reclaimed",
Verdict::NothingToReclaim => "nothing_to_reclaim",
Verdict::AggregateCapExceeded { .. } => "aggregate_cap_exceeded",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TreasuryStatus {
pub status: &'static str,
pub parent_cap: u64,
pub committed: u64,
pub free_pool: u64,
pub slices: Vec<Slice>,
}
pub struct TreasuryLedger {
dir: PathBuf,
}
impl TreasuryLedger {
pub fn new(repo_path: &Path) -> Self {
Self {
dir: repo_path.join(TREASURY_LEDGER_DIR),
}
}
fn record_path(&self, manager: &str) -> Result<PathBuf, TreasuryError> {
Ok(self.dir.join(format!("{}.json", safe_key(manager)?)))
}
fn read(&self, manager: &str) -> Result<Option<TreasuryRecord>, TreasuryError> {
let path = self.record_path(manager)?;
match fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| TreasuryError::Persistence(format!("record parse failed: {e}"))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(TreasuryError::Persistence(format!("read failed: {e}"))),
}
}
fn write(&self, rec: &TreasuryRecord) -> Result<(), TreasuryError> {
fs::create_dir_all(&self.dir)
.map_err(|e| TreasuryError::Persistence(format!("mkdir failed: {e}")))?;
let key = safe_key(&rec.manager)?;
let path = self.dir.join(format!("{key}.json"));
let body = serde_json::to_vec_pretty(rec)
.map_err(|e| TreasuryError::Persistence(format!("encode failed: {e}")))?;
let tmp = self.dir.join(format!(".{key}.tmp"));
fs::write(&tmp, &body)
.map_err(|e| TreasuryError::Persistence(format!("temp write failed: {e}")))?;
fs::rename(&tmp, &path)
.map_err(|e| TreasuryError::Persistence(format!("commit (rename) failed: {e}")))?;
Ok(())
}
pub fn open(&self, manager: &str, parent_cap: u64) -> Result<Verdict, TreasuryError> {
if let Some(existing) = self.read(manager)? {
if existing.parent_cap == parent_cap && existing.slices.is_empty() {
return Ok(Verdict::Opened { parent_cap });
}
return Err(TreasuryError::AlreadyOpen(manager.to_string()));
}
self.write(&TreasuryRecord {
manager: manager.to_string(),
parent_cap,
slices: Vec::new(),
})?;
Ok(Verdict::Opened { parent_cap })
}
pub fn allot(
&self,
manager: &str,
agent_did: &str,
amount: u64,
) -> Result<Verdict, TreasuryError> {
let mut rec = self
.read(manager)?
.ok_or_else(|| TreasuryError::NotOpened(manager.to_string()))?;
let committed = rec.committed();
if committed.saturating_add(amount) > rec.parent_cap {
return Ok(Verdict::AggregateCapExceeded {
parent_cap: rec.parent_cap,
committed,
requested: amount,
});
}
match rec.slice_mut(agent_did) {
Some(s) => s.amount += amount,
None => rec.slices.push(Slice {
agent_did: agent_did.to_string(),
amount,
}),
}
self.write(&rec)?;
Ok(Verdict::Allotted {
agent_did: agent_did.to_string(),
amount,
})
}
pub fn reallocate(
&self,
manager: &str,
from: &str,
to: &str,
amount: u64,
) -> Result<Verdict, TreasuryError> {
let mut rec = self
.read(manager)?
.ok_or_else(|| TreasuryError::NotOpened(manager.to_string()))?;
let held = rec
.slice_mut(from)
.ok_or_else(|| TreasuryError::UnknownSlice(from.to_string()))?
.amount;
if held < amount {
return Ok(Verdict::AggregateCapExceeded {
parent_cap: rec.parent_cap,
committed: rec.committed(),
requested: amount,
});
}
if rec.slice_mut(to).is_none() {
rec.slices.push(Slice {
agent_did: to.to_string(),
amount: 0,
});
}
rec.slice_mut(from)
.ok_or_else(|| TreasuryError::UnknownSlice(from.to_string()))?
.amount -= amount;
rec.slice_mut(to)
.ok_or_else(|| TreasuryError::UnknownSlice(to.to_string()))?
.amount += amount;
self.write(&rec)?;
Ok(Verdict::Reallocated {
from: from.to_string(),
to: to.to_string(),
amount,
})
}
pub fn status(&self, manager: &str) -> Result<TreasuryStatus, TreasuryError> {
let rec = self
.read(manager)?
.ok_or_else(|| TreasuryError::NotOpened(manager.to_string()))?;
let committed = rec.committed();
Ok(TreasuryStatus {
status: if committed <= rec.parent_cap {
"valid"
} else {
"aggregate_cap_exceeded"
},
parent_cap: rec.parent_cap,
committed,
free_pool: rec.parent_cap.saturating_sub(committed),
slices: rec.slices,
})
}
pub fn reclaim(&self, manager: &str, agent_did: &str) -> Result<Verdict, TreasuryError> {
let mut rec = self
.read(manager)?
.ok_or_else(|| TreasuryError::NotOpened(manager.to_string()))?;
match rec.slices.iter().position(|s| s.agent_did == agent_did) {
Some(pos) => {
let freed = rec.slices.remove(pos).amount;
self.write(&rec)?;
Ok(Verdict::Reclaimed {
agent_did: agent_did.to_string(),
freed,
})
}
None => Ok(Verdict::NothingToReclaim),
}
}
}
fn safe_key(manager: &str) -> Result<String, TreasuryError> {
let tail = manager.strip_prefix("did:keri:").unwrap_or(manager);
let safe = !tail.is_empty()
&& tail != "."
&& tail != ".."
&& tail
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if !safe {
return Err(TreasuryError::UnsafeKey(manager.to_string()));
}
Ok(tail.to_string())
}
pub fn open(repo_path: &Path, manager: &str, parent_cap: u64) -> Result<Verdict, TreasuryError> {
TreasuryLedger::new(repo_path).open(manager, parent_cap)
}
pub fn allot(
repo_path: &Path,
manager: &str,
agent_did: &str,
amount: u64,
) -> Result<Verdict, TreasuryError> {
TreasuryLedger::new(repo_path).allot(manager, agent_did, amount)
}
pub fn reallocate(
repo_path: &Path,
manager: &str,
from: &str,
to: &str,
amount: u64,
) -> Result<Verdict, TreasuryError> {
TreasuryLedger::new(repo_path).reallocate(manager, from, to, amount)
}
pub fn status(repo_path: &Path, manager: &str) -> Result<TreasuryStatus, TreasuryError> {
TreasuryLedger::new(repo_path).status(manager)
}
pub fn reclaim(repo_path: &Path, manager: &str, agent_did: &str) -> Result<Verdict, TreasuryError> {
TreasuryLedger::new(repo_path).reclaim(manager, agent_did)
}
const CREDIT_LEDGER_DIR: &str = "credit-ledger";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CreditVerdict {
Credited {
cents: u64,
},
CreditMismatch {
recorded_cents: u64,
claimed_cents: u64,
},
}
impl CreditVerdict {
pub fn status(&self) -> &'static str {
match self {
CreditVerdict::Credited { .. } => "credited",
CreditVerdict::CreditMismatch { .. } => "credit_mismatch",
}
}
pub fn credited_cents(&self) -> u64 {
match self {
CreditVerdict::Credited { cents } => *cents,
CreditVerdict::CreditMismatch { .. } => 0,
}
}
}
pub fn credit(
repo_path: &Path,
agent_did: &str,
settlement_path: &Path,
claim_cents: Option<u64>,
) -> Result<CreditVerdict, TreasuryError> {
let bytes = fs::read(settlement_path)
.map_err(|e| TreasuryError::Persistence(format!("settlement read failed: {e}")))?;
let v: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| TreasuryError::Persistence(format!("settlement parse failed: {e}")))?;
let decimals = v
.get("decimals")
.and_then(serde_json::Value::as_u64)
.unwrap_or(6);
let atomic_str = v
.pointer("/settlement/amountAtomic")
.or_else(|| v.pointer("/requirements/maxAmountRequired"))
.and_then(serde_json::Value::as_str)
.ok_or_else(|| TreasuryError::Persistence("settlement missing amountAtomic".into()))?;
let atomic: u128 = atomic_str.parse().map_err(|_| {
TreasuryError::Persistence(format!("non-numeric settlement amount '{atomic_str}'"))
})?;
let scale = u32::try_from(decimals)
.ok()
.and_then(|d| 10u128.checked_pow(d))
.ok_or_else(|| {
TreasuryError::Persistence(format!("settlement decimals {decimals} out of range"))
})?;
let cents = atomic
.checked_mul(100)
.map(|scaled| scaled / scale)
.and_then(|c| u64::try_from(c).ok())
.ok_or_else(|| {
TreasuryError::Persistence(format!("settlement amount '{atomic_str}' out of range"))
})?;
if let Some(claimed) = claim_cents
&& claimed != cents
{
return Ok(CreditVerdict::CreditMismatch {
recorded_cents: cents,
claimed_cents: claimed,
});
}
let dir = repo_path.join(CREDIT_LEDGER_DIR);
let key = safe_key(agent_did)?;
fs::create_dir_all(&dir)
.map_err(|e| TreasuryError::Persistence(format!("mkdir failed: {e}")))?;
let path = dir.join(format!("{key}.json"));
let prior = fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|j| j.get("credited_cents").and_then(serde_json::Value::as_u64))
.unwrap_or(0);
let body = serde_json::to_vec_pretty(&serde_json::json!({
"agent_did": agent_did,
"credited_cents": prior.saturating_add(cents),
"rail": "x402",
"direction": "inbound",
}))
.map_err(|e| TreasuryError::Persistence(format!("encode failed: {e}")))?;
let tmp = dir.join(format!(".{key}.tmp"));
fs::write(&tmp, &body)
.map_err(|e| TreasuryError::Persistence(format!("temp write failed: {e}")))?;
fs::rename(&tmp, &path)
.map_err(|e| TreasuryError::Persistence(format!("commit (rename) failed: {e}")))?;
Ok(CreditVerdict::Credited { cents })
}
const SUBDELEGATION_LEDGER_DIR: &str = "subdelegation-ledger";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct SubdelRecord {
parent: String,
children: Vec<Slice>,
}
pub fn subdelegate(
repo_path: &Path,
manager: &str,
parent_did: &str,
child_did: &str,
amount: u64,
) -> Result<Verdict, TreasuryError> {
let parent_held = TreasuryLedger::new(repo_path)
.status(manager)?
.slices
.into_iter()
.find(|s| s.agent_did == parent_did)
.ok_or_else(|| TreasuryError::UnknownSlice(parent_did.to_string()))?
.amount;
let dir = repo_path.join(SUBDELEGATION_LEDGER_DIR);
let key = safe_key(parent_did)?;
let path = dir.join(format!("{key}.json"));
let mut rec: SubdelRecord = match fs::read(&path) {
Ok(b) => serde_json::from_slice(&b)
.map_err(|e| TreasuryError::Persistence(format!("subdel parse failed: {e}")))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => SubdelRecord {
parent: parent_did.to_string(),
children: Vec::new(),
},
Err(e) => {
return Err(TreasuryError::Persistence(format!(
"subdel read failed: {e}"
)));
}
};
let children_sum: u64 = rec
.children
.iter()
.fold(0u64, |acc, c| acc.saturating_add(c.amount));
if children_sum.saturating_add(amount) > parent_held {
return Ok(Verdict::AggregateCapExceeded {
parent_cap: parent_held,
committed: children_sum,
requested: amount,
});
}
rec.children.push(Slice {
agent_did: child_did.to_string(),
amount,
});
fs::create_dir_all(&dir)
.map_err(|e| TreasuryError::Persistence(format!("mkdir failed: {e}")))?;
let body = serde_json::to_vec_pretty(&rec)
.map_err(|e| TreasuryError::Persistence(format!("encode failed: {e}")))?;
let tmp = dir.join(format!(".{key}.tmp"));
fs::write(&tmp, &body)
.map_err(|e| TreasuryError::Persistence(format!("temp write failed: {e}")))?;
fs::rename(&tmp, &path)
.map_err(|e| TreasuryError::Persistence(format!("commit (rename) failed: {e}")))?;
Ok(Verdict::Subdelegated {
parent: parent_did.to_string(),
child: child_did.to_string(),
amount,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn led() -> (tempfile::TempDir, TreasuryLedger) {
let dir = tempfile::tempdir().unwrap();
let l = TreasuryLedger::new(dir.path());
(dir, l)
}
#[test]
fn allotments_within_cap_hold_and_overflow_is_refused() {
let (_d, l) = led();
l.open("manager", 10).unwrap();
for (a, n) in [("flip", 4), ("x402", 1), ("yield", 3), ("arb", 2)] {
assert!(matches!(
l.allot("manager", a, n).unwrap(),
Verdict::Allotted { .. }
));
}
let st = l.status("manager").unwrap();
assert_eq!(st.committed, 10);
assert_eq!(st.free_pool, 0);
assert_eq!(st.status, "valid");
assert!(matches!(
l.allot("manager", "extra", 1).unwrap(),
Verdict::AggregateCapExceeded { .. }
));
assert_eq!(l.status("manager").unwrap().committed, 10);
}
#[test]
fn an_allotment_whose_sum_would_wrap_past_u64_max_is_refused() {
let (_d, l) = led();
l.open("manager", 100).unwrap();
assert!(matches!(
l.allot("manager", "a", 50).unwrap(),
Verdict::Allotted { .. }
));
assert!(matches!(
l.allot("manager", "b", u64::MAX - 10).unwrap(),
Verdict::AggregateCapExceeded { .. }
));
assert_eq!(l.status("manager").unwrap().committed, 50);
}
#[test]
fn reallocation_is_constant_sum_and_underflow_is_refused() {
let (_d, l) = led();
l.open("m", 10).unwrap();
l.allot("m", "flip", 4).unwrap();
l.allot("m", "yield", 3).unwrap();
l.allot("m", "arb", 2).unwrap();
assert!(matches!(
l.reallocate("m", "yield", "flip", 2).unwrap(),
Verdict::Reallocated { .. }
));
let st = l.status("m").unwrap();
assert_eq!(st.committed, 9);
assert_eq!(
st.slices
.iter()
.find(|s| s.agent_did == "flip")
.unwrap()
.amount,
6
);
assert!(matches!(
l.reallocate("m", "arb", "flip", 4).unwrap(),
Verdict::AggregateCapExceeded { .. }
));
assert_eq!(
l.status("m")
.unwrap()
.slices
.iter()
.find(|s| s.agent_did == "flip")
.unwrap()
.amount,
6
);
}
#[test]
fn unsafe_manager_key_is_refused() {
let (_d, l) = led();
assert!(matches!(
l.open("../escape", 10),
Err(TreasuryError::UnsafeKey(_))
));
}
#[test]
fn x402_credit_extracts_cents_and_rejects_a_padded_claim() {
let dir = tempfile::tempdir().unwrap();
let settlement = dir.path().join("s.json");
std::fs::write(
&settlement,
br#"{"decimals":6,"settlement":{"amountAtomic":"2500000"}}"#,
)
.unwrap();
let v = credit(dir.path(), "did:keri:Ex402", &settlement, None).unwrap();
assert_eq!(v, CreditVerdict::Credited { cents: 250 });
let v = credit(dir.path(), "did:keri:Ex402", &settlement, Some(99999)).unwrap();
assert!(matches!(
v,
CreditVerdict::CreditMismatch {
recorded_cents: 250,
..
}
));
}
#[test]
fn subdelegation_within_parent_holding_holds_and_overflow_is_refused() {
let dir = tempfile::tempdir().unwrap();
let l = TreasuryLedger::new(dir.path());
l.open("m", 10).unwrap();
l.allot("m", "flip", 4).unwrap();
assert!(matches!(
subdelegate(dir.path(), "m", "flip", "child1", 2).unwrap(),
Verdict::Subdelegated { .. }
));
assert!(matches!(
subdelegate(dir.path(), "m", "flip", "child2", 3).unwrap(),
Verdict::AggregateCapExceeded { .. }
));
assert!(matches!(
subdelegate(dir.path(), "m", "flip", "child3", 2).unwrap(),
Verdict::Subdelegated { .. }
));
}
#[test]
fn reclaim_frees_a_slice_to_the_pool_and_is_idempotent() {
let (_d, l) = led();
l.open("m", 10).unwrap();
l.allot("m", "flip", 4).unwrap();
l.allot("m", "arb", 2).unwrap();
assert_eq!(l.status("m").unwrap().free_pool, 4);
assert!(matches!(
l.reclaim("m", "arb").unwrap(),
Verdict::Reclaimed { freed: 2, .. }
));
let st = l.status("m").unwrap();
assert_eq!(st.committed, 4);
assert_eq!(st.free_pool, 6);
assert!(matches!(
l.reclaim("m", "arb").unwrap(),
Verdict::NothingToReclaim
));
assert_eq!(l.status("m").unwrap().free_pool, 6);
}
}