use std::sync::Arc;
use actix_web::{web, Error as ActixError, HttpRequest, HttpResponse};
use bytes::Bytes;
use solid_pod_rs::provenance::{
prov_ttl, AnchorPolicy, BlockAnchorer, BlockTrailAnchor, ClosedEpoch, EpochAccumulator,
ProvenanceError, ProvenanceMark,
};
use solid_pod_rs::storage::Storage;
use solid_pod_rs::wac::{anchor_mode_of, AnchorMode};
use solid_pod_rs::PodError;
use crate::mempool::{GitmarkAnchorer, MempoolBlockAnchorer, MempoolHttpClient};
use crate::trail_store::{load_gitmark_trail, load_trail, DEFAULT_GITMARK_TRAIL};
use crate::{extract_pubkey_with_body, pod_repo_path, require_pod_owner_with_body, AppState};
const DEFAULT_EPOCH_SIZE: usize = 16;
pub const EPOCH_SIZE_ENV: &str = "JSS_PROV_EPOCH_SIZE";
const EPOCH_PATH: &str = "/.well-known/prov/epoch.json";
const EPOCH_ANCHORED_PATH: &str = "/.well-known/prov/epoch-anchored.json";
fn epoch_size() -> usize {
std::env::var(EPOCH_SIZE_ENV)
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|n| *n >= 1)
.unwrap_or(DEFAULT_EPOCH_SIZE)
}
pub(crate) async fn resolve_anchor_policy(
state: &AppState,
resource_path: &str,
) -> (AnchorPolicy, Option<String>) {
let acl = match crate::find_effective_acl_dyn(&*state.storage, resource_path).await {
Ok(Some(doc)) => doc,
_ => return (AnchorPolicy::Never, None),
};
let Some(graph) = acl.graph.as_ref() else {
return (AnchorPolicy::Never, None);
};
for auth in graph {
if let Some(conds) = auth.condition.as_ref() {
if let Some(mode) = anchor_mode_of(conds) {
let ticker = conds.iter().find_map(|c| match c {
solid_pod_rs::wac::Condition::ProvenanceAnchor(b) => b.ticker.clone(),
_ => None,
});
let policy = match mode {
AnchorMode::Inline => AnchorPolicy::HighValue,
AnchorMode::Epoch => AnchorPolicy::Epoch,
};
return (policy, ticker);
}
}
}
(AnchorPolicy::Never, None)
}
struct UnavailableAnchorer(String);
#[async_trait::async_trait(?Send)]
impl BlockAnchorer for UnavailableAnchorer {
async fn anchor(
&self,
_ticker: &str,
_state_hash: &str,
_network: &str,
) -> Result<BlockTrailAnchor, ProvenanceError> {
Err(ProvenanceError::Anchor(self.0.clone()))
}
async fn verify(&self, _anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
Err(ProvenanceError::Anchor(self.0.clone()))
}
}
pub(crate) async fn build_anchorer(
state: &AppState,
ticker_override: Option<&str>,
) -> Option<(Arc<dyn BlockAnchorer>, String, String)> {
match try_build_anchorer(state, ticker_override).await {
Ok(bundle) => bundle,
Err(e) => {
tracing::warn!(
target: "solid_pod_rs_server::prov",
"anchorer unavailable (trail unreadable): {e}"
);
Some((
Arc::new(UnavailableAnchorer(e)),
ticker_override.unwrap_or_default().to_string(),
String::new(),
))
}
}
}
pub(crate) async fn try_build_anchorer(
state: &AppState,
ticker_override: Option<&str>,
) -> Result<Option<(Arc<dyn BlockAnchorer>, String, String)>, String> {
let token = state.pay_config.token.as_ref();
let ticker = ticker_override
.filter(|t| !t.is_empty())
.map(str::to_string)
.or_else(|| token.map(|t| t.ticker.clone()).filter(|t| !t.is_empty()))
.unwrap_or_else(|| DEFAULT_GITMARK_TRAIL.to_string());
let mempool = || match &state.mempool_url {
Some(url) => MempoolHttpClient::new(url.clone()),
None => MempoolHttpClient::from_env(),
};
if token.is_some() {
if let Some(trail) = load_trail(&state.storage, &ticker)
.await
.map_err(|e| e.to_string())?
{
let anchorer = MempoolBlockAnchorer::with_storage(mempool(), state.storage.clone());
return Ok(Some((Arc::new(anchorer), ticker, trail.network)));
}
}
let Some(trail) = load_gitmark_trail(&state.storage, &ticker)
.await
.map_err(|e| e.to_string())?
else {
return Ok(None);
};
let anchorer = GitmarkAnchorer::with_storage(mempool(), state.storage.clone());
Ok(Some((Arc::new(anchorer), ticker, trail.network())))
}
async fn load_epoch_commits(storage: &Arc<dyn Storage>) -> Result<Vec<String>, String> {
match storage.get(EPOCH_PATH).await {
Ok((bytes, _)) => {
serde_json::from_slice(&bytes).map_err(|e| format!("malformed epoch batch: {e}"))
}
Err(PodError::NotFound(_)) => Ok(Vec::new()),
Err(e) => Err(format!("read epoch batch: {e}")),
}
}
async fn save_epoch_commits(storage: &Arc<dyn Storage>, commits: &[String]) -> Result<(), String> {
let body = serde_json::to_vec(commits).map_err(|e| e.to_string())?;
storage
.put(EPOCH_PATH, Bytes::from(body), "application/json")
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
const EPOCH_BACKLOG_FACTOR: usize = 4;
pub(crate) async fn epoch_push_and_maybe_anchor(
state: &AppState,
anchorer: &Arc<dyn BlockAnchorer>,
ticker: &str,
network: &str,
commit_sha: &str,
) -> Result<Option<ClosedEpoch>, String> {
let size = epoch_size();
let _guard = crate::trail_store::lock_path(EPOCH_PATH).await;
let mut commits = load_epoch_commits(&state.storage).await?;
match state.storage.get(EPOCH_ANCHORED_PATH).await {
Ok((bytes, _)) => {
let done: Vec<String> = serde_json::from_slice(&bytes)
.map_err(|e| format!("malformed anchored-epoch marker: {e}"))?;
commits.retain(|c| !done.contains(c));
save_epoch_commits(&state.storage, &commits).await?;
if let Err(e) = state.storage.delete(EPOCH_ANCHORED_PATH).await {
tracing::warn!("anchored-epoch marker not cleared: {e}");
}
}
Err(PodError::NotFound(_)) => {}
Err(e) => return Err(format!("read anchored-epoch marker: {e}")),
}
if !commits.iter().any(|c| c == commit_sha) {
if commits.len() < size.saturating_mul(EPOCH_BACKLOG_FACTOR) {
commits.push(commit_sha.to_string());
} else {
tracing::warn!(
backlog = commits.len(),
"epoch backlog full: commit not accumulated, anchoring keeps failing"
);
}
}
save_epoch_commits(&state.storage, &commits).await?;
if commits.len() < size {
return Ok(None);
}
let mut acc = EpochAccumulator::new(size);
for c in &commits {
acc.push(c.clone());
}
let closed = acc.close().ok_or("epoch close produced nothing")?;
anchorer
.anchor(ticker, &closed.root, network)
.await
.map_err(|e| e.to_string())?;
if let Err(reset) = save_epoch_commits(&state.storage, &[]).await {
let marker = serde_json::to_vec(&closed.commits).map_err(|e| e.to_string())?;
if let Err(e) = state
.storage
.put(EPOCH_ANCHORED_PATH, Bytes::from(marker), "application/json")
.await
{
tracing::error!(
root = %closed.root,
"epoch anchored but neither the reset ({reset}) nor the marker ({e}) was saved; \
the next push will anchor this batch again"
);
return Err(format!(
"epoch anchored but not reset: {reset}; marker: {e}"
));
}
tracing::warn!(root = %closed.root, "epoch anchored; reset failed ({reset}), marker kept");
}
Ok(Some(closed))
}
async fn handle_resolve(
path: web::Path<(String, String)>,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let (pod, commit_sha) = path.into_inner();
let Some(repo) = pod_repo_path(&state, &pod) else {
return Ok(prov_err(
"git provenance not available (no FS backend / invalid pod)",
501,
));
};
if !repo.join(".git").is_dir() {
return Ok(prov_err("pod is not git-backed", 404));
}
let resolved = match solid_pod_rs_git::resolve_commit(&repo, &commit_sha).await {
Ok(r) => r,
Err(e) => {
let code = e.status_code();
return Ok(prov_err(
&format!("commit not found: {e}"),
if code == 404 { 404 } else { 400 },
));
}
};
let resource_rel = resolved.files.iter().find(|f| !is_sidecar(f)).cloned();
let Some(resource_rel) = resource_rel else {
return Ok(prov_err("commit touched no content resource", 404));
};
let resource = format!("/{pod}/{resource_rel}");
let sidecar = format!("{resource}.prov.ttl");
let sidecar_ttl = state
.storage
.get(&sidecar)
.await
.ok()
.map(|(b, _)| String::from_utf8_lossy(&b).into_owned());
let body = serde_json::json!({
"pod": pod,
"resource": resource,
"commit": {
"sha": resolved.hash,
"parent": resolved.parent,
"agent_did": resolved.author_email,
"committer": resolved.author_name,
"subject": resolved.subject,
"committed_at": resolved.committed_at,
},
"prov_ttl": sidecar_ttl,
"anchored": sidecar_ttl.as_deref().map(|t| t.contains("bt:txid")).unwrap_or(false),
});
Ok(HttpResponse::Ok()
.content_type("application/json")
.json(body))
}
#[derive(Debug, serde::Deserialize)]
struct AnchorBody {
commit_sha: String,
#[serde(default)]
ticker: Option<String>,
}
async fn handle_anchor(
path: web::Path<String>,
req: HttpRequest,
state: web::Data<AppState>,
body: Bytes,
) -> Result<HttpResponse, ActixError> {
let pod = path.into_inner();
let owner = match require_pod_owner_with_body(&req, &pod, Some(&body)).await {
Some(pk) => pk,
None => {
return Ok(match extract_pubkey_with_body(&req, Some(&body)).await {
Some(_) => prov_err("not the pod owner", 403),
None => prov_err("NIP-98 authentication required", 401),
});
}
};
let did = format!("did:nostr:{owner}");
let req_body: AnchorBody = match serde_json::from_slice(&body) {
Ok(b) => b,
Err(e) => return Ok(prov_err(&format!("malformed anchor request: {e}"), 400)),
};
if req_body.commit_sha.is_empty()
|| req_body.commit_sha.len() > 64
|| !req_body.commit_sha.bytes().all(|b| b.is_ascii_hexdigit())
{
return Ok(prov_err("invalid commit_sha", 400));
}
let Some(repo) = pod_repo_path(&state, &pod) else {
return Ok(prov_err("git provenance not available", 501));
};
if !repo.join(".git").is_dir() {
return Ok(prov_err("pod is not git-backed", 404));
}
let resolved = match solid_pod_rs_git::resolve_commit(&repo, &req_body.commit_sha).await {
Ok(r) => r,
Err(e) => return Ok(prov_err(&format!("commit not found: {e}"), 404)),
};
let Some(resource_rel) = resolved.files.iter().find(|f| !is_sidecar(f)).cloned() else {
return Ok(prov_err("commit touched no content resource", 404));
};
let resource = format!("/{pod}/{resource_rel}");
let (anchorer, ticker, network) =
match try_build_anchorer(&state, req_body.ticker.as_deref()).await {
Ok(Some(bundle)) => bundle,
Ok(None) => {
return Ok(prov_err(
"pod not configured for Bitcoin anchoring (no pay-token / trail not minted)",
400,
))
}
Err(e) => return Ok(prov_err(&format!("anchor trail unavailable: {e}"), 500)),
};
let price = anchor_price_sats(&state);
let charge = if price > 0 {
match debit(&state, &did, price).await {
Ok(charge) => Some(charge),
Err(rsp) => return Ok(rsp),
}
} else {
None
};
let anchor = match anchorer.anchor(&ticker, &resolved.hash, &network).await {
Ok(a) => a,
Err(e) if crate::mempool::is_pending_anchor(&e) => {
tracing::warn!(
target: "solid_pod_rs_server::prov",
"anchor outcome pending, charge kept: {e}"
);
return Ok(HttpResponse::Accepted()
.content_type("application/json")
.json(serde_json::json!({
"status": "pending",
"error": format!("anchor pending: {e}"),
"charged_sats": price,
})));
}
Err(e) => {
let refund_pending = match charge {
Some(charge) => !refund_or_retain(&state, charge).await,
None => false,
};
return Ok(HttpResponse::BadGateway()
.content_type("application/json")
.json(serde_json::json!({
"error": format!("anchor failed: {e}"),
"refund_pending": refund_pending,
})));
}
};
let mark = ProvenanceMark {
resource: resource.clone(),
git: solid_pod_rs::provenance::GitMark {
commit_sha: resolved.hash.clone(),
repo: pod.clone(),
branch: "main".to_string(),
parent: resolved.parent.clone(),
},
anchor: Some(anchor.clone()),
agent_did: resolved.author_email.clone(),
created: resolved.committed_at,
};
let ttl = prov_ttl(&mark);
let sidecar = format!("{resource}.prov.ttl");
if let Err(e) = state
.storage
.put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
.await
{
tracing::warn!(
target: "solid_pod_rs_server::prov",
sidecar = %sidecar,
"anchor sidecar rewrite failed (anchor still on-chain): {e}"
);
}
Ok(HttpResponse::Ok()
.content_type("application/json")
.json(serde_json::json!({
"pod": pod,
"resource": resource,
"commit_sha": resolved.hash,
"anchor": {
"ticker": anchor.ticker,
"txid": anchor.txid,
"vout": anchor.vout,
"address": anchor.address,
"network": anchor.network,
"state_hash": anchor.state_hash,
},
"charged_sats": price,
})))
}
fn is_sidecar(path: &str) -> bool {
path.ends_with(".acl") || path.ends_with(".meta") || path.ends_with(".prov.ttl")
}
fn anchor_price_sats(state: &AppState) -> u64 {
std::env::var("JSS_PROV_ANCHOR_PRICE_SATS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or_else(|| {
state
.pay_config
.token
.as_ref()
.map(|t| t.rate.max(1))
.unwrap_or(0)
})
}
async fn debit(
state: &AppState,
did: &str,
sats: u64,
) -> Result<solid_pod_rs::payments::Charge, HttpResponse> {
use crate::handlers::pay::StoragePaymentStore;
use solid_pod_rs::payments::{PaymentError, PaymentStore};
let _transaction = crate::PAYMENT_STATE_LOCK.lock().await;
let store = StoragePaymentStore::new(&*state.storage);
let mut ledger = store
.read_ledger()
.await
.map_err(|e| prov_err(&format!("ledger read failed: {e}"), 500))?;
let charge = ledger.charge(did, sats).map_err(|e| match e {
PaymentError::InsufficientBalance { balance, cost } => HttpResponse::PaymentRequired()
.json(serde_json::json!({
"error": "Insufficient balance to anchor",
"balance": balance,
"cost": cost,
})),
other => prov_err(&format!("debit failed: {other}"), 500),
})?;
store
.write_ledger(&ledger)
.await
.map_err(|e| prov_err(&format!("ledger write failed: {e}"), 500))?;
Ok(charge)
}
async fn refund(state: &AppState, charge: solid_pod_rs::payments::Charge) -> Result<(), String> {
use crate::handlers::pay::StoragePaymentStore;
use solid_pod_rs::payments::PaymentStore;
let _transaction = crate::PAYMENT_STATE_LOCK.lock().await;
let store = StoragePaymentStore::new(&*state.storage);
let mut ledger = store.read_ledger().await.map_err(|e| e.to_string())?;
ledger.refund(charge);
store.write_ledger(&ledger).await.map_err(|e| e.to_string())
}
const PENDING_REFUNDS_PATH: &str = "/.well-known/prov/refunds-pending.json";
async fn refund_or_retain(state: &AppState, charge: solid_pod_rs::payments::Charge) -> bool {
let (did, amount) = (charge.account().to_string(), charge.amount());
let Err(reason) = refund(state, charge).await else {
return true;
};
tracing::error!(
target: "solid_pod_rs_server::prov",
%did, amount, "anchor refund failed, retaining it as pending: {reason}"
);
if let Err(e) = retain_refund(&state.storage, &did, amount, &reason).await {
tracing::error!(
target: "solid_pod_rs_server::prov",
%did, amount, "pending refund could not be recorded either: {e}"
);
}
false
}
async fn retain_refund(
storage: &Arc<dyn Storage>,
did: &str,
amount: u64,
reason: &str,
) -> Result<(), String> {
let _guard = crate::trail_store::lock_path(PENDING_REFUNDS_PATH).await;
let mut owed: Vec<serde_json::Value> = match storage.get(PENDING_REFUNDS_PATH).await {
Ok((bytes, _)) => {
serde_json::from_slice(&bytes).map_err(|e| format!("malformed pending refunds: {e}"))?
}
Err(PodError::NotFound(_)) => Vec::new(),
Err(e) => return Err(format!("read pending refunds: {e}")),
};
owed.push(serde_json::json!({
"did": did,
"amount_sats": amount,
"error": reason,
"at": chrono::Utc::now().to_rfc3339(),
}));
let body = serde_json::to_vec(&owed).map_err(|e| e.to_string())?;
storage
.put(PENDING_REFUNDS_PATH, Bytes::from(body), "application/json")
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
fn prov_err(msg: &str, status: u16) -> HttpResponse {
HttpResponse::build(
actix_web::http::StatusCode::from_u16(status)
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
)
.content_type("application/json")
.json(serde_json::json!({ "error": msg }))
}
pub fn register(app: &mut web::ServiceConfig) {
app.route("/{pod}/_prov/anchor", web::post().to(handle_anchor))
.route("/{pod}/_prov/{commit_sha}", web::get().to(handle_resolve));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trail_store::fault_storage::FaultStorage;
use crate::trail_store::{gitmark_trail_path, trail_path};
use solid_pod_rs::provenance::{BlockTrailAnchor, ProvenanceError};
use solid_pod_rs::storage::memory::MemoryBackend;
struct Unreachable;
#[async_trait::async_trait(?Send)]
impl BlockAnchorer for Unreachable {
async fn anchor(
&self,
_: &str,
_: &str,
_: &str,
) -> Result<BlockTrailAnchor, ProvenanceError> {
panic!("epoch must not close in this test")
}
async fn verify(&self, _: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
Ok(false)
}
}
fn state_over(storage: Arc<dyn Storage>) -> AppState {
AppState::new(storage)
}
async fn epoch_on_disk(inner: &Arc<dyn Storage>) -> Vec<String> {
let (bytes, _) = inner.get(EPOCH_PATH).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn epoch_read_error_keeps_the_pending_batch() {
let inner: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
save_epoch_commits(&inner, &["a".into(), "b".into()])
.await
.unwrap();
let faulty = FaultStorage::new(inner.clone(), false);
faulty.fail_get(EPOCH_PATH);
let state = state_over(faulty.clone());
let anchorer: Arc<dyn BlockAnchorer> = Arc::new(Unreachable);
let out = epoch_push_and_maybe_anchor(&state, &anchorer, "prov", "testnet4", "c").await;
assert!(
out.is_err(),
"a failed read must not look like an empty batch"
);
assert_eq!(epoch_on_disk(&inner).await, vec!["a", "b"], "batch lost");
}
#[tokio::test]
async fn epoch_corrupt_batch_is_not_overwritten() {
let inner: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
inner
.put(
EPOCH_PATH,
Bytes::from_static(b"{not json"),
"application/json",
)
.await
.unwrap();
let state = state_over(inner.clone());
let anchorer: Arc<dyn BlockAnchorer> = Arc::new(Unreachable);
let out = epoch_push_and_maybe_anchor(&state, &anchorer, "prov", "testnet4", "c").await;
assert!(out.is_err());
let (bytes, _) = inner.get(EPOCH_PATH).await.unwrap();
assert_eq!(&bytes[..], b"{not json");
}
#[tokio::test]
async fn concurrent_epoch_pushes_both_survive() {
let inner: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
let faulty = FaultStorage::new(inner.clone(), true);
let state = state_over(faulty.clone());
let anchorer: Arc<dyn BlockAnchorer> = Arc::new(Unreachable);
let (a, b) = tokio::join!(
epoch_push_and_maybe_anchor(&state, &anchorer, "prov", "testnet4", "a"),
epoch_push_and_maybe_anchor(&state, &anchorer, "prov", "testnet4", "b"),
);
a.unwrap();
b.unwrap();
let mut got = epoch_on_disk(&inner).await;
got.sort();
assert_eq!(got, vec!["a", "b"], "lost update in the epoch RMW");
}
#[tokio::test]
async fn build_anchorer_storage_error_is_not_silent_git_only() {
let inner: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
let faulty = FaultStorage::new(inner, false);
faulty.fail_get(&gitmark_trail_path("prov"));
let state = state_over(faulty.clone());
let (anchorer, ticker, network) = build_anchorer(&state, None)
.await
.expect("a storage fault must surface as a failing anchorer, not git-only");
let err = anchorer.anchor(&ticker, "ab", &network).await.unwrap_err();
assert!(err.to_string().contains("injected fault"), "{err}");
}
#[tokio::test]
async fn build_anchorer_mrc20_trail_read_error_does_not_fall_through() {
let inner: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
let faulty = FaultStorage::new(inner, false);
faulty.fail_get(&trail_path("prov"));
let mut state = state_over(faulty.clone());
state.pay_config.token = Some(solid_pod_rs::payments::TokenConfig {
ticker: "prov".into(),
rate: 1,
supply: 1000,
issuer: "02".to_string() + &"ab".repeat(32),
accepted_issuers: vec![],
});
let (anchorer, ticker, network) = build_anchorer(&state, None)
.await
.expect("an MRC20 read fault must not fall through to git-mark/None");
let err = anchorer.anchor(&ticker, "ab", &network).await.unwrap_err();
assert!(err.to_string().contains("injected fault"), "{err}");
}
#[tokio::test]
async fn failed_refund_is_logged_and_retained() {
use solid_pod_rs::payments::{PaymentStore, ReceiptOutcome};
let inner: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
let faulty = FaultStorage::new(inner.clone(), false);
let state = state_over(faulty.clone());
let did = format!("did:nostr:{}", "ab".repeat(32));
{
let store = crate::handlers::pay::StoragePaymentStore::new(&*state.storage);
let mut ledger = store.read_ledger().await.unwrap();
let r = ledger
.credit_by_outpoint(&did, "satoshi", &"cd".repeat(32), 0, 100)
.unwrap();
assert_eq!(r, ReceiptOutcome::Applied);
store.write_ledger(&ledger).await.unwrap();
}
let Ok(charge) = debit(&state, &did, 40).await else {
panic!("debit failed");
};
faulty.fail_put("/.well-known/webledgers/state.json");
assert!(!refund_or_retain(&state, charge).await);
let (bytes, _) = inner.get(PENDING_REFUNDS_PATH).await.unwrap();
let owed: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(owed.len(), 1);
assert_eq!(owed[0]["did"], did.as_str());
assert_eq!(owed[0]["amount_sats"], 40);
}
struct Failing;
#[async_trait::async_trait(?Send)]
impl BlockAnchorer for Failing {
async fn anchor(
&self,
_: &str,
_: &str,
_: &str,
) -> Result<BlockTrailAnchor, ProvenanceError> {
Err(ProvenanceError::Anchor("chain unreachable".into()))
}
async fn verify(&self, _: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
Ok(false)
}
}
struct CountingThenFault {
calls: std::sync::atomic::AtomicUsize,
storage: Arc<FaultStorage>,
}
#[async_trait::async_trait(?Send)]
impl BlockAnchorer for CountingThenFault {
async fn anchor(
&self,
ticker: &str,
state_hash: &str,
network: &str,
) -> Result<BlockTrailAnchor, ProvenanceError> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.storage.fail_put(EPOCH_PATH);
Ok(BlockTrailAnchor {
ticker: ticker.into(),
state_hash: state_hash.into(),
txid: "ab".repeat(32),
vout: 0,
address: String::new(),
network: network.into(),
blockheight: None,
state_strings: vec![],
pubkey: None,
})
}
async fn verify(&self, _: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
Ok(true)
}
}
#[tokio::test]
async fn lost_epoch_reset_does_not_anchor_the_same_batch_twice() {
let inner: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
let full: Vec<String> = (0..DEFAULT_EPOCH_SIZE - 1)
.map(|i| format!("c{i}"))
.collect();
save_epoch_commits(&inner, &full).await.unwrap();
let faulty = FaultStorage::new(inner.clone(), false);
let state = state_over(faulty.clone());
let counting = Arc::new(CountingThenFault {
calls: Default::default(),
storage: faulty.clone(),
});
let anchorer: Arc<dyn BlockAnchorer> = counting.clone();
let first =
epoch_push_and_maybe_anchor(&state, &anchorer, "prov", "testnet4", "last").await;
faulty.heal();
let second =
epoch_push_and_maybe_anchor(&state, &anchorer, "prov", "testnet4", "next").await;
assert_eq!(
counting.calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"the anchored batch was anchored again"
);
assert!(first.is_ok_and(|c| c.is_some()), "the batch was anchored");
assert!(second.is_ok_and(|c| c.is_none()));
assert_eq!(epoch_on_disk(&inner).await, vec!["next"]);
assert!(!inner.exists(EPOCH_ANCHORED_PATH).await.unwrap());
}
#[tokio::test]
async fn failing_anchor_cannot_grow_the_epoch_without_bound() {
let inner: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
let backlog: Vec<String> = (0..DEFAULT_EPOCH_SIZE * EPOCH_BACKLOG_FACTOR)
.map(|i| format!("c{i}"))
.collect();
save_epoch_commits(&inner, &backlog).await.unwrap();
let state = state_over(inner.clone());
let anchorer: Arc<dyn BlockAnchorer> = Arc::new(Failing);
let out = epoch_push_and_maybe_anchor(&state, &anchorer, "prov", "testnet4", "more").await;
assert!(out.is_err());
assert_eq!(epoch_on_disk(&inner).await, backlog, "epoch.json grew");
}
#[tokio::test]
async fn a_full_backlog_still_drains_once_anchoring_recovers() {
let inner: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
let backlog: Vec<String> = (0..DEFAULT_EPOCH_SIZE * EPOCH_BACKLOG_FACTOR)
.map(|i| format!("c{i}"))
.collect();
save_epoch_commits(&inner, &backlog).await.unwrap();
let state = state_over(inner.clone());
struct Works;
#[async_trait::async_trait(?Send)]
impl BlockAnchorer for Works {
async fn anchor(
&self,
ticker: &str,
state_hash: &str,
network: &str,
) -> Result<BlockTrailAnchor, ProvenanceError> {
Ok(BlockTrailAnchor {
ticker: ticker.into(),
state_hash: state_hash.into(),
txid: "ab".repeat(32),
vout: 0,
address: String::new(),
network: network.into(),
blockheight: None,
state_strings: vec![],
pubkey: None,
})
}
async fn verify(&self, _: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
Ok(true)
}
}
let anchorer: Arc<dyn BlockAnchorer> = Arc::new(Works);
let closed = epoch_push_and_maybe_anchor(&state, &anchorer, "prov", "testnet4", "more")
.await
.unwrap()
.expect("the epoch must close once anchoring works again");
assert_eq!(closed.commits, backlog, "every backlogged commit is rooted");
assert!(epoch_on_disk(&inner).await.is_empty());
}
#[tokio::test]
async fn failed_epoch_close_keeps_the_commit_that_filled_it() {
let inner: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
let full: Vec<String> = (0..DEFAULT_EPOCH_SIZE - 1)
.map(|i| format!("c{i}"))
.collect();
save_epoch_commits(&inner, &full).await.unwrap();
let state = state_over(inner.clone());
let anchorer: Arc<dyn BlockAnchorer> = Arc::new(Failing);
let out = epoch_push_and_maybe_anchor(&state, &anchorer, "prov", "testnet4", "last").await;
assert!(out.is_err());
let kept = epoch_on_disk(&inner).await;
assert_eq!(kept.len(), DEFAULT_EPOCH_SIZE);
assert_eq!(kept.last().map(String::as_str), Some("last"));
}
async fn funded(sats: u64) -> (AppState, String) {
use solid_pod_rs::payments::{PaymentStore, ReceiptOutcome};
let inner: Arc<dyn Storage> = Arc::new(MemoryBackend::new());
let state = state_over(FaultStorage::new(inner, true));
let did = format!("did:nostr:{}", "ab".repeat(32));
let store = crate::handlers::pay::StoragePaymentStore::new(&*state.storage);
let mut ledger = store.read_ledger().await.unwrap();
let r = ledger
.credit_by_outpoint(&did, "satoshi", &"cd".repeat(32), 0, sats)
.unwrap();
assert_eq!(r, ReceiptOutcome::Applied);
store.write_ledger(&ledger).await.unwrap();
(state, did)
}
async fn balance(state: &AppState, did: &str) -> u64 {
use solid_pod_rs::payments::PaymentStore;
let store = crate::handlers::pay::StoragePaymentStore::new(&*state.storage);
store.read_ledger().await.unwrap().get_balance(did)
}
#[tokio::test]
async fn concurrent_debits_do_not_lose_an_update() {
let (state, did) = funded(100).await;
let (a, b) = tokio::join!(debit(&state, &did, 40), debit(&state, &did, 40));
assert!(a.is_ok() && b.is_ok());
assert_eq!(balance(&state, &did).await, 20);
}
#[tokio::test]
async fn concurrent_debit_and_refund_keep_both_effects() {
let (state, did) = funded(100).await;
let held = debit(&state, &did, 40).await.unwrap();
let (refunded, debited) = tokio::join!(refund(&state, held), debit(&state, &did, 30));
refunded.unwrap();
assert!(debited.is_ok());
assert_eq!(balance(&state, &did).await, 70);
}
#[tokio::test]
async fn concurrent_debits_cannot_overspend() {
let (state, did) = funded(100).await;
let (a, b) = tokio::join!(debit(&state, &did, 60), debit(&state, &did, 60));
assert_eq!(
a.is_ok() as u8 + b.is_ok() as u8,
1,
"exactly one debit fits"
);
assert_eq!(balance(&state, &did).await, 40);
}
}