use std::sync::Arc;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use solid_pod_rs::blocktrail::{network_for_txo_chain, Blocktrail};
use solid_pod_rs::mrc20::{Mrc20State, Mrc20Trail};
use solid_pod_rs::payments::PaymentError;
use solid_pod_rs::storage::Storage;
use solid_pod_rs::PodError;
#[must_use]
pub fn trail_path(ticker: &str) -> String {
format!("/.well-known/token/{}.json", ticker.to_lowercase())
}
#[must_use]
pub fn is_secret_trail_path(path: &str) -> bool {
let Some(canon) = crate::canonical_pod_path(path) else {
return true;
};
let segs = lower_segments(&canon);
let segs: Vec<&str> = segs.iter().map(String::as_str).collect();
match segs.as_slice() {
[".well-known", "token", ..] => true,
[".well-known", "gitmark", _, "blocktrails.json"] => canon.ends_with('/'),
[".well-known", "gitmark", ..] => true,
_ => false,
}
}
#[must_use]
pub fn is_prov_state_path(path: &str) -> bool {
let Some(canon) = crate::canonical_pod_path(path) else {
return true;
};
let segs = lower_segments(&canon);
matches!(
segs.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.as_slice(),
[".well-known", "prov", ..]
)
}
#[must_use]
pub fn is_server_state_path(path: &str) -> bool {
let Some(canon) = crate::canonical_pod_path(path) else {
return true;
};
let segs = lower_segments(&canon);
matches!(
segs.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.as_slice(),
[".well-known", "webledgers", ..]
)
}
fn lower_segments(canon: &str) -> Vec<String> {
canon
.split('/')
.filter(|s| !s.is_empty())
.map(solid_pod_rs::security::dotfile::fold_case)
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredTrail {
pub ticker: String,
pub name: String,
pub supply: u64,
pub privkey: String,
pub pubkey_base: String,
pub states: Vec<Mrc20State>,
pub state_strings: Vec<String>,
pub current_txid: String,
pub current_vout: u32,
pub current_amount: u64,
pub network: String,
#[serde(default)]
pub date_created: String,
}
impl StoredTrail {
#[must_use]
pub fn to_public(&self) -> Mrc20Trail {
Mrc20Trail {
ticker: self.ticker.clone(),
name: self.name.clone(),
supply: self.supply,
pubkey_base: self.pubkey_base.clone(),
states: self.states.clone(),
state_strings: self.state_strings.clone(),
current_txid: self.current_txid.clone(),
current_vout: self.current_vout,
current_amount: self.current_amount,
network: self.network.clone(),
date_created: self.date_created.clone(),
}
}
pub fn merge_public(&mut self, public: &Mrc20Trail) {
self.states = public.states.clone();
self.state_strings = public.state_strings.clone();
self.current_txid = public.current_txid.clone();
self.current_vout = public.current_vout;
self.current_amount = public.current_amount;
self.supply = public.supply;
}
}
pub async fn load_trail(
storage: &Arc<dyn Storage>,
ticker: &str,
) -> Result<Option<StoredTrail>, PaymentError> {
match storage.get(&trail_path(ticker)).await {
Ok((bytes, _meta)) => {
let trail: StoredTrail = serde_json::from_slice(&bytes)
.map_err(|e| PaymentError::Store(format!("malformed trail {ticker}: {e}")))?;
Ok(Some(trail))
}
Err(PodError::NotFound(_)) => Ok(None),
Err(e) => Err(PaymentError::Store(format!("read trail {ticker}: {e}"))),
}
}
pub async fn save_trail(
storage: &Arc<dyn Storage>,
trail: &StoredTrail,
) -> Result<(), PaymentError> {
let body = serde_json::to_vec_pretty(trail)
.map_err(|e| PaymentError::Store(format!("serialise trail: {e}")))?;
storage
.put(
&trail_path(&trail.ticker),
Bytes::from(body),
"application/json",
)
.await
.map_err(|e| PaymentError::Store(format!("save trail: {e}")))?;
Ok(())
}
pub const DEFAULT_GITMARK_TRAIL: &str = "prov";
#[must_use]
pub fn gitmark_trail_path(name: &str) -> String {
format!("/.well-known/gitmark/{}.json", name.to_lowercase())
}
#[must_use]
pub fn gitmark_blocktrails_path(name: &str) -> String {
format!(
"/.well-known/gitmark/{}/blocktrails.json",
name.to_lowercase()
)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredGitmarkTrail {
pub name: String,
pub privkey: String,
pub trail: Blocktrail,
#[serde(default)]
pub date_created: String,
}
impl StoredGitmarkTrail {
#[must_use]
pub fn network(&self) -> String {
self.trail
.chain
.as_deref()
.map(network_for_txo_chain)
.unwrap_or_default()
}
}
pub async fn load_gitmark_trail(
storage: &Arc<dyn Storage>,
name: &str,
) -> Result<Option<StoredGitmarkTrail>, PaymentError> {
match storage.get(&gitmark_trail_path(name)).await {
Ok((bytes, _meta)) => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| PaymentError::Store(format!("malformed git-mark trail {name}: {e}"))),
Err(PodError::NotFound(_)) => Ok(None),
Err(e) => Err(PaymentError::Store(format!(
"read git-mark trail {name}: {e}"
))),
}
}
#[must_use]
pub fn gitmark_intent_path(name: &str) -> String {
format!("/.well-known/gitmark/{}/intent.json", name.to_lowercase())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitmarkIntent {
pub txid: String,
pub stored: StoredGitmarkTrail,
}
pub async fn load_gitmark_intent(
storage: &Arc<dyn Storage>,
name: &str,
) -> Result<Option<GitmarkIntent>, PaymentError> {
match storage.get(&gitmark_intent_path(name)).await {
Ok((bytes, _meta)) => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| PaymentError::Store(format!("malformed git-mark intent {name}: {e}"))),
Err(PodError::NotFound(_)) => Ok(None),
Err(e) => Err(PaymentError::Store(format!(
"read git-mark intent {name}: {e}"
))),
}
}
pub async fn save_gitmark_intent(
storage: &Arc<dyn Storage>,
intent: &GitmarkIntent,
) -> Result<(), PaymentError> {
let body = serde_json::to_vec(intent)
.map_err(|e| PaymentError::Store(format!("serialise git-mark intent: {e}")))?;
storage
.put(
&gitmark_intent_path(&intent.stored.name),
Bytes::from(body),
"application/json",
)
.await
.map_err(|e| PaymentError::Store(format!("save git-mark intent: {e}")))?;
Ok(())
}
pub async fn clear_gitmark_intent(
storage: &Arc<dyn Storage>,
name: &str,
) -> Result<(), PaymentError> {
match storage.delete(&gitmark_intent_path(name)).await {
Ok(()) | Err(PodError::NotFound(_)) => Ok(()),
Err(e) => Err(PaymentError::Store(format!(
"clear git-mark intent {name}: {e}"
))),
}
}
pub(crate) async fn lock_path(key: &str) -> tokio::sync::OwnedMutexGuard<()> {
type Locks = std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>;
static LOCKS: std::sync::LazyLock<std::sync::Mutex<Locks>> =
std::sync::LazyLock::new(Default::default);
let lock = {
let mut locks = LOCKS.lock().unwrap_or_else(|p| p.into_inner());
locks.retain(|_, l| Arc::strong_count(l) > 1);
locks.entry(key.to_string()).or_default().clone()
};
lock.lock_owned().await
}
pub async fn save_gitmark_trail(
storage: &Arc<dyn Storage>,
stored: &StoredGitmarkTrail,
) -> Result<(), PaymentError> {
let body = serde_json::to_vec_pretty(stored)
.map_err(|e| PaymentError::Store(format!("serialise git-mark trail: {e}")))?;
storage
.put(
&gitmark_trail_path(&stored.name),
Bytes::from(body),
"application/json",
)
.await
.map_err(|e| PaymentError::Store(format!("save git-mark trail: {e}")))?;
let public = serde_json::to_vec_pretty(&stored.trail)
.map_err(|e| PaymentError::Store(format!("serialise blocktrails.json: {e}")))?;
storage
.put(
&gitmark_blocktrails_path(&stored.name),
Bytes::from(public),
"application/json",
)
.await
.map_err(|e| PaymentError::Store(format!("save blocktrails.json: {e}")))?;
Ok(())
}
#[cfg(test)]
pub(crate) mod fault_storage {
use super::*;
use solid_pod_rs::storage::{ResourceMeta, StorageEvent};
use solid_pod_rs::PodError;
use std::collections::HashSet;
use std::sync::Mutex;
pub(crate) struct FaultStorage {
pub(crate) inner: Arc<dyn Storage>,
pub(crate) fail_get: Mutex<HashSet<String>>,
pub(crate) fail_put: Mutex<HashSet<String>>,
pub(crate) yielding: bool,
}
impl FaultStorage {
pub(crate) fn new(inner: Arc<dyn Storage>, yielding: bool) -> Arc<Self> {
Arc::new(Self {
inner,
fail_get: Mutex::default(),
fail_put: Mutex::default(),
yielding,
})
}
pub(crate) fn fail_get(&self, path: &str) {
self.fail_get.lock().unwrap().insert(path.to_string());
}
pub(crate) fn fail_put(&self, path: &str) {
self.fail_put.lock().unwrap().insert(path.to_string());
}
pub(crate) fn heal(&self) {
self.fail_get.lock().unwrap().clear();
self.fail_put.lock().unwrap().clear();
}
fn io() -> PodError {
PodError::Io(std::io::Error::other("injected fault"))
}
}
#[async_trait::async_trait]
impl Storage for FaultStorage {
async fn get(&self, path: &str) -> Result<(Bytes, ResourceMeta), PodError> {
if self.yielding {
tokio::task::yield_now().await;
}
if self.fail_get.lock().unwrap().contains(path) {
return Err(Self::io());
}
self.inner.get(path).await
}
async fn put(
&self,
path: &str,
body: Bytes,
content_type: &str,
) -> Result<ResourceMeta, PodError> {
if self.yielding {
tokio::task::yield_now().await;
}
if self.fail_put.lock().unwrap().contains(path) {
return Err(Self::io());
}
self.inner.put(path, body, content_type).await
}
async fn delete(&self, path: &str) -> Result<(), PodError> {
self.inner.delete(path).await
}
async fn list(&self, container: &str) -> Result<Vec<String>, PodError> {
self.inner.list(container).await
}
async fn head(&self, path: &str) -> Result<ResourceMeta, PodError> {
self.inner.head(path).await
}
async fn exists(&self, path: &str) -> Result<bool, PodError> {
self.inner.exists(path).await
}
async fn watch(
&self,
path: &str,
) -> Result<tokio::sync::mpsc::Receiver<StorageEvent>, PodError> {
self.inner.watch(path).await
}
}
}
#[cfg(test)]
mod tests {
use super::fault_storage::FaultStorage;
use super::*;
use solid_pod_rs::mrc20::MRC20_PROFILE;
use solid_pod_rs::storage::memory::MemoryBackend;
#[test]
fn secret_and_prov_paths_compare_whole_segments() {
for p in [
"/.well-known/token/x.json",
".well-known//token/x.json",
"/.well-known/token/blocktrails.json",
"/.WELL-KNOWN/gitmark/prov/intent.json",
"/.well-known/TOKEN/x.json",
"/.well-known/to\u{212A}en/x.json",
"/.well-known/gitmar\u{212A}/prov/intent.json",
"/.well-known/token/",
] {
assert!(is_secret_trail_path(p), "{p}");
}
for p in [
"/.well-known/gitmark/prov/blocktrails.json",
"/.well-known/tokens/x.json",
"/x/.well-known/token/y",
"/open/y",
] {
assert!(!is_secret_trail_path(p), "{p}");
}
assert!(is_prov_state_path("//.well-known/prov/epoch.json"));
assert!(!is_prov_state_path("/.well-known/provision/x"));
}
fn genesis() -> Mrc20State {
Mrc20State {
profile: MRC20_PROFILE.into(),
prev: "0".repeat(64),
seq: 0,
ticker: Some("PROV".into()),
name: Some("Prov".into()),
decimals: Some(0),
supply: Some(1000),
balances: Some(std::collections::BTreeMap::from([("02ab".into(), 1000)])),
ops: vec![],
anchor: None,
}
}
fn sample() -> StoredTrail {
StoredTrail {
ticker: "PROV".into(),
name: "Prov".into(),
supply: 1000,
privkey: "07".repeat(32),
pubkey_base: "02".to_string() + &"ab".repeat(32),
states: vec![genesis()],
state_strings: vec!["{\"seq\":0}".into()],
current_txid: "ab".repeat(32),
current_vout: 0,
current_amount: 9700,
network: "testnet4".into(),
date_created: "2026-06-13T00:00:00Z".into(),
}
}
#[test]
fn trail_path_lowercases() {
assert_eq!(trail_path("PROV"), "/.well-known/token/prov.json");
assert_eq!(trail_path("prov"), "/.well-known/token/prov.json");
}
#[test]
fn to_public_drops_privkey() {
let t = sample();
let public = t.to_public();
let json = serde_json::to_string(&public).unwrap();
assert!(
!json.contains(&t.privkey),
"privkey must not appear in public trail"
);
assert_eq!(public.ticker, "PROV");
assert_eq!(public.current_amount, 9700);
}
#[test]
fn stored_trail_uses_camelcase_keys() {
let json = serde_json::to_string(&sample()).unwrap();
assert!(json.contains("\"pubkeyBase\""));
assert!(json.contains("\"stateStrings\""));
assert!(json.contains("\"currentTxid\""));
assert!(json.contains("\"dateCreated\""));
}
#[tokio::test]
async fn load_missing_trail_is_none() {
let storage: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
let got = load_trail(&storage, "NOPE").await.unwrap();
assert!(got.is_none());
}
#[tokio::test]
async fn save_then_load_round_trips() {
let storage: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
save_trail(&storage, &sample()).await.unwrap();
let got = load_trail(&storage, "PROV")
.await
.unwrap()
.expect("trail present");
assert_eq!(got.ticker, "PROV");
assert_eq!(got.privkey, "07".repeat(32));
assert_eq!(got.states.len(), 1);
assert!(load_trail(&storage, "prov").await.unwrap().is_some());
}
#[tokio::test]
async fn merge_public_keeps_secret() {
let mut t = sample();
let secret = t.privkey.clone();
let mut public = t.to_public();
public.current_txid = "ff".repeat(32);
public.current_amount = 9400;
public.states.push(genesis()); t.merge_public(&public);
assert_eq!(t.privkey, secret, "secret retained across merge");
assert_eq!(t.current_txid, "ff".repeat(32));
assert_eq!(t.current_amount, 9400);
assert_eq!(t.states.len(), 2);
}
#[tokio::test]
async fn gitmark_trail_round_trips_and_publishes_no_secret() {
use solid_pod_rs::blocktrail::BlocktrailTxo;
let storage: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
let commit = "9adc596cfd1100333393a12f2f41b2d820f16d0b";
let stored = StoredGitmarkTrail {
name: "Prov".into(),
privkey: "07".repeat(32),
trail: Blocktrail::gitmark(
"02".to_string() + &"ab".repeat(32),
"tbtc4",
vec![commit.into()],
vec![BlocktrailTxo::new("tbtc4", "cd".repeat(32), 0).with_commit(commit)],
),
date_created: "2026-10-02T00:00:00Z".into(),
};
assert_eq!(stored.network(), "testnet4");
save_gitmark_trail(&storage, &stored).await.unwrap();
let back = load_gitmark_trail(&storage, "prov").await.unwrap().unwrap();
assert_eq!(back.trail, stored.trail);
assert_eq!(back.privkey, stored.privkey);
let (public, _) = storage
.get(&gitmark_blocktrails_path("PROV"))
.await
.unwrap();
let public = String::from_utf8(public.to_vec()).unwrap();
assert!(
!public.contains(&stored.privkey),
"the secret is never published"
);
let parsed: Blocktrail = serde_json::from_str(&public).unwrap();
assert_eq!(parsed, stored.trail);
assert!(load_gitmark_trail(&storage, "none")
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn loaders_propagate_storage_errors_and_only_not_found_is_absent() {
let mem: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
let faulty = FaultStorage::new(mem, false);
faulty.fail_get(&trail_path("prov"));
faulty.fail_get(&gitmark_trail_path("prov"));
let storage: Arc<dyn Storage> = faulty.clone();
assert!(load_trail(&storage, "prov").await.is_err());
assert!(load_gitmark_trail(&storage, "prov").await.is_err());
assert!(load_trail(&storage, "other").await.unwrap().is_none());
assert!(load_gitmark_trail(&storage, "other")
.await
.unwrap()
.is_none());
}
}