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