1use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct LedgerEntry {
31 #[serde(rename = "type")]
32 pub entry_type: String,
33 pub url: String,
35 pub amount: LedgerAmount,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(untagged)]
42pub enum LedgerAmount {
43 Simple(String),
44 Multi(Vec<CurrencyAmount>),
45}
46
47impl LedgerAmount {
48 pub fn sats(&self) -> u64 {
49 match self {
50 LedgerAmount::Simple(s) => s.parse().unwrap_or(0),
51 LedgerAmount::Multi(v) => v
52 .iter()
53 .find(|a| a.currency == "satoshi" || a.currency == "sat")
54 .map(|a| a.value.parse().unwrap_or(0))
55 .unwrap_or(0),
56 }
57 }
58
59 pub fn set_sats(&mut self, amount: u64) {
60 match self {
61 LedgerAmount::Simple(s) => *s = amount.to_string(),
62 LedgerAmount::Multi(v) => {
63 if let Some(entry) = v
64 .iter_mut()
65 .find(|a| a.currency == "satoshi" || a.currency == "sat")
66 {
67 entry.value = amount.to_string();
68 } else {
69 v.push(CurrencyAmount {
70 currency: "satoshi".into(),
71 value: amount.to_string(),
72 });
73 }
74 }
75 }
76 }
77
78 pub fn chain_balance(&self, chain: &str) -> u64 {
79 match self {
80 LedgerAmount::Simple(_) => 0,
81 LedgerAmount::Multi(v) => v
82 .iter()
83 .find(|a| a.currency == chain)
84 .map(|a| a.value.parse().unwrap_or(0))
85 .unwrap_or(0),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct CurrencyAmount {
93 pub currency: String,
94 pub value: String,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct WebLedger {
100 #[serde(rename = "@context")]
101 pub context: String,
102 #[serde(rename = "type")]
103 pub ledger_type: String,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub id: Option<String>,
106 pub name: String,
107 pub description: String,
108 #[serde(rename = "defaultCurrency")]
109 pub default_currency: String,
110 pub created: u64,
111 pub updated: u64,
112 pub entries: Vec<LedgerEntry>,
113}
114
115impl WebLedger {
116 pub fn new(name: &str) -> Self {
117 let now = now_secs();
118 Self {
119 context: "https://w3id.org/webledgers".into(),
120 ledger_type: "WebLedger".into(),
121 id: None,
122 name: name.into(),
123 description: "Paid API balance ledger".into(),
124 default_currency: "satoshi".into(),
125 created: now,
126 updated: now,
127 entries: Vec::new(),
128 }
129 }
130
131 pub fn get_balance(&self, did: &str) -> u64 {
132 self.entries
133 .iter()
134 .find(|e| e.url == did)
135 .map(|e| e.amount.sats())
136 .unwrap_or(0)
137 }
138
139 pub fn credit(&mut self, did: &str, amount: u64) {
140 self.updated = now_secs();
141 if let Some(entry) = self.entries.iter_mut().find(|e| e.url == did) {
142 let current = entry.amount.sats();
143 entry.amount.set_sats(current.saturating_add(amount));
144 } else {
145 self.entries.push(LedgerEntry {
146 entry_type: "Entry".into(),
147 url: did.into(),
148 amount: LedgerAmount::Simple(amount.to_string()),
149 });
150 }
151 }
152
153 pub fn debit(&mut self, did: &str, amount: u64) -> Result<u64, PaymentError> {
154 self.updated = now_secs();
155 let entry = self
156 .entries
157 .iter_mut()
158 .find(|e| e.url == did)
159 .ok_or(PaymentError::InsufficientBalance {
160 balance: 0,
161 cost: amount,
162 })?;
163 let current = entry.amount.sats();
164 if current < amount {
165 return Err(PaymentError::InsufficientBalance {
166 balance: current,
167 cost: amount,
168 });
169 }
170 entry.amount.set_sats(current - amount);
171 Ok(current - amount)
172 }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct PayConfig {
182 pub enabled: bool,
183 pub cost_sats: u64,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub token: Option<TokenConfig>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub chains: Vec<ChainConfig>,
188}
189
190impl Default for PayConfig {
191 fn default() -> Self {
192 Self {
193 enabled: false,
194 cost_sats: 1,
195 token: None,
196 chains: Vec::new(),
197 }
198 }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct TokenConfig {
204 pub ticker: String,
205 pub rate: u64,
206 pub supply: u64,
207 pub issuer: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct ChainConfig {
213 pub id: String,
214 pub unit: String,
215 pub name: String,
216 pub explorer_api: String,
217}
218
219impl ChainConfig {
220 pub fn bitcoin_mainnet() -> Self {
221 Self {
222 id: "btc".into(),
223 unit: "sat".into(),
224 name: "Bitcoin".into(),
225 explorer_api: "https://mempool.space/api".into(),
226 }
227 }
228
229 pub fn bitcoin_testnet3() -> Self {
230 Self {
231 id: "tbtc3".into(),
232 unit: "tbtc3".into(),
233 name: "Bitcoin Testnet3".into(),
234 explorer_api: "https://mempool.space/testnet/api".into(),
235 }
236 }
237
238 pub fn bitcoin_testnet4() -> Self {
239 Self {
240 id: "tbtc4".into(),
241 unit: "tbtc4".into(),
242 name: "Bitcoin Testnet4".into(),
243 explorer_api: "https://mempool.space/testnet4/api".into(),
244 }
245 }
246
247 pub fn bitcoin_signet() -> Self {
248 Self {
249 id: "signet".into(),
250 unit: "signet".into(),
251 name: "Bitcoin Signet".into(),
252 explorer_api: "https://mempool.space/signet/api".into(),
253 }
254 }
255}
256
257pub fn payment_required_body(balance: u64, cost: u64) -> serde_json::Value {
263 serde_json::json!({
264 "error": "Payment Required",
265 "balance": balance,
266 "cost": cost,
267 "unit": "sat",
268 "deposit": "/pay/.deposit",
269 "balance_endpoint": "/pay/.balance",
270 "spec": "https://webledgers.org"
271 })
272}
273
274pub fn pay_info(config: &PayConfig) -> serde_json::Value {
276 let mut info = serde_json::json!({
277 "cost": config.cost_sats,
278 "unit": "sat",
279 "deposit": "/pay/.deposit",
280 "balance": "/pay/.balance"
281 });
282 if let Some(ref token) = config.token {
283 info["token"] = serde_json::json!({
284 "ticker": token.ticker,
285 "rate": token.rate,
286 "buy": "/pay/.buy",
287 "withdraw": "/pay/.withdraw",
288 "supply": token.supply,
289 "issuer": token.issuer
290 });
291 }
292 if !config.chains.is_empty() {
293 info["chains"] = serde_json::json!(
294 config.chains.iter().map(|c| serde_json::json!({
295 "id": c.id,
296 "unit": c.unit,
297 "name": c.name
298 })).collect::<Vec<_>>()
299 );
300 info["pool"] = serde_json::json!("/pay/.pool");
301 }
302 info
303}
304
305pub fn balance_response(did: &str, balance: u64, cost: u64) -> serde_json::Value {
307 serde_json::json!({
308 "did": did,
309 "balance": balance,
310 "cost": cost,
311 "unit": "sat"
312 })
313}
314
315pub fn webledgers_discovery(pod_base: &str) -> serde_json::Value {
317 serde_json::json!({
318 "@context": "https://w3id.org/webledgers",
319 "type": "WebLedger",
320 "name": "Pod Credits",
321 "description": "Satoshi-denominated micropayments for pod resource access",
322 "defaultCurrency": "satoshi",
323 "endpoints": {
324 "info": "/pay/.info",
325 "balance": "/pay/.balance",
326 "deposit": "/pay/.deposit",
327 "ledger": "/.well-known/webledgers/webledgers.json"
328 },
329 "verification": {
330 "method": "mempool-api",
331 "url": "https://mempool.space/api/"
332 },
333 "server": pod_base
334 })
335}
336
337#[derive(Debug, Clone)]
343pub struct TxoDeposit {
344 pub chain: Option<String>,
345 pub txid: String,
346 pub vout: u32,
347}
348
349pub fn parse_txo_uri(input: &str) -> Result<TxoDeposit, PaymentError> {
351 let trimmed = input.trim();
352
353 if let Some(rest) = trimmed.strip_prefix("txo:") {
355 let parts: Vec<&str> = rest.splitn(3, ':').collect();
356 if parts.len() == 3 {
357 let chain = parts[0].to_lowercase();
358 let txid = parts[1];
359 let vout: u32 = parts[2]
360 .parse()
361 .map_err(|_| PaymentError::InvalidTxo("bad vout".into()))?;
362 validate_txid(txid)?;
363 return Ok(TxoDeposit {
364 chain: Some(chain),
365 txid: txid.to_string(),
366 vout,
367 });
368 }
369 }
370
371 let cleaned = trimmed.strip_prefix("bitcoin:").unwrap_or(trimmed);
373 let parts: Vec<&str> = cleaned.split(':').collect();
374 if parts.len() != 2 {
375 return Err(PaymentError::InvalidTxo(
376 "expected txid:vout format".into(),
377 ));
378 }
379 let txid = parts[0];
380 let vout: u32 = parts[1]
381 .parse()
382 .map_err(|_| PaymentError::InvalidTxo("bad vout".into()))?;
383 validate_txid(txid)?;
384 Ok(TxoDeposit {
385 chain: None,
386 txid: txid.to_string(),
387 vout,
388 })
389}
390
391fn validate_txid(txid: &str) -> Result<(), PaymentError> {
392 if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
393 return Err(PaymentError::InvalidTxo(
394 "txid must be 64 hex chars".into(),
395 ));
396 }
397 Ok(())
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct Mrc20State {
407 pub profile: String,
408 pub prev: String,
409 pub seq: u64,
410 pub ops: Vec<Mrc20Op>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub anchor: Option<String>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct Mrc20Op {
418 pub op: String,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub from: Option<String>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub to: Option<String>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub amt: Option<u64>,
425}
426
427pub fn verify_state_link(state: &Mrc20State, prev_state: &Mrc20State) -> Result<(), PaymentError> {
429 let prev_json = serde_json::to_string(prev_state)
430 .map_err(|e| PaymentError::InvalidState(format!("serialize: {e}")))?;
431 let hash = hex::encode(sha2::Sha256::digest(prev_json.as_bytes()));
432 if state.prev != hash {
433 return Err(PaymentError::InvalidState(format!(
434 "chain break: expected prev {hash}, got {}",
435 state.prev
436 )));
437 }
438 if state.seq != prev_state.seq + 1 {
439 return Err(PaymentError::InvalidState(format!(
440 "sequence mismatch: expected {}, got {}",
441 prev_state.seq + 1,
442 state.seq
443 )));
444 }
445 Ok(())
446}
447
448#[async_trait::async_trait(?Send)]
454pub trait PaymentStore: Send + Sync {
455 async fn read_ledger(&self) -> Result<WebLedger, PaymentError>;
456 async fn write_ledger(&self, ledger: &WebLedger) -> Result<(), PaymentError>;
457 async fn check_replay(&self, key: &str) -> Result<bool, PaymentError>;
458 async fn record_replay(&self, key: &str) -> Result<(), PaymentError>;
459}
460
461pub fn pubkey_to_did(pubkey: &str) -> String {
467 format!("did:nostr:{pubkey}")
468}
469
470pub fn did_to_pubkey(did: &str) -> Option<&str> {
472 did.strip_prefix("did:nostr:")
473}
474
475#[derive(Debug, thiserror::Error)]
481pub enum PaymentError {
482 #[error("insufficient balance: have {balance}, need {cost}")]
483 InsufficientBalance { balance: u64, cost: u64 },
484
485 #[error("invalid TXO: {0}")]
486 InvalidTxo(String),
487
488 #[error("invalid MRC20 state: {0}")]
489 InvalidState(String),
490
491 #[error("replay detected: {0}")]
492 Replay(String),
493
494 #[error("payment store: {0}")]
495 Store(String),
496}
497
498use sha2::Digest;
503
504fn now_secs() -> u64 {
505 #[cfg(target_arch = "wasm32")]
506 {
507 (js_sys::Date::now() / 1000.0) as u64
508 }
509 #[cfg(not(target_arch = "wasm32"))]
510 {
511 std::time::SystemTime::now()
512 .duration_since(std::time::UNIX_EPOCH)
513 .unwrap_or_default()
514 .as_secs()
515 }
516}
517
518#[cfg(test)]
523mod tests {
524 use super::*;
525
526 #[test]
527 fn new_ledger_empty() {
528 let ledger = WebLedger::new("Test");
529 assert!(ledger.entries.is_empty());
530 assert_eq!(ledger.default_currency, "satoshi");
531 assert_eq!(ledger.context, "https://w3id.org/webledgers");
532 }
533
534 #[test]
535 fn credit_creates_entry() {
536 let mut ledger = WebLedger::new("Test");
537 ledger.credit("did:nostr:abc123", 1000);
538 assert_eq!(ledger.get_balance("did:nostr:abc123"), 1000);
539 }
540
541 #[test]
542 fn debit_reduces_balance() {
543 let mut ledger = WebLedger::new("Test");
544 ledger.credit("did:nostr:abc123", 1000);
545 let remaining = ledger.debit("did:nostr:abc123", 100).unwrap();
546 assert_eq!(remaining, 900);
547 assert_eq!(ledger.get_balance("did:nostr:abc123"), 900);
548 }
549
550 #[test]
551 fn debit_rejects_insufficient() {
552 let mut ledger = WebLedger::new("Test");
553 ledger.credit("did:nostr:abc123", 50);
554 let err = ledger.debit("did:nostr:abc123", 100).unwrap_err();
555 assert!(matches!(
556 err,
557 PaymentError::InsufficientBalance {
558 balance: 50,
559 cost: 100
560 }
561 ));
562 }
563
564 #[test]
565 fn debit_rejects_unknown_did() {
566 let mut ledger = WebLedger::new("Test");
567 let err = ledger.debit("did:nostr:unknown", 1).unwrap_err();
568 assert!(matches!(
569 err,
570 PaymentError::InsufficientBalance {
571 balance: 0,
572 cost: 1
573 }
574 ));
575 }
576
577 #[test]
578 fn credit_accumulates() {
579 let mut ledger = WebLedger::new("Test");
580 ledger.credit("did:nostr:abc", 100);
581 ledger.credit("did:nostr:abc", 200);
582 assert_eq!(ledger.get_balance("did:nostr:abc"), 300);
583 }
584
585 #[test]
586 fn agent_agent_payment() {
587 let mut ledger = WebLedger::new("Test");
588 let agent_a = "did:nostr:aaaa";
589 let agent_b = "did:nostr:bbbb";
590 ledger.credit(agent_a, 500);
591 ledger.debit(agent_a, 100).unwrap();
592 ledger.credit(agent_b, 100);
593 assert_eq!(ledger.get_balance(agent_a), 400);
594 assert_eq!(ledger.get_balance(agent_b), 100);
595 }
596
597 #[test]
598 fn parse_txo_bare() {
599 let txid = "a".repeat(64);
600 let uri = format!("{txid}:0");
601 let txo = parse_txo_uri(&uri).unwrap();
602 assert!(txo.chain.is_none());
603 assert_eq!(txo.txid, txid);
604 assert_eq!(txo.vout, 0);
605 }
606
607 #[test]
608 fn parse_txo_with_chain() {
609 let txid = "b".repeat(64);
610 let uri = format!("txo:tbtc4:{txid}:1");
611 let txo = parse_txo_uri(&uri).unwrap();
612 assert_eq!(txo.chain.as_deref(), Some("tbtc4"));
613 assert_eq!(txo.txid, txid);
614 assert_eq!(txo.vout, 1);
615 }
616
617 #[test]
618 fn parse_txo_bitcoin_prefix() {
619 let txid = "c".repeat(64);
620 let uri = format!("bitcoin:{txid}:2");
621 let txo = parse_txo_uri(&uri).unwrap();
622 assert!(txo.chain.is_none());
623 assert_eq!(txo.vout, 2);
624 }
625
626 #[test]
627 fn parse_txo_rejects_short_txid() {
628 assert!(parse_txo_uri("abc123:0").is_err());
629 }
630
631 #[test]
632 fn pay_info_basic() {
633 let config = PayConfig::default();
634 let info = pay_info(&config);
635 assert_eq!(info["cost"], 1);
636 assert_eq!(info["unit"], "sat");
637 assert!(info.get("token").is_none());
638 }
639
640 #[test]
641 fn pay_info_with_token() {
642 let config = PayConfig {
643 enabled: true,
644 cost_sats: 2,
645 token: Some(TokenConfig {
646 ticker: "PODS".into(),
647 rate: 10,
648 supply: 10000,
649 issuer: "025e60b6".into(),
650 }),
651 chains: vec![ChainConfig::bitcoin_testnet4()],
652 };
653 let info = pay_info(&config);
654 assert_eq!(info["token"]["ticker"], "PODS");
655 assert!(info["chains"].as_array().is_some());
656 }
657
658 #[test]
659 fn ledger_serialization_roundtrip() {
660 let mut ledger = WebLedger::new("Test");
661 ledger.credit("did:nostr:abc", 42);
662 let json = serde_json::to_string(&ledger).unwrap();
663 let parsed: WebLedger = serde_json::from_str(&json).unwrap();
664 assert_eq!(parsed.get_balance("did:nostr:abc"), 42);
665 }
666
667 #[test]
668 fn pubkey_did_roundtrip() {
669 let pk = "abc123def456";
670 let did = pubkey_to_did(pk);
671 assert_eq!(did, "did:nostr:abc123def456");
672 assert_eq!(did_to_pubkey(&did), Some(pk));
673 }
674
675 #[test]
676 fn multi_currency_balance() {
677 let entry = LedgerEntry {
678 entry_type: "Entry".into(),
679 url: "did:nostr:abc".into(),
680 amount: LedgerAmount::Multi(vec![
681 CurrencyAmount {
682 currency: "satoshi".into(),
683 value: "100".into(),
684 },
685 CurrencyAmount {
686 currency: "tbtc4".into(),
687 value: "50".into(),
688 },
689 ]),
690 };
691 assert_eq!(entry.amount.sats(), 100);
692 assert_eq!(entry.amount.chain_balance("tbtc4"), 50);
693 assert_eq!(entry.amount.chain_balance("ltc"), 0);
694 }
695
696 #[test]
697 fn default_config_disabled() {
698 let config = PayConfig::default();
699 assert!(!config.enabled);
700 assert_eq!(config.cost_sats, 1);
701 }
702}