use async_trait::async_trait;
use celestia_rpc::{BlobClient, Client, TxConfig};
use celestia_types::blob::{Blob, Commitment};
use celestia_types::nmt::Namespace;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use crate::da::{DaBackend, DaBackendId, DaBackendStatus, DaPointer};
use crate::error::{Result, StorageError};
pub struct CelestiaBackend {
client: Arc<Client>,
namespace: Namespace,
last_submission_ms: RwLock<Option<i64>>,
last_fetch_ms: RwLock<Option<i64>>,
}
impl CelestiaBackend {
pub fn new(client: Arc<Client>, namespace: Namespace) -> Self {
Self {
client,
namespace,
last_submission_ms: RwLock::new(None),
last_fetch_ms: RwLock::new(None),
}
}
pub async fn connect(
url: &str,
auth_token: &str,
namespace_id: &[u8],
) -> Result<Self> {
let namespace = Namespace::new_v0(namespace_id).map_err(|e| {
StorageError::Generic(format!("invalid Celestia namespace id: {e}"))
})?;
let client = Client::new(
url,
Some(auth_token),
Some(Duration::from_secs(10)),
Some(Duration::from_secs(60)),
)
.await
.map_err(|e| StorageError::Generic(format!("Celestia client connect failed: {e}")))?;
Ok(Self::new(Arc::new(client), namespace))
}
pub fn namespace(&self) -> Namespace {
self.namespace
}
fn resolve_namespace(&self, override_ns: &[u8]) -> Result<Namespace> {
if override_ns.is_empty() {
Ok(self.namespace)
} else {
Namespace::new_v0(override_ns).map_err(|e| {
StorageError::Generic(format!("invalid Celestia namespace override: {e}"))
})
}
}
}
fn encode_locator(height: u64, commitment: &Commitment) -> Vec<u8> {
let hex = hex::encode(commitment.hash());
format!("{height}:{hex}").into_bytes()
}
fn decode_locator(locator: &[u8]) -> Result<(u64, Vec<u8>)> {
let s = std::str::from_utf8(locator).map_err(|e| {
StorageError::InvalidValue(format!("Celestia locator not valid UTF-8: {e}"))
})?;
let (height_str, hex_str) = s.split_once(':').ok_or_else(|| {
StorageError::InvalidValue(format!(
"Celestia locator missing ':' separator: {s}"
))
})?;
let height = height_str.parse::<u64>().map_err(|e| {
StorageError::InvalidValue(format!("Celestia locator height invalid: {e}"))
})?;
let bytes = hex::decode(hex_str).map_err(|e| {
StorageError::InvalidValue(format!("Celestia locator commitment hex invalid: {e}"))
})?;
Ok((height, bytes))
}
fn commitment_from_bytes(bytes: &[u8]) -> Result<Commitment> {
if bytes.len() != 32 {
return Err(StorageError::InvalidValue(format!(
"Celestia commitment must be 32 bytes, got {}",
bytes.len()
)));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(bytes);
Ok(Commitment::new(arr))
}
fn now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[async_trait]
impl DaBackend for CelestiaBackend {
fn id(&self) -> DaBackendId {
DaBackendId::Celestia
}
fn status(&self) -> DaBackendStatus {
let last_submission_ms = self.last_submission_ms.try_read().ok().and_then(|g| *g);
let last_fetch_ms = self.last_fetch_ms.try_read().ok().and_then(|g| *g);
DaBackendStatus {
backend: DaBackendId::Celestia,
healthy: true,
last_submission_ms,
last_fetch_ms,
error_rate_bps: 0,
}
}
async fn submit(&self, namespace: &[u8], payload: &[u8]) -> Result<DaPointer> {
let ns = self.resolve_namespace(namespace)?;
let blob = Blob::new(ns, payload.to_vec(), None).map_err(|e| {
StorageError::Generic(format!("failed to construct Celestia blob: {e}"))
})?;
let commitment = blob.commitment;
let height = self
.client
.blob_submit(&[blob], TxConfig::default())
.await
.map_err(|e| {
StorageError::Generic(format!("Celestia blob_submit failed: {e}"))
})?;
*self.last_submission_ms.write().await = Some(now_ms());
Ok(DaPointer {
backend: DaBackendId::Celestia,
namespace: namespace.to_vec(),
locator: encode_locator(height, &commitment),
commitment_kzg: Some(commitment.hash().to_vec()),
attestation_root: None,
})
}
async fn fetch(&self, pointer: &DaPointer) -> Result<Vec<u8>> {
if pointer.backend != DaBackendId::Celestia {
return Err(StorageError::InvalidValue(format!(
"CelestiaBackend cannot fetch pointer for backend {:?}",
pointer.backend
)));
}
let ns = self.resolve_namespace(&pointer.namespace)?;
let (height, commitment_bytes) = decode_locator(&pointer.locator)?;
let commitment = commitment_from_bytes(&commitment_bytes)?;
let blob = self
.client
.blob_get(height, ns, commitment)
.await
.map_err(|e| {
StorageError::Generic(format!("Celestia blob_get failed: {e}"))
})?;
*self.last_fetch_ms.write().await = Some(now_ms());
Ok(blob.data)
}
async fn verify_availability(&self, pointer: &DaPointer) -> Result<()> {
if pointer.backend != DaBackendId::Celestia {
return Err(StorageError::InvalidValue(format!(
"CelestiaBackend cannot verify pointer for backend {:?}",
pointer.backend
)));
}
let ns = self.resolve_namespace(&pointer.namespace)?;
let (height, commitment_bytes) = decode_locator(&pointer.locator)?;
let commitment = commitment_from_bytes(&commitment_bytes)?;
let proofs = self
.client
.blob_get_proof(height, ns, commitment)
.await
.map_err(|e| {
StorageError::Generic(format!("Celestia blob_get_proof failed: {e}"))
})?;
if proofs.is_empty() {
return Err(StorageError::Generic(
"Celestia returned empty proof set for blob".into(),
));
}
let included = self
.client
.blob_included(height, ns, &proofs[0], commitment)
.await
.map_err(|e| {
StorageError::Generic(format!("Celestia blob_included failed: {e}"))
})?;
if included {
Ok(())
} else {
Err(StorageError::Generic(format!(
"Celestia reports commitment not included at height {height}"
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::da::compute_commitment;
#[test]
fn locator_round_trip() {
let commitment = Commitment::new([0xab; 32]);
let encoded = encode_locator(123_456, &commitment);
let s = std::str::from_utf8(&encoded).unwrap();
assert!(s.starts_with("123456:"));
let (height, bytes) = decode_locator(&encoded).unwrap();
assert_eq!(height, 123_456);
assert_eq!(bytes, commitment.hash());
}
#[test]
fn locator_decode_rejects_garbage() {
assert!(decode_locator(b"no-colon-here").is_err());
assert!(decode_locator(b"notanumber:dead").is_err());
assert!(decode_locator(b"100:not-hex-zz").is_err());
}
#[test]
fn commitment_from_bytes_enforces_length() {
assert!(commitment_from_bytes(&[0u8; 32]).is_ok());
assert!(commitment_from_bytes(&[0u8; 31]).is_err());
assert!(commitment_from_bytes(&[0u8; 33]).is_err());
}
#[tokio::test]
#[ignore = "requires CELESTIA_RPC_URL + CELESTIA_AUTH_TOKEN env vars and a reachable celestia-node"]
async fn live_submit_fetch_verify_round_trip() {
let url = match std::env::var("CELESTIA_RPC_URL") {
Ok(u) => u,
Err(_) => {
eprintln!("skipping: CELESTIA_RPC_URL not set");
return;
}
};
let auth = match std::env::var("CELESTIA_AUTH_TOKEN") {
Ok(t) => t,
Err(_) => {
eprintln!("skipping: CELESTIA_AUTH_TOKEN not set");
return;
}
};
let backend = CelestiaBackend::connect(&url, &auth, b"tenzro-test")
.await
.expect("connect");
let payload = b"tenzro celestia DA round-trip smoke test".to_vec();
let sha = compute_commitment(&payload);
let pointer = backend.submit(b"", &payload).await.expect("submit");
assert_eq!(pointer.backend, DaBackendId::Celestia);
assert!(pointer.commitment_kzg.is_some());
assert_ne!(
pointer.commitment_kzg.as_ref().unwrap().as_slice(),
sha.as_bytes()
);
let fetched = backend.fetch(&pointer).await.expect("fetch");
assert_eq!(fetched, payload);
assert_eq!(compute_commitment(&fetched), sha);
backend
.verify_availability(&pointer)
.await
.expect("verify_availability");
}
}