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, PartialEq, Eq, Serialize, Deserialize)]
121pub struct LedgerGenesis {
122 pub operator: String,
124 pub name: String,
126 pub currency: String,
128 pub created: u64,
130 pub confirmations: u64,
132}
133
134impl LedgerGenesis {
135 pub fn new(
139 operator: &str,
140 name: &str,
141 currency: &str,
142 created: u64,
143 confirmations: u64,
144 ) -> Result<Self, PaymentError> {
145 let genesis = Self {
146 operator: account_of(operator)?,
147 name: name.to_string(),
148 currency: currency.to_string(),
149 created,
150 confirmations,
151 };
152 genesis.validate()?;
153 Ok(genesis)
154 }
155
156 fn validate(&self) -> Result<(), PaymentError> {
157 let chars = self.name.chars().count();
158 if chars == 0 || chars > 80 {
159 return Err(PaymentError::InvalidState(
160 "a ledger has a name of up to 80 characters".into(),
161 ));
162 }
163 Ok(())
164 }
165
166 pub fn hash(&self) -> String {
168 let value = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
169 crate::mrc20::sha256_hex(&crate::mrc20::jcs(&value))
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct DepositReceipt {
179 pub outpoint: String,
181 pub account: String,
183 pub value: u64,
185 #[serde(default)]
187 pub height: Option<u64>,
188 pub at: u64,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub currency: Option<String>,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct PayoutReceipt {
199 pub id: String,
201 pub account: String,
203 pub value: u64,
205 #[serde(default)]
207 pub to: Option<String>,
208 #[serde(default)]
210 pub txid: Option<String>,
211 pub at: u64,
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum ReceiptOutcome {
218 Applied,
220 AlreadyApplied,
223}
224
225#[derive(Debug, PartialEq, Eq)]
229pub struct Charge {
230 account: String,
231 amount: u64,
232}
233
234impl Charge {
235 pub fn account(&self) -> &str {
237 &self.account
238 }
239
240 pub fn amount(&self) -> u64 {
242 self.amount
243 }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct WebLedger {
284 #[serde(rename = "@context")]
285 pub context: String,
286 #[serde(rename = "type")]
287 pub ledger_type: String,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub(crate) id: Option<String>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub(crate) hash: Option<String>,
292 pub name: String,
293 #[serde(default)]
294 pub description: String,
295 #[serde(rename = "defaultCurrency")]
296 pub default_currency: String,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub(crate) genesis: Option<LedgerGenesis>,
299 #[serde(default)]
300 pub created: u64,
301 pub updated: u64,
302 pub(crate) entries: Vec<LedgerEntry>,
303 #[serde(default)]
304 pub(crate) deposits: Vec<DepositReceipt>,
305 #[serde(default)]
306 pub(crate) applied: Vec<String>,
307 #[serde(default)]
308 pub(crate) payouts: Vec<PayoutReceipt>,
309}
310
311impl WebLedger {
312 pub fn new(name: &str) -> Self {
314 let now = now_secs();
315 Self {
316 context: "https://w3id.org/webledgers".into(),
317 ledger_type: "WebLedger".into(),
318 id: None,
319 hash: None,
320 name: name.into(),
321 description: "Paid API balance ledger".into(),
322 default_currency: "satoshi".into(),
323 genesis: None,
324 created: now,
325 updated: now,
326 entries: Vec::new(),
327 deposits: Vec::new(),
328 applied: Vec::new(),
329 payouts: Vec::new(),
330 }
331 }
332
333 pub fn with_genesis(genesis: LedgerGenesis) -> Result<Self, PaymentError> {
337 genesis.validate()?;
338 let hash = genesis.hash();
339 let mut ledger = Self::new(&genesis.name);
340 ledger.id = Some(format!("urn:webledgers:{hash}"));
341 ledger.hash = Some(hash);
342 ledger.default_currency = genesis.currency.clone();
343 ledger.created = genesis.created;
344 ledger.updated = genesis.created;
345 ledger.genesis = Some(genesis);
346 Ok(ledger)
347 }
348
349 pub fn id(&self) -> Option<&str> {
351 self.id.as_deref()
352 }
353
354 pub fn hash(&self) -> Option<&str> {
356 self.hash.as_deref()
357 }
358
359 pub fn genesis(&self) -> Option<&LedgerGenesis> {
361 self.genesis.as_ref()
362 }
363
364 pub fn ledger_hash(&self) -> Option<String> {
366 self.genesis.as_ref().map(LedgerGenesis::hash)
367 }
368
369 pub fn check_genesis(&self) -> Result<(), PaymentError> {
373 let Some(genesis) = &self.genesis else {
374 return Ok(());
375 };
376 if self.hash.as_deref() != Some(genesis.hash().as_str()) {
377 return Err(PaymentError::InvalidState(
378 "the ledger's hash is not the hash of its genesis".into(),
379 ));
380 }
381 Ok(())
382 }
383
384 pub fn entries(&self) -> &[LedgerEntry] {
386 &self.entries
387 }
388
389 pub fn deposits(&self) -> &[DepositReceipt] {
391 &self.deposits
392 }
393
394 pub fn payouts(&self) -> &[PayoutReceipt] {
396 &self.payouts
397 }
398
399 pub fn has_deposit(&self, txid: &str, vout: u32) -> bool {
401 let key = format!("{txid}:{vout}");
402 self.deposits.iter().any(|d| d.outpoint == key)
403 }
404
405 pub fn is_sats_unit(&self, currency: &str) -> bool {
408 currency == "satoshi" || currency == "sat" || currency == self.default_currency
409 }
410
411 pub fn get_balance(&self, did: &str) -> u64 {
413 self.entries
414 .iter()
415 .find(|e| e.url == did)
416 .map(|e| e.amount.sats())
417 .unwrap_or(0)
418 }
419
420 pub fn credit_by_outpoint(
451 &mut self,
452 account: &str,
453 currency: &str,
454 txid: &str,
455 vout: u32,
456 value: u64,
457 ) -> Result<ReceiptOutcome, PaymentError> {
458 let account = account_of(account)?;
459 if txid.len() != 64 || !txid.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
460 return Err(PaymentError::InvalidTxo(
461 "a deposit is an outpoint txid:vout".into(),
462 ));
463 }
464 if currency.is_empty() {
465 return Err(PaymentError::InvalidState(
466 "a deposit names its unit".into(),
467 ));
468 }
469 check_amount(value)?;
470 if self.has_deposit(txid, vout) {
471 return Ok(ReceiptOutcome::AlreadyApplied);
472 }
473 let sats = self.is_sats_unit(currency);
474 if sats {
475 self.credit(&account, value);
476 } else {
477 self.credit_currency(&account, currency, value);
478 }
479 let now = now_secs();
480 self.deposits.push(DepositReceipt {
481 outpoint: format!("{txid}:{vout}"),
482 account,
483 value,
484 height: None,
485 at: now,
486 currency: (!sats).then(|| currency.to_string()),
487 });
488 self.touch(now);
489 Ok(ReceiptOutcome::Applied)
490 }
491
492 pub fn debit_by_payout(
502 &mut self,
503 account: &str,
504 amount: u64,
505 txid: &str,
506 ) -> Result<ReceiptOutcome, PaymentError> {
507 let account = account_of(account)?;
508 if txid.len() != 64 || !txid.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
509 return Err(PaymentError::InvalidTxo(
510 "a payout is identified by its 64-hex txid".into(),
511 ));
512 }
513 check_amount(amount)?;
514 if self.applied.iter().any(|id| id == txid) {
515 return Ok(ReceiptOutcome::AlreadyApplied);
516 }
517 self.debit(&account, amount)?;
518 let now = now_secs();
519 self.applied.push(txid.to_string());
520 self.payouts.push(PayoutReceipt {
521 id: txid.to_string(),
522 account,
523 value: amount,
524 to: None,
525 txid: Some(txid.to_string()),
526 at: now,
527 });
528 self.touch(now);
529 Ok(ReceiptOutcome::Applied)
530 }
531
532 pub fn reverse_payout(&mut self, txid: &str) -> Option<u64> {
537 let index = self.payouts.iter().position(|p| p.id == txid)?;
538 let payout = self.payouts.remove(index);
539 self.applied.retain(|id| id != txid);
540 self.credit(&payout.account, payout.value);
541 Some(payout.value)
542 }
543
544 pub fn charge(&mut self, did: &str, amount: u64) -> Result<Charge, PaymentError> {
549 self.debit(did, amount)?;
550 Ok(Charge {
551 account: did.to_string(),
552 amount,
553 })
554 }
555
556 pub fn refund(&mut self, charge: Charge) {
558 self.credit(&charge.account, charge.amount);
559 }
560
561 fn touch(&mut self, now: u64) {
562 self.updated = self.updated.max(now);
563 }
564
565 pub(crate) fn credit(&mut self, did: &str, amount: u64) {
566 self.updated = now_secs();
567 if let Some(entry) = self.entries.iter_mut().find(|e| e.url == did) {
568 let current = entry.amount.sats();
569 entry.amount.set_sats(current.saturating_add(amount));
570 } else {
571 self.entries.push(LedgerEntry {
572 entry_type: "Entry".into(),
573 url: did.into(),
574 amount: LedgerAmount::Simple(amount.to_string()),
575 });
576 }
577 }
578
579 pub(crate) fn debit(&mut self, did: &str, amount: u64) -> Result<u64, PaymentError> {
580 self.updated = now_secs();
581 let entry = self.entries.iter_mut().find(|e| e.url == did).ok_or(
582 PaymentError::InsufficientBalance {
583 balance: 0,
584 cost: amount,
585 },
586 )?;
587 let current = entry.amount.sats();
588 if current < amount {
589 return Err(PaymentError::InsufficientBalance {
590 balance: current,
591 cost: amount,
592 });
593 }
594 entry.amount.set_sats(current - amount);
595 Ok(current - amount)
596 }
597}
598
599pub fn account_of(id: &str) -> Result<String, PaymentError> {
602 let s = id.trim().to_ascii_lowercase();
603 let is_x = |x: &str| x.len() == 64 && x.bytes().all(|b| b.is_ascii_hexdigit());
604 let x = s
605 .strip_prefix("did:nostr:")
606 .filter(|x| is_x(x))
607 .or_else(|| Some(s.as_str()).filter(|x| is_x(x)))
608 .or_else(|| {
609 s.strip_prefix("fe70102")
610 .or_else(|| s.strip_prefix("fe70103"))
611 .filter(|x| is_x(x))
612 })
613 .ok_or_else(|| {
614 PaymentError::InvalidState(
615 "an account is a did:nostr identifier (did:nostr:<64 hex>)".into(),
616 )
617 })?;
618 Ok(format!("did:nostr:{x}"))
619}
620
621fn check_amount(value: u64) -> Result<(), PaymentError> {
623 const MAX: u64 = 2_100_000_000_000_000;
624 if value > MAX {
625 return Err(PaymentError::InvalidState(
626 "an amount is a whole number of satoshis".into(),
627 ));
628 }
629 Ok(())
630}
631
632#[derive(Debug, Clone, Serialize, Deserialize)]
638pub struct PayConfig {
639 pub enabled: bool,
640 pub cost_sats: u64,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub token: Option<TokenConfig>,
643 #[serde(default, skip_serializing_if = "Vec::is_empty")]
644 pub chains: Vec<ChainConfig>,
645}
646
647impl Default for PayConfig {
648 fn default() -> Self {
649 Self {
650 enabled: false,
651 cost_sats: 1,
652 token: None,
653 chains: Vec::new(),
654 }
655 }
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct TokenConfig {
661 pub ticker: String,
662 pub rate: u64,
663 pub supply: u64,
664 pub issuer: String,
667 #[serde(default, skip_serializing_if = "Vec::is_empty")]
670 pub accepted_issuers: Vec<String>,
671}
672
673impl TokenConfig {
674 pub fn accepts_issuer(&self, pubkey: &str) -> bool {
678 let pubkey = pubkey.trim();
679 !pubkey.is_empty()
680 && std::iter::once(&self.issuer)
681 .chain(self.accepted_issuers.iter())
682 .any(|k| !k.is_empty() && k.trim().eq_ignore_ascii_case(pubkey))
683 }
684}
685
686#[derive(Debug, Clone, Serialize, Deserialize)]
688pub struct ChainConfig {
689 pub id: String,
690 pub unit: String,
691 pub name: String,
692 pub explorer_api: String,
693}
694
695impl ChainConfig {
696 pub fn bitcoin_mainnet() -> Self {
697 Self {
698 id: "btc".into(),
699 unit: "sat".into(),
700 name: "Bitcoin".into(),
701 explorer_api: "https://mempool.space/api".into(),
702 }
703 }
704
705 pub fn bitcoin_testnet3() -> Self {
706 Self {
707 id: "tbtc3".into(),
708 unit: "tbtc3".into(),
709 name: "Bitcoin Testnet3".into(),
710 explorer_api: "https://mempool.space/testnet/api".into(),
711 }
712 }
713
714 pub fn bitcoin_testnet4() -> Self {
715 Self {
716 id: "tbtc4".into(),
717 unit: "tbtc4".into(),
718 name: "Bitcoin Testnet4".into(),
719 explorer_api: "https://mempool.space/testnet4/api".into(),
720 }
721 }
722
723 pub fn bitcoin_signet() -> Self {
724 Self {
725 id: "signet".into(),
726 unit: "signet".into(),
727 name: "Bitcoin Signet".into(),
728 explorer_api: "https://mempool.space/signet/api".into(),
729 }
730 }
731}
732
733pub fn payment_required_body(balance: u64, cost: u64) -> serde_json::Value {
739 serde_json::json!({
740 "error": "Payment Required",
741 "balance": balance,
742 "cost": cost,
743 "unit": "sat",
744 "deposit": "/pay/.deposit",
745 "balance_endpoint": "/pay/.balance",
746 "spec": "https://webledgers.org"
747 })
748}
749
750pub fn pay_info(config: &PayConfig) -> serde_json::Value {
752 let mut info = serde_json::json!({
753 "cost": config.cost_sats,
754 "unit": "sat",
755 "deposit": "/pay/.deposit",
756 "balance": "/pay/.balance"
757 });
758 if let Some(ref token) = config.token {
759 info["token"] = serde_json::json!({
760 "ticker": token.ticker,
761 "rate": token.rate,
762 "buy": "/pay/.buy",
763 "withdraw": "/pay/.withdraw",
764 "supply": token.supply,
765 "issuer": token.issuer
766 });
767 }
768 if !config.chains.is_empty() {
769 info["chains"] = serde_json::json!(config
770 .chains
771 .iter()
772 .map(|c| serde_json::json!({
773 "id": c.id,
774 "unit": c.unit,
775 "name": c.name
776 }))
777 .collect::<Vec<_>>());
778 info["pool"] = serde_json::json!("/pay/.pool");
779 }
780 info
781}
782
783pub fn payment_response_headers(
793 balance: u64,
794 cost: u64,
795 currency: &str,
796) -> Vec<(&'static str, String)> {
797 vec![
798 ("X-Balance", balance.to_string()),
799 ("X-Cost", cost.to_string()),
800 ("X-Pay-Currency", currency.to_string()),
801 ]
802}
803
804pub fn balance_response(did: &str, balance: u64, cost: u64) -> serde_json::Value {
806 serde_json::json!({
807 "did": did,
808 "balance": balance,
809 "cost": cost,
810 "unit": "sat"
811 })
812}
813
814pub fn webledgers_discovery(pod_base: &str) -> serde_json::Value {
816 serde_json::json!({
817 "@context": "https://w3id.org/webledgers",
818 "type": "WebLedger",
819 "name": "Pod Credits",
820 "description": "Satoshi-denominated micropayments for pod resource access",
821 "defaultCurrency": "satoshi",
822 "endpoints": {
823 "info": "/pay/.info",
824 "balance": "/pay/.balance",
825 "deposit": "/pay/.deposit",
826 "ledger": "/.well-known/webledgers/webledgers.json"
827 },
828 "verification": {
829 "method": "mempool-api",
830 "url": "https://mempool.space/api/"
831 },
832 "server": pod_base
833 })
834}
835
836#[derive(Debug, Clone)]
842pub struct TxoDeposit {
843 pub chain: Option<String>,
844 pub txid: String,
845 pub vout: u32,
846}
847
848pub fn parse_txo_uri(input: &str) -> Result<TxoDeposit, PaymentError> {
850 let trimmed = input.trim();
851
852 if let Some(rest) = trimmed.strip_prefix("txo:") {
854 let parts: Vec<&str> = rest.splitn(3, ':').collect();
855 if parts.len() == 3 {
856 let chain = parts[0].to_lowercase();
857 let txid = parts[1];
858 let vout: u32 = parts[2]
859 .parse()
860 .map_err(|_| PaymentError::InvalidTxo("bad vout".into()))?;
861 validate_txid(txid)?;
862 return Ok(TxoDeposit {
863 chain: Some(chain),
864 txid: txid.to_string(),
865 vout,
866 });
867 }
868 }
869
870 let cleaned = trimmed.strip_prefix("bitcoin:").unwrap_or(trimmed);
872 let parts: Vec<&str> = cleaned.split(':').collect();
873 if parts.len() != 2 {
874 return Err(PaymentError::InvalidTxo("expected txid:vout format".into()));
875 }
876 let txid = parts[0];
877 let vout: u32 = parts[1]
878 .parse()
879 .map_err(|_| PaymentError::InvalidTxo("bad vout".into()))?;
880 validate_txid(txid)?;
881 Ok(TxoDeposit {
882 chain: None,
883 txid: txid.to_string(),
884 vout,
885 })
886}
887
888fn validate_txid(txid: &str) -> Result<(), PaymentError> {
889 if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
890 return Err(PaymentError::InvalidTxo("txid must be 64 hex chars".into()));
891 }
892 Ok(())
893}
894
895pub use crate::mrc20::{verify_state_link, Mrc20Op, Mrc20State};
904
905#[async_trait::async_trait(?Send)]
911pub trait PaymentStore: Send + Sync {
912 async fn read_ledger(&self) -> Result<WebLedger, PaymentError>;
913 async fn write_ledger(&self, ledger: &WebLedger) -> Result<(), PaymentError>;
914 async fn check_replay(&self, key: &str) -> Result<bool, PaymentError>;
915 async fn record_replay(&self, key: &str) -> Result<(), PaymentError>;
916}
917
918pub fn pubkey_to_did(pubkey: &str) -> String {
924 format!("did:nostr:{pubkey}")
925}
926
927pub fn did_to_pubkey(did: &str) -> Option<&str> {
929 did.strip_prefix("did:nostr:")
930}
931
932#[derive(Debug, thiserror::Error)]
938pub enum PaymentError {
939 #[error("insufficient balance: have {balance}, need {cost}")]
940 InsufficientBalance { balance: u64, cost: u64 },
941
942 #[error("invalid TXO: {0}")]
943 InvalidTxo(String),
944
945 #[error("invalid MRC20 state: {0}")]
946 InvalidState(String),
947
948 #[error("replay detected: {0}")]
949 Replay(String),
950
951 #[error("payment store: {0}")]
952 Store(String),
953}
954
955fn now_secs() -> u64 {
960 #[cfg(target_arch = "wasm32")]
961 {
962 (js_sys::Date::now() / 1000.0) as u64
963 }
964 #[cfg(not(target_arch = "wasm32"))]
965 {
966 std::time::SystemTime::now()
967 .duration_since(std::time::UNIX_EPOCH)
968 .unwrap_or_default()
969 .as_secs()
970 }
971}
972
973#[cfg(test)]
978mod tests {
979 use super::*;
980
981 #[test]
982 fn new_ledger_empty() {
983 let ledger = WebLedger::new("Test");
984 assert!(ledger.entries.is_empty());
985 assert_eq!(ledger.default_currency, "satoshi");
986 assert_eq!(ledger.context, "https://w3id.org/webledgers");
987 }
988
989 #[test]
990 fn credit_creates_entry() {
991 let mut ledger = WebLedger::new("Test");
992 ledger.credit("did:nostr:abc123", 1000);
993 assert_eq!(ledger.get_balance("did:nostr:abc123"), 1000);
994 }
995
996 #[test]
997 fn debit_reduces_balance() {
998 let mut ledger = WebLedger::new("Test");
999 ledger.credit("did:nostr:abc123", 1000);
1000 let remaining = ledger.debit("did:nostr:abc123", 100).unwrap();
1001 assert_eq!(remaining, 900);
1002 assert_eq!(ledger.get_balance("did:nostr:abc123"), 900);
1003 }
1004
1005 #[test]
1006 fn debit_rejects_insufficient() {
1007 let mut ledger = WebLedger::new("Test");
1008 ledger.credit("did:nostr:abc123", 50);
1009 let err = ledger.debit("did:nostr:abc123", 100).unwrap_err();
1010 assert!(matches!(
1011 err,
1012 PaymentError::InsufficientBalance {
1013 balance: 50,
1014 cost: 100
1015 }
1016 ));
1017 }
1018
1019 #[test]
1020 fn debit_rejects_unknown_did() {
1021 let mut ledger = WebLedger::new("Test");
1022 let err = ledger.debit("did:nostr:unknown", 1).unwrap_err();
1023 assert!(matches!(
1024 err,
1025 PaymentError::InsufficientBalance {
1026 balance: 0,
1027 cost: 1
1028 }
1029 ));
1030 }
1031
1032 #[test]
1033 fn credit_accumulates() {
1034 let mut ledger = WebLedger::new("Test");
1035 ledger.credit("did:nostr:abc", 100);
1036 ledger.credit("did:nostr:abc", 200);
1037 assert_eq!(ledger.get_balance("did:nostr:abc"), 300);
1038 }
1039
1040 #[test]
1041 fn agent_agent_payment() {
1042 let mut ledger = WebLedger::new("Test");
1043 let agent_a = "did:nostr:aaaa";
1044 let agent_b = "did:nostr:bbbb";
1045 ledger.credit(agent_a, 500);
1046 ledger.debit(agent_a, 100).unwrap();
1047 ledger.credit(agent_b, 100);
1048 assert_eq!(ledger.get_balance(agent_a), 400);
1049 assert_eq!(ledger.get_balance(agent_b), 100);
1050 }
1051
1052 const ALICE: &str =
1053 "did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa";
1054
1055 fn txid(byte: &str) -> String {
1056 byte.repeat(32)
1057 }
1058
1059 #[test]
1060 fn credit_by_outpoint_credits_once() {
1061 let mut ledger = WebLedger::new("Test");
1062 let t = txid("ab");
1063 assert_eq!(
1064 ledger
1065 .credit_by_outpoint(ALICE, "satoshi", &t, 0, 50_000)
1066 .unwrap(),
1067 ReceiptOutcome::Applied
1068 );
1069 assert_eq!(
1070 ledger
1071 .credit_by_outpoint(ALICE, "satoshi", &t, 0, 50_000)
1072 .unwrap(),
1073 ReceiptOutcome::AlreadyApplied
1074 );
1075 let bob = format!("did:nostr:{}", "b".repeat(64));
1077 assert_eq!(
1078 ledger.credit_by_outpoint(&bob, "PODS", &t, 0, 9).unwrap(),
1079 ReceiptOutcome::AlreadyApplied
1080 );
1081 assert_eq!(ledger.get_balance(ALICE), 50_000);
1082 assert_eq!(ledger.get_currency_balance(&bob, "PODS"), 0);
1083 assert_eq!(ledger.deposits().len(), 1);
1084 assert_eq!(ledger.deposits()[0].outpoint, format!("{t}:0"));
1085 assert!(ledger.has_deposit(&t, 0));
1086 assert!(!ledger.has_deposit(&t, 1));
1087 assert_eq!(
1089 ledger
1090 .credit_by_outpoint(ALICE, "satoshi", &t, 1, 1)
1091 .unwrap(),
1092 ReceiptOutcome::Applied
1093 );
1094 assert_eq!(ledger.get_balance(ALICE), 50_001);
1095 }
1096
1097 #[test]
1098 fn token_credit_lands_in_its_ticker_never_sats() {
1099 let mut ledger = WebLedger::new("Test");
1100 ledger
1101 .credit_by_outpoint(ALICE, "satoshi", &txid("01"), 0, 777)
1102 .unwrap();
1103 ledger
1104 .credit_by_outpoint(ALICE, "PODS", &txid("02"), 3, 100)
1105 .unwrap();
1106 assert_eq!(ledger.get_balance(ALICE), 777, "sats unchanged");
1107 assert_eq!(ledger.get_currency_balance(ALICE, "PODS"), 100);
1108 assert_eq!(ledger.deposits()[1].currency.as_deref(), Some("PODS"));
1109 assert_eq!(ledger.deposits()[0].currency, None);
1110 }
1111
1112 #[test]
1113 fn credit_by_outpoint_reads_accounts_as_teller_does() {
1114 let x = &ALICE["did:nostr:".len()..];
1115 let mut ledger = WebLedger::new("Test");
1116 ledger
1117 .credit_by_outpoint(&x.to_uppercase(), "satoshi", &txid("03"), 0, 5)
1118 .unwrap();
1119 ledger
1120 .credit_by_outpoint(&format!("fe70102{x}"), "sat", &txid("04"), 0, 5)
1121 .unwrap();
1122 assert_eq!(ledger.get_balance(ALICE), 10);
1123 for bad in ["did:nostr:alice", "npub1x", ""] {
1124 assert!(ledger
1125 .credit_by_outpoint(bad, "satoshi", &txid("05"), 0, 1)
1126 .is_err());
1127 }
1128 }
1129
1130 #[test]
1131 fn credit_by_outpoint_refuses_bad_outpoints_and_amounts() {
1132 let mut ledger = WebLedger::new("Test");
1133 assert!(ledger
1134 .credit_by_outpoint(ALICE, "satoshi", &"AB".repeat(32), 0, 1)
1135 .is_err());
1136 assert!(ledger
1137 .credit_by_outpoint(ALICE, "satoshi", "abcd", 0, 1)
1138 .is_err());
1139 assert!(ledger
1140 .credit_by_outpoint(ALICE, "satoshi", &txid("ab"), 0, 2_100_000_000_000_001)
1141 .is_err());
1142 assert!(ledger
1143 .credit_by_outpoint(ALICE, "", &txid("ab"), 0, 1)
1144 .is_err());
1145 assert!(ledger.entries().is_empty() && ledger.deposits().is_empty());
1146 }
1147
1148 #[test]
1149 fn debit_by_payout_applies_once_and_reverses() {
1150 let mut ledger = WebLedger::new("Test");
1151 ledger
1152 .credit_by_outpoint(ALICE, "satoshi", &txid("ab"), 0, 1_000)
1153 .unwrap();
1154 let payout = txid("cd");
1155 assert_eq!(
1156 ledger.debit_by_payout(ALICE, 400, &payout).unwrap(),
1157 ReceiptOutcome::Applied
1158 );
1159 assert_eq!(
1160 ledger.debit_by_payout(ALICE, 400, &payout).unwrap(),
1161 ReceiptOutcome::AlreadyApplied
1162 );
1163 assert_eq!(ledger.get_balance(ALICE), 600);
1164 assert!(matches!(
1165 ledger.debit_by_payout(ALICE, 601, &txid("ce")),
1166 Err(PaymentError::InsufficientBalance {
1167 balance: 600,
1168 cost: 601
1169 })
1170 ));
1171 assert!(ledger.debit_by_payout(ALICE, 1, "not-a-txid").is_err());
1172 assert_eq!(ledger.payouts().len(), 1);
1173
1174 assert_eq!(ledger.reverse_payout(&payout), Some(400));
1175 assert_eq!(ledger.reverse_payout(&payout), None, "reversed once");
1176 assert_eq!(ledger.get_balance(ALICE), 1_000);
1177 assert!(ledger.payouts().is_empty());
1178 assert_eq!(ledger.reverse_payout(&txid("ef")), None);
1179 assert_eq!(ledger.get_balance(ALICE), 1_000, "no receipt, no credit");
1180 }
1181
1182 #[test]
1183 fn charge_and_refund() {
1184 let mut ledger = WebLedger::new("Test");
1185 ledger
1186 .credit_by_outpoint(ALICE, "satoshi", &txid("ab"), 0, 100)
1187 .unwrap();
1188 let charge = ledger.charge(ALICE, 30).unwrap();
1189 assert_eq!((charge.account(), charge.amount()), (ALICE, 30));
1190 assert_eq!(ledger.get_balance(ALICE), 70);
1191 ledger.refund(charge);
1192 assert_eq!(ledger.get_balance(ALICE), 100);
1193 assert!(matches!(
1194 ledger.charge(ALICE, 101),
1195 Err(PaymentError::InsufficientBalance {
1196 balance: 100,
1197 cost: 101
1198 })
1199 ));
1200 }
1201
1202 #[test]
1203 fn genesis_identity() {
1204 let genesis =
1205 LedgerGenesis::new(ALICE, "Pod Credits", "satoshi", 1_759_300_000, 1).unwrap();
1206 let ledger = WebLedger::with_genesis(genesis.clone()).unwrap();
1207 assert_eq!(ledger.hash(), Some(genesis.hash().as_str()));
1208 assert_eq!(
1209 ledger.id().unwrap(),
1210 format!("urn:webledgers:{}", genesis.hash())
1211 );
1212 ledger.check_genesis().unwrap();
1213 assert!(WebLedger::new("Legacy").check_genesis().is_ok());
1214 assert!(LedgerGenesis::new(ALICE, "", "satoshi", 0, 1).is_err());
1215 assert!(LedgerGenesis::new(ALICE, &"n".repeat(81), "satoshi", 0, 1).is_err());
1216 assert!(LedgerGenesis::new("did:nostr:alice", "x", "satoshi", 0, 1).is_err());
1217
1218 let legacy = r#"{"@context":"https://w3id.org/webledgers","type":"WebLedger","name":"Pod Credits","description":"d","defaultCurrency":"satoshi","created":1,"updated":2,"entries":[{"type":"Entry","url":"did:nostr:x","amount":"5"}]}"#;
1220 let ledger: WebLedger = serde_json::from_str(legacy).unwrap();
1221 assert_eq!(ledger.get_balance("did:nostr:x"), 5);
1222 assert!(ledger.genesis().is_none() && ledger.check_genesis().is_ok());
1223 }
1224
1225 #[test]
1226 fn token_config_accepts_its_issuers_only() {
1227 let mut token = TokenConfig {
1228 ticker: "PODS".into(),
1229 rate: 1,
1230 supply: 1,
1231 issuer: "02AA".into(),
1232 accepted_issuers: Vec::new(),
1233 };
1234 assert!(token.accepts_issuer("02aa"));
1235 assert!(!token.accepts_issuer("02bb"));
1236 assert!(!token.accepts_issuer(""));
1237 token.accepted_issuers.push("02bb".into());
1238 assert!(token.accepts_issuer("02BB"));
1239 token.issuer.clear();
1240 token.accepted_issuers.clear();
1241 assert!(!token.accepts_issuer(""));
1242 }
1243
1244 #[test]
1245 fn parse_txo_bare() {
1246 let txid = "a".repeat(64);
1247 let uri = format!("{txid}:0");
1248 let txo = parse_txo_uri(&uri).unwrap();
1249 assert!(txo.chain.is_none());
1250 assert_eq!(txo.txid, txid);
1251 assert_eq!(txo.vout, 0);
1252 }
1253
1254 #[test]
1255 fn parse_txo_with_chain() {
1256 let txid = "b".repeat(64);
1257 let uri = format!("txo:tbtc4:{txid}:1");
1258 let txo = parse_txo_uri(&uri).unwrap();
1259 assert_eq!(txo.chain.as_deref(), Some("tbtc4"));
1260 assert_eq!(txo.txid, txid);
1261 assert_eq!(txo.vout, 1);
1262 }
1263
1264 #[test]
1265 fn parse_txo_bitcoin_prefix() {
1266 let txid = "c".repeat(64);
1267 let uri = format!("bitcoin:{txid}:2");
1268 let txo = parse_txo_uri(&uri).unwrap();
1269 assert!(txo.chain.is_none());
1270 assert_eq!(txo.vout, 2);
1271 }
1272
1273 #[test]
1274 fn parse_txo_rejects_short_txid() {
1275 assert!(parse_txo_uri("abc123:0").is_err());
1276 }
1277
1278 #[test]
1279 fn pay_info_basic() {
1280 let config = PayConfig::default();
1281 let info = pay_info(&config);
1282 assert_eq!(info["cost"], 1);
1283 assert_eq!(info["unit"], "sat");
1284 assert!(info.get("token").is_none());
1285 }
1286
1287 #[test]
1288 fn pay_info_with_token() {
1289 let config = PayConfig {
1290 enabled: true,
1291 cost_sats: 2,
1292 token: Some(TokenConfig {
1293 ticker: "PODS".into(),
1294 rate: 10,
1295 supply: 10000,
1296 issuer: "025e60b6".into(),
1297 accepted_issuers: Vec::new(),
1298 }),
1299 chains: vec![ChainConfig::bitcoin_testnet4()],
1300 };
1301 let info = pay_info(&config);
1302 assert_eq!(info["token"]["ticker"], "PODS");
1303 assert!(info["chains"].as_array().is_some());
1304 }
1305
1306 #[test]
1307 fn ledger_serialization_roundtrip() {
1308 let mut ledger = WebLedger::new("Test");
1309 ledger.credit("did:nostr:abc", 42);
1310 let json = serde_json::to_string(&ledger).unwrap();
1311 let parsed: WebLedger = serde_json::from_str(&json).unwrap();
1312 assert_eq!(parsed.get_balance("did:nostr:abc"), 42);
1313 }
1314
1315 #[test]
1316 fn pubkey_did_roundtrip() {
1317 let pk = "abc123def456";
1318 let did = pubkey_to_did(pk);
1319 assert_eq!(did, "did:nostr:abc123def456");
1320 assert_eq!(did_to_pubkey(&did), Some(pk));
1321 }
1322
1323 #[test]
1324 fn multi_currency_balance() {
1325 let entry = LedgerEntry {
1326 entry_type: "Entry".into(),
1327 url: "did:nostr:abc".into(),
1328 amount: LedgerAmount::Multi(vec![
1329 CurrencyAmount {
1330 currency: "satoshi".into(),
1331 value: "100".into(),
1332 },
1333 CurrencyAmount {
1334 currency: "tbtc4".into(),
1335 value: "50".into(),
1336 },
1337 ]),
1338 };
1339 assert_eq!(entry.amount.sats(), 100);
1340 assert_eq!(entry.amount.chain_balance("tbtc4"), 50);
1341 assert_eq!(entry.amount.chain_balance("ltc"), 0);
1342 }
1343
1344 #[test]
1345 fn default_config_disabled() {
1346 let config = PayConfig::default();
1347 assert!(!config.enabled);
1348 assert_eq!(config.cost_sats, 1);
1349 }
1350
1351 #[test]
1352 fn payment_response_headers_returns_three_headers() {
1353 let headers = super::payment_response_headers(950, 50, "sat");
1354 assert_eq!(headers.len(), 3);
1355 assert_eq!(headers[0], ("X-Balance", "950".to_string()));
1356 assert_eq!(headers[1], ("X-Cost", "50".to_string()));
1357 assert_eq!(headers[2], ("X-Pay-Currency", "sat".to_string()));
1358 }
1359
1360 #[test]
1361 fn payment_response_headers_zero_balance() {
1362 let headers = super::payment_response_headers(0, 1, "sat");
1363 assert_eq!(headers[0].1, "0");
1364 }
1365}