use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LedgerEntry {
#[serde(rename = "type")]
pub entry_type: String,
pub url: String,
pub amount: LedgerAmount,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum LedgerAmount {
Simple(String),
Multi(Vec<CurrencyAmount>),
}
impl LedgerAmount {
pub fn sats(&self) -> u64 {
match self {
LedgerAmount::Simple(s) => s.parse().unwrap_or(0),
LedgerAmount::Multi(v) => v
.iter()
.find(|a| a.currency == "satoshi" || a.currency == "sat")
.map(|a| a.value.parse().unwrap_or(0))
.unwrap_or(0),
}
}
pub fn set_sats(&mut self, amount: u64) {
match self {
LedgerAmount::Simple(s) => *s = amount.to_string(),
LedgerAmount::Multi(v) => {
if let Some(entry) = v
.iter_mut()
.find(|a| a.currency == "satoshi" || a.currency == "sat")
{
entry.value = amount.to_string();
} else {
v.push(CurrencyAmount {
currency: "satoshi".into(),
value: amount.to_string(),
});
}
}
}
}
pub fn chain_balance(&self, chain: &str) -> u64 {
match self {
LedgerAmount::Simple(_) => 0,
LedgerAmount::Multi(v) => v
.iter()
.find(|a| a.currency == chain)
.map(|a| a.value.parse().unwrap_or(0))
.unwrap_or(0),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrencyAmount {
pub currency: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebLedger {
#[serde(rename = "@context")]
pub context: String,
#[serde(rename = "type")]
pub ledger_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub name: String,
pub description: String,
#[serde(rename = "defaultCurrency")]
pub default_currency: String,
pub created: u64,
pub updated: u64,
pub entries: Vec<LedgerEntry>,
}
impl WebLedger {
pub fn new(name: &str) -> Self {
let now = now_secs();
Self {
context: "https://w3id.org/webledgers".into(),
ledger_type: "WebLedger".into(),
id: None,
name: name.into(),
description: "Paid API balance ledger".into(),
default_currency: "satoshi".into(),
created: now,
updated: now,
entries: Vec::new(),
}
}
pub fn get_balance(&self, did: &str) -> u64 {
self.entries
.iter()
.find(|e| e.url == did)
.map(|e| e.amount.sats())
.unwrap_or(0)
}
pub fn credit(&mut self, did: &str, amount: u64) {
self.updated = now_secs();
if let Some(entry) = self.entries.iter_mut().find(|e| e.url == did) {
let current = entry.amount.sats();
entry.amount.set_sats(current.saturating_add(amount));
} else {
self.entries.push(LedgerEntry {
entry_type: "Entry".into(),
url: did.into(),
amount: LedgerAmount::Simple(amount.to_string()),
});
}
}
pub fn debit(&mut self, did: &str, amount: u64) -> Result<u64, PaymentError> {
self.updated = now_secs();
let entry = self
.entries
.iter_mut()
.find(|e| e.url == did)
.ok_or(PaymentError::InsufficientBalance {
balance: 0,
cost: amount,
})?;
let current = entry.amount.sats();
if current < amount {
return Err(PaymentError::InsufficientBalance {
balance: current,
cost: amount,
});
}
entry.amount.set_sats(current - amount);
Ok(current - amount)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayConfig {
pub enabled: bool,
pub cost_sats: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<TokenConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub chains: Vec<ChainConfig>,
}
impl Default for PayConfig {
fn default() -> Self {
Self {
enabled: false,
cost_sats: 1,
token: None,
chains: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenConfig {
pub ticker: String,
pub rate: u64,
pub supply: u64,
pub issuer: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainConfig {
pub id: String,
pub unit: String,
pub name: String,
pub explorer_api: String,
}
impl ChainConfig {
pub fn bitcoin_mainnet() -> Self {
Self {
id: "btc".into(),
unit: "sat".into(),
name: "Bitcoin".into(),
explorer_api: "https://mempool.space/api".into(),
}
}
pub fn bitcoin_testnet3() -> Self {
Self {
id: "tbtc3".into(),
unit: "tbtc3".into(),
name: "Bitcoin Testnet3".into(),
explorer_api: "https://mempool.space/testnet/api".into(),
}
}
pub fn bitcoin_testnet4() -> Self {
Self {
id: "tbtc4".into(),
unit: "tbtc4".into(),
name: "Bitcoin Testnet4".into(),
explorer_api: "https://mempool.space/testnet4/api".into(),
}
}
pub fn bitcoin_signet() -> Self {
Self {
id: "signet".into(),
unit: "signet".into(),
name: "Bitcoin Signet".into(),
explorer_api: "https://mempool.space/signet/api".into(),
}
}
}
pub fn payment_required_body(balance: u64, cost: u64) -> serde_json::Value {
serde_json::json!({
"error": "Payment Required",
"balance": balance,
"cost": cost,
"unit": "sat",
"deposit": "/pay/.deposit",
"balance_endpoint": "/pay/.balance",
"spec": "https://webledgers.org"
})
}
pub fn pay_info(config: &PayConfig) -> serde_json::Value {
let mut info = serde_json::json!({
"cost": config.cost_sats,
"unit": "sat",
"deposit": "/pay/.deposit",
"balance": "/pay/.balance"
});
if let Some(ref token) = config.token {
info["token"] = serde_json::json!({
"ticker": token.ticker,
"rate": token.rate,
"buy": "/pay/.buy",
"withdraw": "/pay/.withdraw",
"supply": token.supply,
"issuer": token.issuer
});
}
if !config.chains.is_empty() {
info["chains"] = serde_json::json!(
config.chains.iter().map(|c| serde_json::json!({
"id": c.id,
"unit": c.unit,
"name": c.name
})).collect::<Vec<_>>()
);
info["pool"] = serde_json::json!("/pay/.pool");
}
info
}
pub fn balance_response(did: &str, balance: u64, cost: u64) -> serde_json::Value {
serde_json::json!({
"did": did,
"balance": balance,
"cost": cost,
"unit": "sat"
})
}
pub fn webledgers_discovery(pod_base: &str) -> serde_json::Value {
serde_json::json!({
"@context": "https://w3id.org/webledgers",
"type": "WebLedger",
"name": "Pod Credits",
"description": "Satoshi-denominated micropayments for pod resource access",
"defaultCurrency": "satoshi",
"endpoints": {
"info": "/pay/.info",
"balance": "/pay/.balance",
"deposit": "/pay/.deposit",
"ledger": "/.well-known/webledgers/webledgers.json"
},
"verification": {
"method": "mempool-api",
"url": "https://mempool.space/api/"
},
"server": pod_base
})
}
#[derive(Debug, Clone)]
pub struct TxoDeposit {
pub chain: Option<String>,
pub txid: String,
pub vout: u32,
}
pub fn parse_txo_uri(input: &str) -> Result<TxoDeposit, PaymentError> {
let trimmed = input.trim();
if let Some(rest) = trimmed.strip_prefix("txo:") {
let parts: Vec<&str> = rest.splitn(3, ':').collect();
if parts.len() == 3 {
let chain = parts[0].to_lowercase();
let txid = parts[1];
let vout: u32 = parts[2]
.parse()
.map_err(|_| PaymentError::InvalidTxo("bad vout".into()))?;
validate_txid(txid)?;
return Ok(TxoDeposit {
chain: Some(chain),
txid: txid.to_string(),
vout,
});
}
}
let cleaned = trimmed.strip_prefix("bitcoin:").unwrap_or(trimmed);
let parts: Vec<&str> = cleaned.split(':').collect();
if parts.len() != 2 {
return Err(PaymentError::InvalidTxo(
"expected txid:vout format".into(),
));
}
let txid = parts[0];
let vout: u32 = parts[1]
.parse()
.map_err(|_| PaymentError::InvalidTxo("bad vout".into()))?;
validate_txid(txid)?;
Ok(TxoDeposit {
chain: None,
txid: txid.to_string(),
vout,
})
}
fn validate_txid(txid: &str) -> Result<(), PaymentError> {
if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(PaymentError::InvalidTxo(
"txid must be 64 hex chars".into(),
));
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Mrc20State {
pub profile: String,
pub prev: String,
pub seq: u64,
pub ops: Vec<Mrc20Op>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anchor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Mrc20Op {
pub op: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amt: Option<u64>,
}
pub fn verify_state_link(state: &Mrc20State, prev_state: &Mrc20State) -> Result<(), PaymentError> {
let prev_json = serde_json::to_string(prev_state)
.map_err(|e| PaymentError::InvalidState(format!("serialize: {e}")))?;
let hash = hex::encode(sha2::Sha256::digest(prev_json.as_bytes()));
if state.prev != hash {
return Err(PaymentError::InvalidState(format!(
"chain break: expected prev {hash}, got {}",
state.prev
)));
}
if state.seq != prev_state.seq + 1 {
return Err(PaymentError::InvalidState(format!(
"sequence mismatch: expected {}, got {}",
prev_state.seq + 1,
state.seq
)));
}
Ok(())
}
#[async_trait::async_trait(?Send)]
pub trait PaymentStore: Send + Sync {
async fn read_ledger(&self) -> Result<WebLedger, PaymentError>;
async fn write_ledger(&self, ledger: &WebLedger) -> Result<(), PaymentError>;
async fn check_replay(&self, key: &str) -> Result<bool, PaymentError>;
async fn record_replay(&self, key: &str) -> Result<(), PaymentError>;
}
pub fn pubkey_to_did(pubkey: &str) -> String {
format!("did:nostr:{pubkey}")
}
pub fn did_to_pubkey(did: &str) -> Option<&str> {
did.strip_prefix("did:nostr:")
}
#[derive(Debug, thiserror::Error)]
pub enum PaymentError {
#[error("insufficient balance: have {balance}, need {cost}")]
InsufficientBalance { balance: u64, cost: u64 },
#[error("invalid TXO: {0}")]
InvalidTxo(String),
#[error("invalid MRC20 state: {0}")]
InvalidState(String),
#[error("replay detected: {0}")]
Replay(String),
#[error("payment store: {0}")]
Store(String),
}
use sha2::Digest;
fn now_secs() -> u64 {
#[cfg(target_arch = "wasm32")]
{
(js_sys::Date::now() / 1000.0) as u64
}
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_ledger_empty() {
let ledger = WebLedger::new("Test");
assert!(ledger.entries.is_empty());
assert_eq!(ledger.default_currency, "satoshi");
assert_eq!(ledger.context, "https://w3id.org/webledgers");
}
#[test]
fn credit_creates_entry() {
let mut ledger = WebLedger::new("Test");
ledger.credit("did:nostr:abc123", 1000);
assert_eq!(ledger.get_balance("did:nostr:abc123"), 1000);
}
#[test]
fn debit_reduces_balance() {
let mut ledger = WebLedger::new("Test");
ledger.credit("did:nostr:abc123", 1000);
let remaining = ledger.debit("did:nostr:abc123", 100).unwrap();
assert_eq!(remaining, 900);
assert_eq!(ledger.get_balance("did:nostr:abc123"), 900);
}
#[test]
fn debit_rejects_insufficient() {
let mut ledger = WebLedger::new("Test");
ledger.credit("did:nostr:abc123", 50);
let err = ledger.debit("did:nostr:abc123", 100).unwrap_err();
assert!(matches!(
err,
PaymentError::InsufficientBalance {
balance: 50,
cost: 100
}
));
}
#[test]
fn debit_rejects_unknown_did() {
let mut ledger = WebLedger::new("Test");
let err = ledger.debit("did:nostr:unknown", 1).unwrap_err();
assert!(matches!(
err,
PaymentError::InsufficientBalance {
balance: 0,
cost: 1
}
));
}
#[test]
fn credit_accumulates() {
let mut ledger = WebLedger::new("Test");
ledger.credit("did:nostr:abc", 100);
ledger.credit("did:nostr:abc", 200);
assert_eq!(ledger.get_balance("did:nostr:abc"), 300);
}
#[test]
fn agent_agent_payment() {
let mut ledger = WebLedger::new("Test");
let agent_a = "did:nostr:aaaa";
let agent_b = "did:nostr:bbbb";
ledger.credit(agent_a, 500);
ledger.debit(agent_a, 100).unwrap();
ledger.credit(agent_b, 100);
assert_eq!(ledger.get_balance(agent_a), 400);
assert_eq!(ledger.get_balance(agent_b), 100);
}
#[test]
fn parse_txo_bare() {
let txid = "a".repeat(64);
let uri = format!("{txid}:0");
let txo = parse_txo_uri(&uri).unwrap();
assert!(txo.chain.is_none());
assert_eq!(txo.txid, txid);
assert_eq!(txo.vout, 0);
}
#[test]
fn parse_txo_with_chain() {
let txid = "b".repeat(64);
let uri = format!("txo:tbtc4:{txid}:1");
let txo = parse_txo_uri(&uri).unwrap();
assert_eq!(txo.chain.as_deref(), Some("tbtc4"));
assert_eq!(txo.txid, txid);
assert_eq!(txo.vout, 1);
}
#[test]
fn parse_txo_bitcoin_prefix() {
let txid = "c".repeat(64);
let uri = format!("bitcoin:{txid}:2");
let txo = parse_txo_uri(&uri).unwrap();
assert!(txo.chain.is_none());
assert_eq!(txo.vout, 2);
}
#[test]
fn parse_txo_rejects_short_txid() {
assert!(parse_txo_uri("abc123:0").is_err());
}
#[test]
fn pay_info_basic() {
let config = PayConfig::default();
let info = pay_info(&config);
assert_eq!(info["cost"], 1);
assert_eq!(info["unit"], "sat");
assert!(info.get("token").is_none());
}
#[test]
fn pay_info_with_token() {
let config = PayConfig {
enabled: true,
cost_sats: 2,
token: Some(TokenConfig {
ticker: "PODS".into(),
rate: 10,
supply: 10000,
issuer: "025e60b6".into(),
}),
chains: vec![ChainConfig::bitcoin_testnet4()],
};
let info = pay_info(&config);
assert_eq!(info["token"]["ticker"], "PODS");
assert!(info["chains"].as_array().is_some());
}
#[test]
fn ledger_serialization_roundtrip() {
let mut ledger = WebLedger::new("Test");
ledger.credit("did:nostr:abc", 42);
let json = serde_json::to_string(&ledger).unwrap();
let parsed: WebLedger = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.get_balance("did:nostr:abc"), 42);
}
#[test]
fn pubkey_did_roundtrip() {
let pk = "abc123def456";
let did = pubkey_to_did(pk);
assert_eq!(did, "did:nostr:abc123def456");
assert_eq!(did_to_pubkey(&did), Some(pk));
}
#[test]
fn multi_currency_balance() {
let entry = LedgerEntry {
entry_type: "Entry".into(),
url: "did:nostr:abc".into(),
amount: LedgerAmount::Multi(vec![
CurrencyAmount {
currency: "satoshi".into(),
value: "100".into(),
},
CurrencyAmount {
currency: "tbtc4".into(),
value: "50".into(),
},
]),
};
assert_eq!(entry.amount.sats(), 100);
assert_eq!(entry.amount.chain_balance("tbtc4"), 50);
assert_eq!(entry.amount.chain_balance("ltc"), 0);
}
#[test]
fn default_config_disabled() {
let config = PayConfig::default();
assert!(!config.enabled);
assert_eq!(config.cost_sats, 1);
}
}