Skip to main content

solid_pod_rs_server/
trail_store.rs

1//! MRC20 trail persistence — the server-side load/save for a token's
2//! Bitcoin-anchored state chain (ADR-059 Phase 4; JSS `token.js:189-208`).
3//!
4//! A trail lives at `/.well-known/token/{ticker}.json` in pod storage. JSS
5//! persists the issuer **private key** straight inside the trail JSON
6//! (`token.js:294`) so `transferToken` can re-sign the next state. We keep the
7//! same on-disk shape but model it as a [`StoredTrail`] = the public
8//! [`Mrc20Trail`] plus a `privkey` (and a `date_created` the library type does
9//! not carry), so the secret never leaks onto the shared `Mrc20Trail` type that
10//! flows through wasm/core and the portable proof.
11//!
12//! Native-only: it uses `Arc<dyn Storage>` (the pod's filesystem/cloud
13//! backend). wasm consumers never compile this.
14
15use 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/// Pod-storage path of a trail file (JSS `token.js:194-196`). Ticker is
25/// lower-cased so `PROV` and `prov` resolve to one file.
26#[must_use]
27pub fn trail_path(ticker: &str) -> String {
28    format!("/.well-known/token/{}.json", ticker.to_lowercase())
29}
30
31/// The persisted trail — the public [`Mrc20Trail`] plus the issuer secret and
32/// creation timestamp (JSS stores all of these in one JSON blob,
33/// `token.js:290-303`). Serialises with camelCase keys to match the JSS file
34/// format byte-for-byte so a JSS-written trail loads here and vice versa.
35#[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    /// Issuer private key (64-char hex). **Secret** — never placed on the
42    /// public `Mrc20Trail` nor in any portable proof.
43    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    /// Project to the public [`Mrc20Trail`] (drops the secret `privkey`).
57    #[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    /// Re-absorb a public [`Mrc20Trail`] (e.g. the appended trail returned by a
75    /// transfer/anchor) while keeping this trail's secret `privkey`.
76    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
86/// Load a trail for `ticker`, or `None` if it does not exist (JSS
87/// `loadTrail`, `token.js:198-203` — a read failure is "no trail", not an
88/// error).
89pub 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
103/// Persist a trail (JSS `saveTrail`, `token.js:205-208`). Pretty-printed to
104/// match the JSS `JSON.stringify(trail, null, 2)` file format.
105pub 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        // The public type has no privkey field; round-trip its JSON and assert
164        // the secret is absent.
165        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        // JSS file-format parity: keys are camelCase (pubkeyBase, stateStrings…).
174        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        // Lower-cased lookup also resolves.
197        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()); // simulate an appended state
208        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}