solid_pod_rs_server/
trail_store.rs1use std::sync::Arc;
16
17use bytes::Bytes;
18use serde::{Deserialize, Serialize};
19
20use solid_pod_rs::mrc20::{Mrc20State, Mrc20Trail};
21use solid_pod_rs::payments::PaymentError;
22use solid_pod_rs::storage::Storage;
23
24#[must_use]
27pub fn trail_path(ticker: &str) -> String {
28 format!("/.well-known/token/{}.json", ticker.to_lowercase())
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct StoredTrail {
38 pub ticker: String,
39 pub name: String,
40 pub supply: u64,
41 pub privkey: String,
44 pub pubkey_base: String,
45 pub states: Vec<Mrc20State>,
46 pub state_strings: Vec<String>,
47 pub current_txid: String,
48 pub current_vout: u32,
49 pub current_amount: u64,
50 pub network: String,
51 #[serde(default)]
52 pub date_created: String,
53}
54
55impl StoredTrail {
56 #[must_use]
58 pub fn to_public(&self) -> Mrc20Trail {
59 Mrc20Trail {
60 ticker: self.ticker.clone(),
61 name: self.name.clone(),
62 supply: self.supply,
63 pubkey_base: self.pubkey_base.clone(),
64 states: self.states.clone(),
65 state_strings: self.state_strings.clone(),
66 current_txid: self.current_txid.clone(),
67 current_vout: self.current_vout,
68 current_amount: self.current_amount,
69 network: self.network.clone(),
70 date_created: self.date_created.clone(),
71 }
72 }
73
74 pub fn merge_public(&mut self, public: &Mrc20Trail) {
77 self.states = public.states.clone();
78 self.state_strings = public.state_strings.clone();
79 self.current_txid = public.current_txid.clone();
80 self.current_vout = public.current_vout;
81 self.current_amount = public.current_amount;
82 self.supply = public.supply;
83 }
84}
85
86pub async fn load_trail(
90 storage: &Arc<dyn Storage>,
91 ticker: &str,
92) -> Result<Option<StoredTrail>, PaymentError> {
93 match storage.get(&trail_path(ticker)).await {
94 Ok((bytes, _meta)) => {
95 let trail: StoredTrail = serde_json::from_slice(&bytes)
96 .map_err(|e| PaymentError::Store(format!("malformed trail {ticker}: {e}")))?;
97 Ok(Some(trail))
98 }
99 Err(_) => Ok(None),
100 }
101}
102
103pub async fn save_trail(storage: &Arc<dyn Storage>, trail: &StoredTrail) -> Result<(), PaymentError> {
106 let body = serde_json::to_vec_pretty(trail)
107 .map_err(|e| PaymentError::Store(format!("serialise trail: {e}")))?;
108 storage
109 .put(&trail_path(&trail.ticker), Bytes::from(body), "application/json")
110 .await
111 .map_err(|e| PaymentError::Store(format!("save trail: {e}")))?;
112 Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use solid_pod_rs::mrc20::MRC20_PROFILE;
119 use solid_pod_rs::storage::memory::MemoryBackend;
120
121 fn genesis() -> Mrc20State {
122 Mrc20State {
123 profile: MRC20_PROFILE.into(),
124 prev: "0".repeat(64),
125 seq: 0,
126 ticker: Some("PROV".into()),
127 name: Some("Prov".into()),
128 decimals: Some(0),
129 supply: Some(1000),
130 balances: Some(std::collections::BTreeMap::from([("02ab".into(), 1000)])),
131 ops: vec![],
132 anchor: None,
133 }
134 }
135
136 fn sample() -> StoredTrail {
137 StoredTrail {
138 ticker: "PROV".into(),
139 name: "Prov".into(),
140 supply: 1000,
141 privkey: "07".repeat(32),
142 pubkey_base: "02".to_string() + &"ab".repeat(32),
143 states: vec![genesis()],
144 state_strings: vec!["{\"seq\":0}".into()],
145 current_txid: "ab".repeat(32),
146 current_vout: 0,
147 current_amount: 9700,
148 network: "testnet4".into(),
149 date_created: "2026-06-13T00:00:00Z".into(),
150 }
151 }
152
153 #[test]
154 fn trail_path_lowercases() {
155 assert_eq!(trail_path("PROV"), "/.well-known/token/prov.json");
156 assert_eq!(trail_path("prov"), "/.well-known/token/prov.json");
157 }
158
159 #[test]
160 fn to_public_drops_privkey() {
161 let t = sample();
162 let public = t.to_public();
163 let json = serde_json::to_string(&public).unwrap();
166 assert!(!json.contains(&t.privkey), "privkey must not appear in public trail");
167 assert_eq!(public.ticker, "PROV");
168 assert_eq!(public.current_amount, 9700);
169 }
170
171 #[test]
172 fn stored_trail_uses_camelcase_keys() {
173 let json = serde_json::to_string(&sample()).unwrap();
175 assert!(json.contains("\"pubkeyBase\""));
176 assert!(json.contains("\"stateStrings\""));
177 assert!(json.contains("\"currentTxid\""));
178 assert!(json.contains("\"dateCreated\""));
179 }
180
181 #[tokio::test]
182 async fn load_missing_trail_is_none() {
183 let storage: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
184 let got = load_trail(&storage, "NOPE").await.unwrap();
185 assert!(got.is_none());
186 }
187
188 #[tokio::test]
189 async fn save_then_load_round_trips() {
190 let storage: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
191 save_trail(&storage, &sample()).await.unwrap();
192 let got = load_trail(&storage, "PROV").await.unwrap().expect("trail present");
193 assert_eq!(got.ticker, "PROV");
194 assert_eq!(got.privkey, "07".repeat(32));
195 assert_eq!(got.states.len(), 1);
196 assert!(load_trail(&storage, "prov").await.unwrap().is_some());
198 }
199
200 #[tokio::test]
201 async fn merge_public_keeps_secret() {
202 let mut t = sample();
203 let secret = t.privkey.clone();
204 let mut public = t.to_public();
205 public.current_txid = "ff".repeat(32);
206 public.current_amount = 9400;
207 public.states.push(genesis()); t.merge_public(&public);
209 assert_eq!(t.privkey, secret, "secret retained across merge");
210 assert_eq!(t.current_txid, "ff".repeat(32));
211 assert_eq!(t.current_amount, 9400);
212 assert_eq!(t.states.len(), 2);
213 }
214}